Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce new cache.batch(options) method for InMemoryCache. #7819

Merged
merged 6 commits into from Mar 11, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Expand Up @@ -13,6 +13,9 @@ TBD
- `InMemoryCache` now _guarantees_ that any two result objects returned by the cache (from `readQuery`, `readFragment`, etc.) will be referentially equal (`===`) if they are deeply equal. Previously, `===` equality was often achievable for results for the same query, on a best-effort basis. Now, equivalent result objects will be automatically shared among the result trees of completely different queries. This guarantee is important for taking full advantage of optimistic updates that correctly guess the final data, and for "pure" UI components that can skip re-rendering when their input data are unchanged. <br/>
[@benjamn](https://github.com/benjamn) in [#7439](https://github.com/apollographql/apollo-client/pull/7439)

- `InMemoryCache` supports a new method called `batch`, which is similar to `performTransaction` but takes named options rather than positional parameters. One of these named options is an `onDirty(watch, diff)` callback, which can be used to determine which watched queries were invalidated by the `batch` operation. <br/>
[@benjamn](https://github.com/benjamn) in [#7819](https://github.com/apollographql/apollo-client/pull/7819)

- Support `client.refetchQueries` as an imperative way to refetch queries, without having to pass `options.refetchQueries` to `client.mutate`. <br/>
[@dannycochran](https://github.com/dannycochran) in [#7431](https://github.com/apollographql/apollo-client/pull/7431)

Expand Down
2 changes: 1 addition & 1 deletion package.json
Expand Up @@ -57,7 +57,7 @@
{
"name": "apollo-client",
"path": "./dist/apollo-client.cjs.min.js",
"maxSize": "26.1 kB"
"maxSize": "26.4 kB"
}
],
"peerDependencies": {
Expand Down
34 changes: 34 additions & 0 deletions src/cache/core/cache.ts
Expand Up @@ -11,6 +11,28 @@ import { Cache } from './types/Cache';

export type Transaction<T> = (c: ApolloCache<T>) => void;

export type BatchOptions<C extends ApolloCache<any>> = {
// Same as the first parameter of performTransaction, except the cache
// argument will have the subclass type rather than ApolloCache.
transaction(cache: C): void;

// Passing a string for this option creates a new optimistic layer with
// that string as its layer.id, just like passing a string for the
// optimisticId parameter of performTransaction. Passing true is the
// same as passing undefined to performTransaction, and passing false is
// the same as passing null.
optimistic: string | boolean;

// If you want to find out which watched queries were invalidated during
// this batch operation, pass this optional callback function. Returning
// false from the callback will prevent broadcasting this result.
onDirty?: (
this: C,
watch: Cache.WatchOptions,
diff: Cache.DiffResult<any>,
) => void | false;
};

export abstract class ApolloCache<TSerialized> implements DataProxy {
// required to implement
// core API
Expand Down Expand Up @@ -54,6 +76,18 @@ export abstract class ApolloCache<TSerialized> implements DataProxy {

// Transactional API

// The batch method is intended to replace/subsume both performTransaction
// and recordOptimisticTransaction, but performTransaction came first, so we
// provide a default batch implementation that's just another way of calling
// performTransaction. Subclasses of ApolloCache (such as InMemoryCache) can
// override the batch method to do more interesting things with its options.
public batch(options: BatchOptions<this>) {
const optimisticId =
typeof options.optimistic === "string" ? options.optimistic :
options.optimistic === false ? null : void 0;
this.performTransaction(options.transaction, optimisticId);
}

public abstract performTransaction(
transaction: Transaction<TSerialized>,
// Although subclasses may implement recordOptimisticTransaction
Expand Down
192 changes: 190 additions & 2 deletions src/cache/inmemory/__tests__/cache.ts
Expand Up @@ -2,7 +2,7 @@ import gql, { disableFragmentWarnings } from 'graphql-tag';

import { stripSymbols } from '../../../utilities/testing/stripSymbols';
import { cloneDeep } from '../../../utilities/common/cloneDeep';
import { makeReference, Reference, makeVar, TypedDocumentNode, isReference } from '../../../core';
import { makeReference, Reference, makeVar, TypedDocumentNode, isReference, DocumentNode } from '../../../core';
import { Cache } from '../../../cache';
import { InMemoryCache, InMemoryCacheConfig } from '../inMemoryCache';

Expand Down Expand Up @@ -1327,6 +1327,194 @@ describe('Cache', () => {
);
});

describe('batch', () => {
const last = <E>(array: E[]) => array[array.length - 1];

function watch(cache: InMemoryCache, query: DocumentNode) {
const options: Cache.WatchOptions = {
query,
optimistic: true,
immediate: true,
callback(diff) {
diffs.push(diff);
},
};
const diffs: Cache.DiffResult<any>[] = [];
const cancel = cache.watch(options);
diffs.shift(); // Discard the immediate diff
return { diffs, watch: options, cancel };
}

it('calls onDirty for each invalidated watch', () => {
const cache = new InMemoryCache;

const aQuery = gql`query { a }`;
const abQuery = gql`query { a b }`;
const bQuery = gql`query { b }`;

const aInfo = watch(cache, aQuery);
const abInfo = watch(cache, abQuery);
const bInfo = watch(cache, bQuery);

const dirtied = new Map<Cache.WatchOptions, Cache.DiffResult<any>>();

cache.batch({
transaction(cache) {
cache.writeQuery({
query: aQuery,
data: {
a: "ay",
},
});
},
optimistic: true,
onDirty(w, diff) {
dirtied.set(w, diff);
},
});

expect(dirtied.size).toBe(2);
expect(dirtied.has(aInfo.watch)).toBe(true);
expect(dirtied.has(abInfo.watch)).toBe(true);
expect(dirtied.has(bInfo.watch)).toBe(false);

expect(aInfo.diffs.length).toBe(1);
expect(last(aInfo.diffs)).toEqual({
complete: true,
result: {
a: "ay",
},
});

expect(abInfo.diffs.length).toBe(1);
expect(last(abInfo.diffs)).toEqual({
complete: false,
missing: expect.any(Array),
result: {
a: "ay",
},
});

expect(bInfo.diffs.length).toBe(0);

dirtied.clear();

cache.batch({
transaction(cache) {
cache.writeQuery({
query: bQuery,
data: {
b: "bee",
},
});
},
optimistic: true,
onDirty(w, diff) {
dirtied.set(w, diff);
},
});

expect(dirtied.size).toBe(2);
expect(dirtied.has(aInfo.watch)).toBe(false);
expect(dirtied.has(abInfo.watch)).toBe(true);
expect(dirtied.has(bInfo.watch)).toBe(true);

expect(aInfo.diffs.length).toBe(1);
expect(last(aInfo.diffs)).toEqual({
complete: true,
result: {
a: "ay",
},
});

expect(abInfo.diffs.length).toBe(2);
expect(last(abInfo.diffs)).toEqual({
complete: true,
result: {
a: "ay",
b: "bee",
},
});

expect(bInfo.diffs.length).toBe(1);
expect(last(bInfo.diffs)).toEqual({
complete: true,
result: {
b: "bee",
},
});

aInfo.cancel();
abInfo.cancel();
bInfo.cancel();
});

it('works with cache.modify and INVALIDATE', () => {
const cache = new InMemoryCache;

const aQuery = gql`query { a }`;
const abQuery = gql`query { a b }`;
const bQuery = gql`query { b }`;

cache.writeQuery({
query: abQuery,
data: {
a: "ay",
b: "bee",
},
});

const aInfo = watch(cache, aQuery);
const abInfo = watch(cache, abQuery);
const bInfo = watch(cache, bQuery);

const dirtied = new Map<Cache.WatchOptions, Cache.DiffResult<any>>();

cache.batch({
transaction(cache) {
cache.modify({
fields: {
a(value, { INVALIDATE }) {
expect(value).toBe("ay");
return INVALIDATE;
},
},
});
},
optimistic: true,
onDirty(w, diff) {
dirtied.set(w, diff);
},
});

expect(dirtied.size).toBe(2);
expect(dirtied.has(aInfo.watch)).toBe(true);
expect(dirtied.has(abInfo.watch)).toBe(true);
expect(dirtied.has(bInfo.watch)).toBe(false);

expect(last(aInfo.diffs)).toEqual({
complete: true,
result: {
a: "ay",
},
});

expect(last(abInfo.diffs)).toEqual({
complete: true,
result: {
a: "ay",
b: "bee",
},
});

expect(bInfo.diffs.length).toBe(0);

aInfo.cancel();
abInfo.cancel();
bInfo.cancel();
});
});

describe('performTransaction', () => {
itWithInitialData('will not broadcast mid-transaction', [{}], cache => {
let numBroadcasts = 0;
Expand Down Expand Up @@ -1373,7 +1561,7 @@ describe('Cache', () => {
});
});

describe('performOptimisticTransaction', () => {
describe('recordOptimisticTransaction', () => {
itWithInitialData('will only broadcast once', [{}], cache => {
let numBroadcasts = 0;

Expand Down
10 changes: 10 additions & 0 deletions src/cache/inmemory/entityStore.ts
Expand Up @@ -167,6 +167,16 @@ export abstract class EntityStore implements NormalizedCache {
}
});

if (fieldsToDirty.__typename &&
!(existing && existing.__typename) &&
// Since we return default root __typename strings
// automatically from store.get, we don't need to dirty the
// ROOT_QUERY.__typename field if merged.__typename is equal
// to the default string (usually "Query").
this.policies.rootTypenamesById[dataId] === merged.__typename) {
delete fieldsToDirty.__typename;
}

Object.keys(fieldsToDirty).forEach(
fieldName => this.group.dirty(dataId as string, fieldName));
}
Expand Down