diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e4c5bda4c7..dc2b65dc9b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,9 @@ - The `cache.evict` method can optionally take an arguments object as its third parameter (following the entity ID and field name), to delete only those field values with specific arguments.
[@danReynolds](https://github.com/danReynolds) in [#6141](https://github.com/apollographql/apollo-client/pull/6141) +- Cache methods that would normally trigger a broadcast, like `cache.evict`, `cache.writeQuery`, and `cache.writeFragment`, can now be called with a named options object, which supports a `broadcast: boolean` property that can be used to silence the broadcast, for situations where you want to update the cache multiple times without triggering a broadcast each time.
+ [@benjamn](https://github.com/benjamn) in [#6288](https://github.com/apollographql/apollo-client/pull/6288) + - The contents of the `@apollo/react-hooks` package have been merged into `@apollo/client`, enabling the following all-in-one `import`: ```ts import { ApolloClient, ApolloProvider, useQuery } from '@apollo/client'; diff --git a/src/cache/core/__tests__/cache.ts b/src/cache/core/__tests__/cache.ts index 1cfeae93845..04b3cdb6bfe 100644 --- a/src/cache/core/__tests__/cache.ts +++ b/src/cache/core/__tests__/cache.ts @@ -11,7 +11,7 @@ class TestCache extends ApolloCache { return {}; } - public evict(dataId: string, fieldName?: string): boolean { + public evict(): boolean { return false; } diff --git a/src/cache/core/cache.ts b/src/cache/core/cache.ts index 581653b8e7f..ff0415d9b73 100644 --- a/src/cache/core/cache.ts +++ b/src/cache/core/cache.ts @@ -22,10 +22,20 @@ export abstract class ApolloCache implements DataProxy { public abstract watch(watch: Cache.WatchOptions): () => void; public abstract reset(): Promise; - // If called with only one argument, removes the entire entity - // identified by dataId. If called with a fieldName as well, removes all - // fields of the identified entity whose store names match fieldName. - public abstract evict(dataId: string, fieldName?: string): boolean; + // Remove whole objects from the cache by passing just options.id, or + // specific fields by passing options.field and/or options.args. If no + // options.args are provided, all fields matching options.field (even + // those with arguments) will be removed. Returns true iff any data was + // removed from the cache. + public abstract evict(options: Cache.EvictOptions): boolean; + + // For backwards compatibility, evict can also take positional + // arguments. Please prefer the Cache.EvictOptions style (above). + public abstract evict( + id: string, + field?: string, + args?: Record, + ): boolean; // intializer / offline / ssr API /** @@ -129,6 +139,7 @@ export abstract class ApolloCache implements DataProxy { result: options.data, query: options.query, variables: options.variables, + broadcast: options.broadcast, }); } @@ -140,6 +151,7 @@ export abstract class ApolloCache implements DataProxy { result: options.data, variables: options.variables, query: this.getFragmentDoc(options.fragment, options.fragmentName), + broadcast: options.broadcast, }); } } diff --git a/src/cache/core/types/Cache.ts b/src/cache/core/types/Cache.ts index 41bab402bab..55d7e4279c8 100644 --- a/src/cache/core/types/Cache.ts +++ b/src/cache/core/types/Cache.ts @@ -1,7 +1,7 @@ import { DataProxy } from './DataProxy'; export namespace Cache { - export type WatchCallback = (newData: any) => void; + export type WatchCallback = (diff: Cache.DiffResult) => void; export interface ReadOptions extends DataProxy.Query { @@ -14,6 +14,7 @@ export namespace Cache { extends DataProxy.Query { dataId: string; result: TResult; + broadcast?: boolean; } export interface DiffOptions extends ReadOptions { @@ -25,6 +26,13 @@ export namespace Cache { callback: WatchCallback; } + export interface EvictOptions { + id: string; + fieldName?: string; + args?: Record; + broadcast?: boolean; + } + export import DiffResult = DataProxy.DiffResult; export import WriteQueryOptions = DataProxy.WriteQueryOptions; export import WriteFragmentOptions = DataProxy.WriteFragmentOptions; diff --git a/src/cache/core/types/DataProxy.ts b/src/cache/core/types/DataProxy.ts index b20038e4bcd..d9da863b13c 100644 --- a/src/cache/core/types/DataProxy.ts +++ b/src/cache/core/types/DataProxy.ts @@ -59,6 +59,10 @@ export namespace DataProxy { * The data you will be writing to the store. */ data: TData; + /** + * Whether to notify query watchers (default: true). + */ + broadcast?: boolean; } export interface WriteFragmentOptions @@ -67,6 +71,10 @@ export namespace DataProxy { * The data you will be writing to the store. */ data: TData; + /** + * Whether to notify query watchers (default: true). + */ + broadcast?: boolean; } export type DiffResult = { diff --git a/src/cache/inmemory/__tests__/entityStore.ts b/src/cache/inmemory/__tests__/entityStore.ts index 26bd3f60938..4e20c5bda6d 100644 --- a/src/cache/inmemory/__tests__/entityStore.ts +++ b/src/cache/inmemory/__tests__/entityStore.ts @@ -925,13 +925,13 @@ describe('EntityStore', () => { publisherOfBook: MelvilleData, }); - cache.evict( - cache.identify({ + cache.evict({ + id: cache.identify({ __typename: "Publisher", name: "Alfred A. Knopf", })!, - "yearOfFounding", - ); + fieldName: "yearOfFounding", + }); expect(cache.extract()).toEqual({ ROOT_QUERY: { @@ -951,10 +951,12 @@ describe('EntityStore', () => { // Nothing to garbage collect yet. expect(cache.gc()).toEqual([]); - cache.evict(cache.identify({ - __typename: "Publisher", - name: "Melville House", - })!); + cache.evict({ + id: cache.identify({ + __typename: "Publisher", + name: "Melville House", + })!, + }); expect(cache.extract()).toEqual({ ROOT_QUERY: { @@ -970,7 +972,7 @@ describe('EntityStore', () => { // Melville House has been removed }); - cache.evict("ROOT_QUERY", "publisherOfBook"); + cache.evict({ id: "ROOT_QUERY", fieldName: "publisherOfBook" }); function withoutPublisherOfBook(obj: Record) { const clean = { ...obj }; @@ -1049,10 +1051,10 @@ describe('EntityStore', () => { name: "Ted Chiang", }; - cache.evict( - cache.identify(tedWithoutHobby)!, - "hobby", - ); + cache.evict({ + id: cache.identify(tedWithoutHobby)!, + fieldName: "hobby", + }); expect(cache.diff({ query, @@ -1082,7 +1084,7 @@ describe('EntityStore', () => { ], }); - cache.evict("ROOT_QUERY", "authorOfBook"); + cache.evict({ id: "ROOT_QUERY", fieldName: "authorOfBook"}); expect(cache.gc().sort()).toEqual([ 'Author:{"name":"Jenny Odell"}', 'Author:{"name":"Ted Chiang"}', @@ -1232,6 +1234,158 @@ describe('EntityStore', () => { }); }); + it("allows evicting specific fields with specific arguments using EvictOptions", () => { + const query: DocumentNode = gql` + query { + authorOfBook(isbn: $isbn) { + name + hobby + } + } + `; + + const cache = new InMemoryCache(); + + const TedChiangData = { + __typename: "Author", + name: "Ted Chiang", + hobby: "video games", + }; + + const IsaacAsimovData = { + __typename: "Author", + name: "Isaac Asimov", + hobby: "chemistry", + }; + + const JamesCoreyData = { + __typename: "Author", + name: "James S.A. Corey", + hobby: "tabletop games", + }; + + cache.writeQuery({ + query, + data: { + authorOfBook: TedChiangData, + }, + variables: { + isbn: "1", + }, + }); + + cache.writeQuery({ + query, + data: { + authorOfBook: IsaacAsimovData, + }, + variables: { + isbn: "2", + }, + }); + + cache.writeQuery({ + query, + data: { + authorOfBook: JamesCoreyData, + }, + variables: {}, + }); + + expect(cache.extract()).toEqual({ + ROOT_QUERY: { + __typename: "Query", + "authorOfBook({\"isbn\":\"1\"})": { + __typename: "Author", + name: "Ted Chiang", + hobby: "video games", + }, + "authorOfBook({\"isbn\":\"2\"})": { + __typename: "Author", + name: "Isaac Asimov", + hobby: "chemistry", + }, + "authorOfBook({})": { + __typename: "Author", + name: "James S.A. Corey", + hobby: "tabletop games", + } + }, + }); + + cache.evict({ + id: 'ROOT_QUERY', + fieldName: 'authorOfBook', + args: { isbn: "1" }, + }); + + expect(cache.extract()).toEqual({ + ROOT_QUERY: { + __typename: "Query", + "authorOfBook({\"isbn\":\"2\"})": { + __typename: "Author", + name: "Isaac Asimov", + hobby: "chemistry", + }, + "authorOfBook({})": { + __typename: "Author", + name: "James S.A. Corey", + hobby: "tabletop games", + } + }, + }); + + cache.evict({ + id: 'ROOT_QUERY', + fieldName: 'authorOfBook', + args: { isbn: '3' }, + }); + + expect(cache.extract()).toEqual({ + ROOT_QUERY: { + __typename: "Query", + "authorOfBook({\"isbn\":\"2\"})": { + __typename: "Author", + name: "Isaac Asimov", + hobby: "chemistry", + }, + "authorOfBook({})": { + __typename: "Author", + name: "James S.A. Corey", + hobby: "tabletop games", + } + }, + }); + + cache.evict({ + id: 'ROOT_QUERY', + fieldName: 'authorOfBook', + args: {}, + }); + + expect(cache.extract()).toEqual({ + ROOT_QUERY: { + __typename: "Query", + "authorOfBook({\"isbn\":\"2\"})": { + __typename: "Author", + name: "Isaac Asimov", + hobby: "chemistry", + }, + }, + }); + + cache.evict({ + id: 'ROOT_QUERY', + fieldName: 'authorOfBook', + }); + + expect(cache.extract()).toEqual({ + ROOT_QUERY: { + __typename: "Query", + }, + }); + }); + it("supports cache.identify(object)", () => { const queryWithAliases: DocumentNode = gql` query { diff --git a/src/cache/inmemory/__tests__/writeToStore.ts b/src/cache/inmemory/__tests__/writeToStore.ts index b120144c787..1dbbef8d0d9 100644 --- a/src/cache/inmemory/__tests__/writeToStore.ts +++ b/src/cache/inmemory/__tests__/writeToStore.ts @@ -15,6 +15,7 @@ import { } from '../../../utilities/graphql/storeUtils'; import { addTypenameToDocument } from '../../../utilities/graphql/transform'; import { cloneDeep } from '../../../utilities/common/cloneDeep'; +import { itAsync } from '../../../utilities/testing/itAsync'; import { StoreWriter } from '../writeToStore'; import { defaultNormalizedCacheFactory } from '../entityStore'; import { InMemoryCache } from '../inMemoryCache'; @@ -2085,4 +2086,122 @@ describe('writing to the store', () => { expect(mergeCounts).toEqual({ first: 1, second: 1, third: 1, fourth: 1 }); }); + + itAsync("should allow silencing broadcast of cache updates", function (resolve, reject) { + const cache = new InMemoryCache({ + typePolicies: { + Counter: { + // Counter is a singleton, but we want to be able to test + // writing to it with writeFragment, so it needs to have an ID. + keyFields: [], + }, + }, + }); + + const query = gql` + query { + counter { + count + } + } + `; + + const results: number[] = []; + + cache.watch({ + query, + optimistic: true, + callback(diff) { + results.push(diff.result); + expect(diff.result).toEqual({ + counter: { + __typename: "Counter", + count: 3, + }, + }); + resolve(); + }, + }); + + let count = 0; + + cache.writeQuery({ + query, + data: { + counter: { + __typename: "Counter", + count: ++count, + }, + }, + broadcast: false, + }); + + expect(cache.extract()).toEqual({ + ROOT_QUERY: { + __typename: "Query", + counter: { __ref: "Counter:{}" }, + }, + "Counter:{}": { + __typename: "Counter", + count: 1, + }, + }); + + expect(results).toEqual([]); + + const counterId = cache.identify({ + __typename: "Counter", + })!; + + cache.writeFragment({ + id: counterId, + fragment: gql`fragment Count on Counter { count }`, + data: { + count: ++count, + }, + broadcast: false, + }); + + expect(cache.extract()).toEqual({ + ROOT_QUERY: { + __typename: "Query", + counter: { __ref: "Counter:{}" }, + }, + "Counter:{}": { + __typename: "Counter", + count: 2, + }, + }); + + expect(results).toEqual([]); + + expect(cache.evict({ + id: counterId, + fieldName: "count", + broadcast: false, + })).toBe(true); + + expect(cache.extract()).toEqual({ + ROOT_QUERY: { + __typename: "Query", + counter: { __ref: "Counter:{}" }, + }, + "Counter:{}": { + __typename: "Counter", + }, + }); + + expect(results).toEqual([]); + + // Only this write should trigger a broadcast. + cache.writeQuery({ + query, + data: { + counter: { + __typename: "Counter", + count: 3, + }, + }, + }); + }); }); diff --git a/src/cache/inmemory/entityStore.ts b/src/cache/inmemory/entityStore.ts index 2cb7ba74f2f..e7ac5564aa9 100644 --- a/src/cache/inmemory/entityStore.ts +++ b/src/cache/inmemory/entityStore.ts @@ -14,7 +14,8 @@ import { canUseWeakMap } from '../../utilities/common/canUse'; import { NormalizedCache, NormalizedCacheObject } from './types'; import { fieldNameFromStoreName } from './helpers'; import { Policies } from './policies'; -import { Modifier, Modifiers, SafeReadonly } from '../core/types/common'; +import { Modifier, Modifiers, SafeReadonly } from '../core/types/common'; +import { Cache } from '../core/types/Cache'; const hasOwn = Object.prototype.hasOwnProperty; @@ -197,23 +198,19 @@ export abstract class EntityStore implements NormalizedCache { return false; } - public evict( - dataId: string, - fieldName?: string, - args?: Record, - ): boolean { + public evict(options: Cache.EvictOptions): boolean { let evicted = false; - if (hasOwn.call(this.data, dataId)) { - evicted = this.delete(dataId, fieldName, args); + if (hasOwn.call(this.data, options.id)) { + evicted = this.delete(options.id, options.fieldName, options.args); } if (this instanceof Layer) { - evicted = this.parent.evict(dataId, fieldName, args) || evicted; + evicted = this.parent.evict(options) || evicted; } // Always invalidate the field to trigger rereading of watched // queries, even if no cache data was modified by the eviction, // because queries may depend on computed fields with custom read // functions, whose values are not stored in the EntityStore. - this.group.dirty(dataId, fieldName || "__exists"); + this.group.dirty(options.id, options.fieldName || "__exists"); return evicted; } diff --git a/src/cache/inmemory/inMemoryCache.ts b/src/cache/inmemory/inMemoryCache.ts index c3b5e22c879..22d797c924c 100644 --- a/src/cache/inmemory/inMemoryCache.ts +++ b/src/cache/inmemory/inMemoryCache.ts @@ -146,7 +146,9 @@ export class InMemoryCache extends ApolloCache { variables: options.variables, }); - this.broadcastWatches(); + if (options.broadcast !== false) { + this.broadcastWatches(); + } } public modify( @@ -224,12 +226,21 @@ export class InMemoryCache extends ApolloCache { } public evict( - dataId: string, + idOrOptions: string | Cache.EvictOptions, fieldName?: string, args?: Record, ): boolean { - const evicted = this.optimisticData.evict(dataId, fieldName, args); - this.broadcastWatches(); + const evicted = this.optimisticData.evict( + typeof idOrOptions === "string" ? { + id: idOrOptions, + fieldName, + args, + } : idOrOptions, + ); + if (typeof idOrOptions === "string" || + idOrOptions.broadcast !== false) { + this.broadcastWatches(); + } return evicted; }