From 251ffae36bf910c415e0a6804358fb25ee08495f Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 1 Dec 2020 14:00:14 -0500 Subject: [PATCH 001/380] Add --tag beta to npm publish command for release-3.4. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 086fbb7160e..6479e83f97e 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "coverage": "jest --config ./config/jest.config.js --verbose --coverage", "bundlesize": "npm run build && bundlesize", "predeploy": "npm run build", - "deploy": "cd dist && npm publish" + "deploy": "cd dist && npm publish --tag beta" }, "bundlesize": [ { From 587934f4d7e0bed4172429ce9a0b6e142ffcb8b8 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 1 Dec 2020 14:01:43 -0500 Subject: [PATCH 002/380] Skeleton CHANGELOG.md entry for v3.4.0. --- CHANGELOG.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e63c2bcdc6..3e7bd3150d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,14 @@ +## Apollo Client 3.4.0 (not yet released) + +## Bug fixes +TBD + +## Improvements +TBD + +## Documentation +TBD + ## Apollo Client 3.3.2 > ⚠️ **Note:** This version of `@apollo/client` contains no behavioral changes since version 3.3.1 From 8d265e62d3916582eba45c4ef2ec548fe04a3848 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 1 Dec 2020 14:02:22 -0500 Subject: [PATCH 003/380] Bump @apollo/client npm version to 3.4.0-beta.0. --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3114c5cc3d9..e9ae1e16009 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.3.2", + "version": "3.4.0-beta.0", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 6479e83f97e..e36c87bceb9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.3.2", + "version": "3.4.0-beta.0", "description": "A fully-featured caching GraphQL client.", "private": true, "keywords": [ From 2a2843c86347928a49cc034ae71514f6c0532463 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 1 Dec 2020 20:13:49 -0500 Subject: [PATCH 004/380] Set both Symbol.species and "@@species" on Concast constructor (#7403) An attempt to address the problems I speculated about in https://github.com/apollographql/apollo-client/issues/6520#issuecomment-736899757 Theory: in the Hermes JS engine, Symbol.species is not defined, so Object.defineProperty was not called, but perhaps "@@species" was getting set somewhere else by a polyfill library, causing the zen-observable library to fall back to "@@species" instead of using/ignoring the nonexistent Symbol.species symbol. --- src/utilities/observables/Concast.ts | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/utilities/observables/Concast.ts b/src/utilities/observables/Concast.ts index b0d04939f77..2949a699d5e 100644 --- a/src/utilities/observables/Concast.ts +++ b/src/utilities/observables/Concast.ts @@ -243,11 +243,19 @@ export class Concast extends Observable { // Those methods assume (perhaps unwisely?) that they can call the // subtype's constructor with an observer registration function, but the // Concast constructor uses a different signature. Defining this -// Symbol.species getter function on the Concast constructor function is -// a hint to generic Observable code to use the default constructor -// instead of trying to do `new Concast(observer => ...)`. +// Symbol.species property on the Concast constructor function is a hint +// to generic Observable code to use the default constructor instead of +// trying to do `new Concast(observer => ...)`. +function setSpecies(key: symbol | string) { + // Object.defineProperty is necessary because Concast[Symbol.species] + // is a getter by default in modern JS environments, so we can't + // assign to it with a normal assignment expression. + Object.defineProperty(Concast, key, { value: Observable }); +} if (typeof Symbol === "function" && Symbol.species) { - Object.defineProperty(Concast, Symbol.species, { - value: Observable, - }); + setSpecies(Symbol.species); } +// The "@@species" string is used as a fake Symbol.species value in some +// polyfill systems (including the SymbolSpecies variable used by +// zen-observable), so we should set it as well, to be safe. +setSpecies("@@species"); From 22243ac5dd5ed5faafd5b4451fecb5e5653c9ce2 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Thu, 3 Dec 2020 10:49:30 -0500 Subject: [PATCH 005/380] Bump @apollo/client npm version to 3.4.0-beta.1. --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index e9ae1e16009..ac40cc3e224 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-beta.0", + "version": "3.4.0-beta.1", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index e36c87bceb9..44a2b3c6d8c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-beta.0", + "version": "3.4.0-beta.1", "description": "A fully-featured caching GraphQL client.", "private": true, "keywords": [ From 7059bb9521b6667788159ca6afb773ee44ed9093 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Fri, 4 Dec 2020 15:36:06 -0500 Subject: [PATCH 006/380] Bump @apollo/client npm version to 3.4.0-beta.2. --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4b5542791a5..37618ef20d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-beta.1", + "version": "3.4.0-beta.2", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index fed6dd4fd81..4d54486c5db 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-beta.1", + "version": "3.4.0-beta.2", "description": "A fully-featured caching GraphQL client.", "private": true, "keywords": [ From 4575e9f05bd2bca5f74b120e7c78383b5b285f9b Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Thu, 10 Dec 2020 10:30:50 -0500 Subject: [PATCH 007/380] Update 3.4.0 CHANGELOG.md headings, a la #7441. --- CHANGELOG.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f11424db65..eeb5be57e89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,12 @@ ## Apollo Client 3.4.0 (not yet released) -## Bug fixes +### Bug fixes TBD -## Improvements +### Improvements TBD -## Documentation +### Documentation TBD ## Apollo Client 3.3.4 From bc7e533c12754112fec0d14e04b6d241ca13178d Mon Sep 17 00:00:00 2001 From: Danny Cochran Date: Fri, 11 Dec 2020 10:11:06 -0800 Subject: [PATCH 008/380] Feature: support client.refetchQueries for refetching queries imperatively (#7431) --- docs/source/api/core/ApolloClient.mdx | 1 + src/__tests__/client.ts | 13 ++++ src/core/ApolloClient.ts | 19 +++++ src/core/QueryManager.ts | 63 ++++++++-------- src/core/__tests__/QueryManager/index.ts | 91 ++++++++++++++++++++++++ 5 files changed, 159 insertions(+), 28 deletions(-) diff --git a/docs/source/api/core/ApolloClient.mdx b/docs/source/api/core/ApolloClient.mdx index 992b4c903f2..a32ae6b9ced 100644 --- a/docs/source/api/core/ApolloClient.mdx +++ b/docs/source/api/core/ApolloClient.mdx @@ -101,6 +101,7 @@ different value for the same option in individual function calls. + ## Types diff --git a/src/__tests__/client.ts b/src/__tests__/client.ts index 9b38bd585cb..dcf4f745c8c 100644 --- a/src/__tests__/client.ts +++ b/src/__tests__/client.ts @@ -2426,6 +2426,19 @@ describe('client', () => { expect(spy).toHaveBeenCalled(); }); + it('has a refetchQueries method which calls QueryManager', async () => { + // TODO(dannycochran) + const client = new ApolloClient({ + link: ApolloLink.empty(), + cache: new InMemoryCache(), + }); + + // @ts-ignore + const spy = jest.spyOn(client.queryManager, 'refetchQueries'); + await client.refetchQueries(['Author1']); + expect(spy).toHaveBeenCalled(); + }); + itAsync('should propagate errors from network interface to observers', (resolve, reject) => { const link = ApolloLink.from([ () => diff --git a/src/core/ApolloClient.ts b/src/core/ApolloClient.ts index 70017b859a5..ec183fa7f0d 100644 --- a/src/core/ApolloClient.ts +++ b/src/core/ApolloClient.ts @@ -22,6 +22,7 @@ import { MutationOptions, SubscriptionOptions, WatchQueryFetchPolicy, + RefetchQueryDescription, } from './watchQueryOptions'; import { @@ -516,6 +517,24 @@ export class ApolloClient implements DataProxy { return this.queryManager.reFetchObservableQueries(includeStandby); } + /** + * Refetches specified active queries. Similar to "reFetchObservableQueries()" but with a specific list of queries. + * + * `refetchQueries()` is useful for use cases to imperatively refresh a selection of queries. + * + * It is important to remember that `refetchQueries()` *will* refetch specified active + * queries. This means that any components that might be mounted will execute + * their queries again using your network interface. If you do not want to + * re-execute any queries then you should make sure to stop watching any + * active queries. + * Takes optional parameter `includeStandby` which will include queries in standby-mode when refetching. + */ + public refetchQueries( + queries: RefetchQueryDescription, + ): Promise[]> { + return Promise.all(this.queryManager.refetchQueries(queries)); + } + /** * Exposes the cache's complete state, in a serializable format for later restoration. */ diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index 827b91b7128..abb70d60844 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -28,6 +28,7 @@ import { MutationOptions, WatchQueryFetchPolicy, ErrorPolicy, + RefetchQueryDescription, } from './watchQueryOptions'; import { ObservableQuery } from './ObservableQuery'; import { NetworkStatus, isNetworkRequestInFlight } from './networkStatus'; @@ -263,34 +264,7 @@ export class QueryManager { refetchQueries = refetchQueries(storeResult!); } - const refetchQueryPromises: Promise< - ApolloQueryResult[] | ApolloQueryResult<{}> - >[] = []; - - if (isNonEmptyArray(refetchQueries)) { - refetchQueries.forEach(refetchQuery => { - if (typeof refetchQuery === 'string') { - self.queries.forEach(({ observableQuery }) => { - if (observableQuery && - observableQuery.queryName === refetchQuery) { - refetchQueryPromises.push(observableQuery.refetch()); - } - }); - } else { - const queryOptions: QueryOptions = { - query: refetchQuery.query, - variables: refetchQuery.variables, - fetchPolicy: 'network-only', - }; - - if (refetchQuery.context) { - queryOptions.context = refetchQuery.context; - } - - refetchQueryPromises.push(self.query(queryOptions)); - } - }); - } + const refetchQueryPromises = self.refetchQueries(refetchQueries); Promise.all( awaitRefetchQueries ? refetchQueryPromises : [], @@ -1023,6 +997,39 @@ export class QueryManager { return concast; } + public refetchQueries(queries: RefetchQueryDescription): + Promise>[] { + const self = this; + const refetchQueryPromises: Promise>[] = []; + + if (isNonEmptyArray(queries)) { + queries.forEach(refetchQuery => { + if (typeof refetchQuery === 'string') { + self.queries.forEach(({ observableQuery }) => { + if (observableQuery && + observableQuery.queryName === refetchQuery) { + refetchQueryPromises.push(observableQuery.refetch()); + } + }); + } else { + const queryOptions: QueryOptions = { + query: refetchQuery.query, + variables: refetchQuery.variables, + fetchPolicy: 'network-only', + }; + + if (refetchQuery.context) { + queryOptions.context = refetchQuery.context; + } + + refetchQueryPromises.push(self.query(queryOptions)); + } + }); + } + + return refetchQueryPromises; + } + private fetchQueryByPolicy( queryInfo: QueryInfo, options: WatchQueryOptions, diff --git a/src/core/__tests__/QueryManager/index.ts b/src/core/__tests__/QueryManager/index.ts index db5669aa2f1..26e052834e9 100644 --- a/src/core/__tests__/QueryManager/index.ts +++ b/src/core/__tests__/QueryManager/index.ts @@ -4243,6 +4243,97 @@ describe('QueryManager', () => { }); }); + describe('refetching specified queries', () => { + itAsync('returns a promise resolving when all queries have been refetched', (resolve, reject) => { + const query = gql` + query GetAuthor { + author { + firstName + lastName + } + } + `; + + const data = { + author: { + firstName: 'John', + lastName: 'Smith', + }, + }; + + const dataChanged = { + author: { + firstName: 'John changed', + lastName: 'Smith', + }, + }; + + const query2 = gql` + query GetAuthor2 { + author2 { + firstName + lastName + } + } + `; + + const data2 = { + author2: { + firstName: 'John', + lastName: 'Smith', + }, + }; + + const data2Changed = { + author2: { + firstName: 'John changed', + lastName: 'Smith', + }, + }; + + const queryManager = createQueryManager({ + link: mockSingleLink({ + request: { query }, + result: { data }, + }, { + request: { query: query2 }, + result: { data: data2 }, + }, { + request: { query }, + result: { data: dataChanged }, + }, { + request: { query: query2 }, + result: { data: data2Changed }, + }).setOnError(reject), + }); + + const observable = queryManager.watchQuery({ query }); + const observable2 = queryManager.watchQuery({ query: query2 }); + + return Promise.all([ + observableToPromise({ observable }, result => + expect(stripSymbols(result.data)).toEqual(data), + ), + observableToPromise({ observable: observable2 }, result => + expect(stripSymbols(result.data)).toEqual(data2), + ), + ]).then(() => { + observable.subscribe({ next: () => null }); + observable2.subscribe({ next: () => null }); + + return Promise.all(queryManager.refetchQueries(['GetAuthor', 'GetAuthor2'])).then(() => { + const result = getCurrentQueryResult(observable); + expect(result.partial).toBe(false); + expect(stripSymbols(result.data)).toEqual(dataChanged); + + const result2 = getCurrentQueryResult(observable2); + expect(result2.partial).toBe(false); + expect(stripSymbols(result2.data)).toEqual(data2Changed); + }); + }).then(resolve, reject); + }); + }); + describe('loading state', () => { itAsync('should be passed as false if we are not watching a query', (resolve, reject) => { const query = gql` From 95fa3514e9a4de906f6c68ee4d9d4c1a4754ea68 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Fri, 11 Dec 2020 19:11:54 -0500 Subject: [PATCH 009/380] Mention PR #7431 in CHANGELOG.md. --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 66b88bfde4a..0bd817dc3b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,9 @@ TBD ### Improvements -TBD + +- Support `client.refetchQueries` as an imperative way to refetch queries, without having to pass `options.refetchQueries` to `client.mutate`.
+ [@dannycochran](https://github.com/dannycochran) in [#7431](https://github.com/apollographql/apollo-client/pull/7431) ### Documentation TBD From ad342a2b53149323ef706eef657bef1c9b0dc483 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Fri, 11 Dec 2020 19:12:43 -0500 Subject: [PATCH 010/380] Bump @apollo/client npm version to 3.4.0-beta.3. --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index e90d68b428a..14d2a6fb2fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-beta.2", + "version": "3.4.0-beta.3", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index a4460fd08c4..e9d5ba125e9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-beta.2", + "version": "3.4.0-beta.3", "description": "A fully-featured caching GraphQL client.", "private": true, "keywords": [ From 7ff1b31d3404eacf2de3c7862d98f70d42160510 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Mon, 23 Nov 2020 18:31:13 -0500 Subject: [PATCH 011/380] Efficiently canonicalize InMemoryCache result objects. https://github.com/apollographql/apollo-client/issues/4141#issuecomment-733091694 --- src/__tests__/client.ts | 12 +-- src/cache/inmemory/__tests__/cache.ts | 7 +- src/cache/inmemory/__tests__/optimistic.ts | 2 +- src/cache/inmemory/__tests__/policies.ts | 13 +-- src/cache/inmemory/canon.ts | 115 +++++++++++++++++++++ src/cache/inmemory/helpers.ts | 5 +- src/cache/inmemory/readFromStore.ts | 17 ++- src/core/__tests__/QueryManager/index.ts | 5 +- 8 files changed, 133 insertions(+), 43 deletions(-) create mode 100644 src/cache/inmemory/canon.ts diff --git a/src/__tests__/client.ts b/src/__tests__/client.ts index dcf4f745c8c..84536300382 100644 --- a/src/__tests__/client.ts +++ b/src/__tests__/client.ts @@ -3037,11 +3037,9 @@ describe('@connection', () => { client.cache.evict({ fieldName: "a" }); await wait(); - // The results are structurally the same, but the result objects have - // been recomputed for queries that involved the ROOT_QUERY.a field. - expect(checkLastResult(aResults, a456)).not.toBe(a456); + expect(checkLastResult(aResults, a456)).toBe(a456); expect(checkLastResult(bResults, bOyez)).toBe(bOyez); - expect(checkLastResult(abResults, a456bOyez)).not.toBe(a456bOyez); + expect(checkLastResult(abResults, a456bOyez)).toBe(a456bOyez); const cQuery = gql`{ c }`; // Passing cache-only as the fetchPolicy allows the { c: "see" } @@ -3090,16 +3088,12 @@ describe('@connection', () => { { a: 123 }, { a: 234 }, { a: 456 }, - // Delivered again because we explicitly called resetLastResults. - { a: 456 }, ]); expect(bResults).toEqual([ { b: "asdf" }, { b: "ASDF" }, { b: "oyez" }, - // Delivered again because we explicitly called resetLastResults. - { b: "oyez" }, ]); expect(abResults).toEqual([ @@ -3107,8 +3101,6 @@ describe('@connection', () => { { a: 234, b: "asdf" }, { a: 234, b: "ASDF" }, { a: 456, b: "oyez" }, - // Delivered again because we explicitly called resetLastResults. - { a: 456, b: "oyez" }, ]); expect(cResults).toEqual([ diff --git a/src/cache/inmemory/__tests__/cache.ts b/src/cache/inmemory/__tests__/cache.ts index f58542e4449..1e4089893c7 100644 --- a/src/cache/inmemory/__tests__/cache.ts +++ b/src/cache/inmemory/__tests__/cache.ts @@ -1728,8 +1728,8 @@ describe("InMemoryCache#modify", () => { })).toBe(false); // Nothing actually modified. const resultAfterAuthorInvalidation = read(); - expect(resultAfterAuthorInvalidation).not.toBe(initialResult); expect(resultAfterAuthorInvalidation).toEqual(initialResult); + expect(resultAfterAuthorInvalidation).toBe(initialResult); expect(cache.modify({ id: cache.identify({ @@ -1743,8 +1743,8 @@ describe("InMemoryCache#modify", () => { })).toBe(false); // Nothing actually modified. const resultAfterBookInvalidation = read(); - expect(resultAfterBookInvalidation).not.toBe(resultAfterAuthorInvalidation); expect(resultAfterBookInvalidation).toEqual(resultAfterAuthorInvalidation); + expect(resultAfterBookInvalidation).toBe(resultAfterAuthorInvalidation); expect(resultAfterBookInvalidation.currentlyReading.author).toEqual({ __typename: "Author", name: "Maria Dahvana Headley", @@ -2591,9 +2591,8 @@ describe("ReactiveVar and makeVar", () => { }); const result2 = cache.readQuery({ query }); - // Without resultCaching, equivalent results will not be ===. - expect(result2).not.toBe(result1); expect(result2).toEqual(result1); + expect(result2).toBe(result1); expect(nameVar()).toBe("Ben"); expect(nameVar("Hugh")).toBe("Hugh"); diff --git a/src/cache/inmemory/__tests__/optimistic.ts b/src/cache/inmemory/__tests__/optimistic.ts index b5d7b79a29f..8030d43ed49 100644 --- a/src/cache/inmemory/__tests__/optimistic.ts +++ b/src/cache/inmemory/__tests__/optimistic.ts @@ -431,7 +431,7 @@ describe('optimistic cache layers', () => { const resultAfterRemovingBuzzLayer = readWithAuthors(); expect(resultAfterRemovingBuzzLayer).toEqual(resultWithBuzz); - expect(resultAfterRemovingBuzzLayer).not.toBe(resultWithBuzz); + expect(resultAfterRemovingBuzzLayer).toBe(resultWithBuzz); resultWithTwoAuthors.books.forEach((book, i) => { expect(book).toEqual(resultAfterRemovingBuzzLayer.books[i]); expect(book).toBe(resultAfterRemovingBuzzLayer.books[i]); diff --git a/src/cache/inmemory/__tests__/policies.ts b/src/cache/inmemory/__tests__/policies.ts index 86315bbc84f..33fc3bc4b99 100644 --- a/src/cache/inmemory/__tests__/policies.ts +++ b/src/cache/inmemory/__tests__/policies.ts @@ -4692,19 +4692,8 @@ describe("type policies", function () { }); const thirdFirstBookResult = readFirstBookResult(); - - // A change in VW's books field triggers rereading of result objects - // that previously involved her books field. - expect(thirdFirstBookResult).not.toBe(secondFirstBookResult); - - // However, since the new Book was not the earliest published, the - // second and third results are structurally the same. expect(thirdFirstBookResult).toEqual(secondFirstBookResult); - - // In fact, the original author.firstBook object has been reused! - expect(thirdFirstBookResult.author.firstBook).toBe( - secondFirstBookResult.author.firstBook, - ); + expect(thirdFirstBookResult).toBe(secondFirstBookResult); }); it("readField can read fields with arguments", function () { diff --git a/src/cache/inmemory/canon.ts b/src/cache/inmemory/canon.ts new file mode 100644 index 00000000000..628dbcd79bf --- /dev/null +++ b/src/cache/inmemory/canon.ts @@ -0,0 +1,115 @@ +import { KeyTrie } from "optimism"; +import { canUseWeakMap } from "../../utilities"; +import { objToStr } from "./helpers"; + +// When we say an object is "canonical" in programming, we mean it has been +// admitted into some abstract "canon" of official/blessed objects. This +// Canon class is a representation of such a collection, with the property +// that canon.admit(value1) === canon.admit(value2) if value1 and value2 are +// deeply equal to each other. The canonicalization process involves looking +// at every property in the provided object tree, so it takes the same order +// of time as deep equality checking (linear time), but already-admitted +// objects are returned immediately from canon.admit, so ensuring subtrees +// have already been canonized tends to speed up canonicalization. Of +// course, since canonized objects may be shared widely between unrelated +// consumers, it's important to regard them as immutable. No detection of +// cycles is needed by the StoreReader class right now, so we don't bother +// keeping track of objects we've already seen during the recursion of the +// admit method. Objects whose internal class name is neither Array nor +// Object can be included in the value tree, but they will not be replaced +// with a canonical version (to put it another way, they are assumed to be +// canonical already). We can easily add additional cases to the switch +// statement to handle other common object types, such as "[object Date]" +// objects, as needed. +export class Canon { + // All known objects this Canon has admitted. + private known = new (canUseWeakMap ? WeakSet : Set)(); + + // Efficient storage/lookup structure for admitting objects. + private pool = new KeyTrie<{ + array?: any[]; + object?: Record; + keys?: SortedKeysInfo; + }>(canUseWeakMap); + + // Returns the canonical version of value. + public admit(value: T): T; + public admit(value: any) { + if (value && typeof value === "object") { + switch (objToStr.call(value)) { + case "[object Array]": { + if (this.known.has(value)) return value; + const array: any[] = value.map(this.admit, this); + // Arrays are looked up in the KeyTrie using their recursively + // canonicalized elements, and the known version of the array is + // preserved as node.array. + const node = this.pool.lookupArray(array); + if (!node.array) { + this.known.add(node.array = array); + if (process.env.NODE_ENV !== "production") { + Object.freeze(array); + } + } + return node.array; + } + + case "[object Object]": { + if (this.known.has(value)) return value; + const proto = Object.getPrototypeOf(value); + const array = [proto]; + const keys = this.sortedKeys(value); + array.push(keys.json); + keys.sorted.forEach(key => { + array.push(this.admit(value[key])); + }); + // Objects are looked up in the KeyTrie by their prototype + // (which is *not* recursively canonicalized), followed by a + // JSON representation of their (sorted) keys, followed by the + // sequence of recursively canonicalized values corresponding to + // those keys. To keep the final results unambiguous with other + // sequences (such as arrays that just happen to contain [proto, + // keys.json, value1, value2, ...]), the known version of the + // object is stored as node.object. + const node = this.pool.lookupArray(array); + if (!node.object) { + const obj = node.object = Object.create(proto); + this.known.add(obj); + keys.sorted.forEach((key, i) => { + obj[key] = array[i + 2]; + }); + if (process.env.NODE_ENV !== "production") { + Object.freeze(obj); + } + } + return node.object; + } + } + } + return value; + } + + // It's worthwhile to cache the sorting of arrays of strings, since the + // same initial unsorted arrays tend to be encountered many times. + // Fortunately, we can reuse the KeyTrie machinery to look up the sorted + // arrays in linear time (which is faster than sorting large arrays). + private sortedKeys(obj: object) { + const keys = Object.keys(obj); + const node = this.pool.lookupArray(keys); + if (!node.keys) { + keys.sort(); + const json = JSON.stringify(keys); + if (!(node.keys = this.keysByJSON.get(json))) { + this.keysByJSON.set(json, node.keys = { sorted: keys, json }); + } + } + return node.keys; + } + // Arrays that contain the same elements in a different order can share + // the same SortedKeysInfo object, to save memory. + private keysByJSON = new Map(); +} + +type SortedKeysInfo = { + sorted: string[]; + json: string; +}; diff --git a/src/cache/inmemory/helpers.ts b/src/cache/inmemory/helpers.ts index dc12bc65838..9777c8330a0 100644 --- a/src/cache/inmemory/helpers.ts +++ b/src/cache/inmemory/helpers.ts @@ -12,7 +12,10 @@ import { shouldInclude, } from '../../utilities'; -export const hasOwn = Object.prototype.hasOwnProperty; +export const { + hasOwnProperty: hasOwn, + toString: objToStr, +} = Object.prototype; export function getTypenameFromStoreObject( store: NormalizedCache, diff --git a/src/cache/inmemory/readFromStore.ts b/src/cache/inmemory/readFromStore.ts index 1abafb40f30..464a50a4548 100644 --- a/src/cache/inmemory/readFromStore.ts +++ b/src/cache/inmemory/readFromStore.ts @@ -36,6 +36,7 @@ import { getTypenameFromStoreObject } from './helpers'; import { Policies } from './policies'; import { InMemoryCache } from './inMemoryCache'; import { MissingFieldError } from '../core/types/common'; +import { Canon } from './canon'; export type VariableMap = { [name: string]: any }; @@ -324,11 +325,7 @@ export class StoreReader { // Perform a single merge at the end so that we can avoid making more // defensive shallow copies than necessary. - finalResult.result = mergeDeepArray(objectsToMerge); - - if (process.env.NODE_ENV !== 'production') { - Object.freeze(finalResult.result); - } + finalResult.result = this.canon.admit(mergeDeepArray(objectsToMerge)); // Store this result with its selection set so that we can quickly // recognize it again in the StoreReader#isFresh method. @@ -337,6 +334,8 @@ export class StoreReader { return finalResult; } + private canon = new Canon; + private knownResults = new WeakMap, SelectionSetNode>(); // Cached version of execSubSelectedArrayImpl. @@ -377,7 +376,7 @@ export class StoreReader { array = array.filter(context.store.canRead); } - array = array.map((item, i) => { + array = this.canon.admit(array.map((item, i) => { // null value in array if (item === null) { return null; @@ -410,11 +409,7 @@ export class StoreReader { invariant(context.path.pop() === i); return item; - }); - - if (process.env.NODE_ENV !== 'production') { - Object.freeze(array); - } + })); return { result: array, missing }; } diff --git a/src/core/__tests__/QueryManager/index.ts b/src/core/__tests__/QueryManager/index.ts index 26e052834e9..66cfb7c86cb 100644 --- a/src/core/__tests__/QueryManager/index.ts +++ b/src/core/__tests__/QueryManager/index.ts @@ -902,10 +902,7 @@ describe('QueryManager', () => { break; case 2: expect(stripSymbols(result.data)).toEqual(data3); - expect(result.data).not.toBe(firstResultData); - expect(result.data.b).toEqual(firstResultData.b); - expect(result.data.d).not.toBe(firstResultData.d); - expect(result.data.d.f).toEqual(firstResultData.d.f); + expect(result.data).toBe(firstResultData); resolve(); break; default: From 789f46b83a0009d428e277f45a312c1f5b670b07 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 15 Dec 2020 15:42:10 -0500 Subject: [PATCH 012/380] Pass scalar field values through without canonicalizing them. --- src/cache/inmemory/canon.ts | 15 +++++++++++++++ src/cache/inmemory/readFromStore.ts | 16 ++++------------ 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/src/cache/inmemory/canon.ts b/src/cache/inmemory/canon.ts index 628dbcd79bf..f3b20e52ef5 100644 --- a/src/cache/inmemory/canon.ts +++ b/src/cache/inmemory/canon.ts @@ -2,6 +2,10 @@ import { KeyTrie } from "optimism"; import { canUseWeakMap } from "../../utilities"; import { objToStr } from "./helpers"; +class Pass { + constructor(public readonly value: T) {} +} + // When we say an object is "canonical" in programming, we mean it has been // admitted into some abstract "canon" of official/blessed objects. This // Canon class is a representation of such a collection, with the property @@ -32,10 +36,21 @@ export class Canon { keys?: SortedKeysInfo; }>(canUseWeakMap); + // Make the ObjectCanon assume this value has already been + // canonicalized. + public pass(value: T): Pass; + public pass(value: any) { + return new Pass(value); + } + // Returns the canonical version of value. public admit(value: T): T; public admit(value: any) { if (value && typeof value === "object") { + if (value instanceof Pass) { + return value.value; + } + switch (objToStr.call(value)) { case "[object Array]": { if (this.known.has(value)) return value; diff --git a/src/cache/inmemory/readFromStore.ts b/src/cache/inmemory/readFromStore.ts index 464a50a4548..5414ce26de0 100644 --- a/src/cache/inmemory/readFromStore.ts +++ b/src/cache/inmemory/readFromStore.ts @@ -21,7 +21,6 @@ import { getFragmentDefinitions, getMainDefinition, getQueryDefinition, - maybeDeepFreeze, mergeDeepArray, getFragmentFromSelection, } from '../../utilities'; @@ -280,17 +279,10 @@ export class StoreReader { } else if (!selection.selectionSet) { // If the field does not have a selection set, then we handle it - // as a scalar value. However, that value should not contain any - // Reference objects, and should be frozen in development, if it - // happens to be an object that is mutable. - if (process.env.NODE_ENV !== 'production') { - assertSelectionSetForIdValue( - context.store, - selection, - fieldValue, - ); - maybeDeepFreeze(fieldValue); - } + // as a scalar value. To keep this.canon from canonicalizing + // this value, we use this.canon.pass to wrap fieldValue in a + // Pass object that this.canon.admit will later unwrap as-is. + fieldValue = this.canon.pass(fieldValue); } else if (fieldValue != null) { // In this case, because we know the field has a selection set, From 05c362f24a5f2c247e150318ab76eb0fd97272ae Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 15 Dec 2020 15:42:17 -0500 Subject: [PATCH 013/380] Test === equality for partial results from different queries. --- .../__snapshots__/readFromStore.ts.snap | 18 +++ src/cache/inmemory/__tests__/readFromStore.ts | 104 +++++++++++++++++- 2 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 src/cache/inmemory/__tests__/__snapshots__/readFromStore.ts.snap diff --git a/src/cache/inmemory/__tests__/__snapshots__/readFromStore.ts.snap b/src/cache/inmemory/__tests__/__snapshots__/readFromStore.ts.snap new file mode 100644 index 00000000000..46732d054ee --- /dev/null +++ b/src/cache/inmemory/__tests__/__snapshots__/readFromStore.ts.snap @@ -0,0 +1,18 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`reading from the store returns === results for different queries 1`] = ` +Object { + "ROOT_QUERY": Object { + "__typename": "Query", + "a": Array [ + "a", + "y", + "y", + ], + "b": Object { + "c": "C", + "d": "D", + }, + }, +} +`; diff --git a/src/cache/inmemory/__tests__/readFromStore.ts b/src/cache/inmemory/__tests__/readFromStore.ts index d46c79b89ca..bf3003c4b5b 100644 --- a/src/cache/inmemory/__tests__/readFromStore.ts +++ b/src/cache/inmemory/__tests__/readFromStore.ts @@ -2,13 +2,19 @@ import { assign, omit } from 'lodash'; import gql from 'graphql-tag'; import { stripSymbols } from '../../../utilities/testing/stripSymbols'; +import { InMemoryCache } from '../inMemoryCache'; import { StoreObject } from '../types'; import { StoreReader } from '../readFromStore'; -import { makeReference, InMemoryCache, Reference, isReference } from '../../../core'; import { Cache } from '../../core/types/Cache'; import { MissingFieldError } from '../../core/types/common'; import { defaultNormalizedCacheFactory, readQueryFromStore } from './helpers'; import { withError } from './diffAgainstStore'; +import { + makeReference, + Reference, + isReference, + TypedDocumentNode, +} from '../../../core'; describe('reading from the store', () => { const reader = new StoreReader({ @@ -1827,4 +1833,100 @@ describe('reading from the store', () => { }, }); }); + + it("returns === results for different queries", function () { + const cache = new InMemoryCache; + + const aQuery: TypedDocumentNode<{ + a: string[]; + }> = gql`query { a }`; + + const abQuery: TypedDocumentNode<{ + a: string[]; + b: { + c: string; + d: string; + }; + }> = gql`query { a b { c d } }`; + + const bQuery: TypedDocumentNode<{ + b: { + c: string; + d: string; + }; + }> = gql`query { b { d c } }`; + + const abData1 = { + a: ["a", "y"], + b: { + c: "see", + d: "dee", + }, + }; + + cache.writeQuery({ + query: abQuery, + data: abData1, + }); + + function read(query: TypedDocumentNode) { + return cache.readQuery({ query })!; + } + + const aResult1 = read(aQuery); + const abResult1 = read(abQuery); + const bResult1 = read(bQuery); + + expect(aResult1.a).toBe(abResult1.a); + expect(abResult1).toEqual(abData1); + expect(aResult1).toEqual({ a: abData1.a }); + expect(bResult1).toEqual({ b: abData1.b }); + expect(abResult1.b).toBe(bResult1.b); + + const aData2 = { + a: "ayy".split(""), + }; + + cache.writeQuery({ + query: aQuery, + data: aData2, + }); + + const aResult2 = read(aQuery); + const abResult2 = read(abQuery); + const bResult2 = read(bQuery); + + expect(aResult2).toEqual(aData2); + expect(abResult2).toEqual({ ...abData1, ...aData2 }); + expect(aResult2.a).toBe(abResult2.a); + expect(bResult2).toBe(bResult1); + expect(abResult2.b).toBe(bResult2.b); + expect(abResult2.b).toBe(bResult1.b); + + const bData3 = { + b: { + d: "D", + c: "C", + }, + }; + + cache.writeQuery({ + query: bQuery, + data: bData3, + }); + + const aResult3 = read(aQuery); + const abResult3 = read(abQuery); + const bResult3 = read(bQuery); + + expect(aResult3).toBe(aResult2); + expect(bResult3).toEqual(bData3); + expect(bResult3).not.toBe(bData3); + expect(abResult3).toEqual({ + ...abResult2, + ...bData3, + }); + + expect(cache.extract()).toMatchSnapshot(); + }); }); From 3059ee351381f68616cd8431b68de653380b7aab Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 15 Dec 2020 16:20:07 -0500 Subject: [PATCH 014/380] Bump bundlesize limit from 24.5kB to 24.9kB. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e9d5ba125e9..610e333294f 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,7 @@ { "name": "apollo-client", "path": "./dist/apollo-client.cjs.min.js", - "maxSize": "25.5 kB" + "maxSize": "25.9 kB" } ], "peerDependencies": { From 8989ad3e97cd3da750afc753f0cf18d40b1b894d Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 15 Dec 2020 16:44:28 -0500 Subject: [PATCH 015/380] Test that canonicalization does not modify scalar objects. --- src/cache/inmemory/__tests__/readFromStore.ts | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/src/cache/inmemory/__tests__/readFromStore.ts b/src/cache/inmemory/__tests__/readFromStore.ts index bf3003c4b5b..bec47f710eb 100644 --- a/src/cache/inmemory/__tests__/readFromStore.ts +++ b/src/cache/inmemory/__tests__/readFromStore.ts @@ -1929,4 +1929,52 @@ describe('reading from the store', () => { expect(cache.extract()).toMatchSnapshot(); }); + + it("does not canonicalize custom scalar objects", function () { + const now = new Date; + const abc = { a: 1, b: 2, c: 3 }; + + const cache = new InMemoryCache({ + typePolicies: { + Query: { + fields: { + now() { + return now; + }, + + abc() { + return abc; + }, + }, + }, + }, + }); + + const query: TypedDocumentNode<{ + now: typeof now; + abc: typeof abc; + }> = gql`query { now abc }`; + + const result1 = cache.readQuery({ query })!; + const result2 = cache.readQuery({ query })!; + + expect(result1).toBe(result2); + expect(result1.now).toBeInstanceOf(Date); + + // We already know result1.now === result2.now, but it's also + // important that it be the very same (===) Date object that was + // returned from the read function for the Query.now field, not a + // canonicalized version. + expect(result1.now).toBe(now); + expect(result2.now).toBe(now); + + // The Query.abc field returns a "normal" object, but we know from the + // structure of the query that it's a scalar object, so it will not be + // canonicalized. + expect(result1.abc).toEqual(abc); + expect(result2.abc).toEqual(abc); + expect(result1.abc).toBe(result2.abc); + expect(result1.abc).toBe(abc); + expect(result2.abc).toBe(abc); + }); }); From cad1a06c18d963130539fd31f7067288132cb259 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 15 Dec 2020 17:50:31 -0500 Subject: [PATCH 016/380] Improve Canon class comments. --- src/cache/inmemory/canon.ts | 81 ++++++++++++++++++++++++++++--------- 1 file changed, 62 insertions(+), 19 deletions(-) diff --git a/src/cache/inmemory/canon.ts b/src/cache/inmemory/canon.ts index f3b20e52ef5..7572c4fd4fa 100644 --- a/src/cache/inmemory/canon.ts +++ b/src/cache/inmemory/canon.ts @@ -6,30 +6,67 @@ class Pass { constructor(public readonly value: T) {} } -// When we say an object is "canonical" in programming, we mean it has been -// admitted into some abstract "canon" of official/blessed objects. This -// Canon class is a representation of such a collection, with the property -// that canon.admit(value1) === canon.admit(value2) if value1 and value2 are -// deeply equal to each other. The canonicalization process involves looking -// at every property in the provided object tree, so it takes the same order -// of time as deep equality checking (linear time), but already-admitted -// objects are returned immediately from canon.admit, so ensuring subtrees -// have already been canonized tends to speed up canonicalization. Of -// course, since canonized objects may be shared widely between unrelated -// consumers, it's important to regard them as immutable. No detection of -// cycles is needed by the StoreReader class right now, so we don't bother -// keeping track of objects we've already seen during the recursion of the -// admit method. Objects whose internal class name is neither Array nor -// Object can be included in the value tree, but they will not be replaced -// with a canonical version (to put it another way, they are assumed to be -// canonical already). We can easily add additional cases to the switch +// When programmers talk about the "canonical form" of an object, they +// usually have the following meaning in mind, which I've copied from +// https://en.wiktionary.org/wiki/canonical_form: +// +// 1. A standard or normal presentation of a mathematical entity [or +// object]. A canonical form is an element of a set of representatives +// of equivalence classes of forms such that there is a function or +// procedure which projects every element of each equivalence class +// onto that one element, the canonical form of that equivalence +// class. The canonical form is expected to be simpler than the rest of +// the forms in some way. +// +// That's a long-winded way of saying any two objects that have the same +// canonical form may be considered equivalent, even if they are !==, +// which usually means the objects are structurally equivalent (deeply +// equal), but don't necessarily use the same memory. +// +// Like a literary or musical canon, this Canon class represents a +// collection of unique canonical items (JavaScript objects), with the +// important property that canon.admit(a) === canon.admit(b) if a and b +// are deeply equal to each other. In terms of the definition above, the +// canon.admit method is the "function or procedure which projects every" +// object "onto that one element, the canonical form." +// +// In the worst case, the canonicalization process may involve looking at +// every property in the provided object tree, so it takes the same order +// of time as deep equality checking. Fortunately, already-canonicalized +// objects are returned immediately from canon.admit, so the presence of +// canonical subtrees tends to speed up canonicalization. +// +// Since consumers of canonical objects can check for deep equality in +// constant time, canonicalizing cache results can massively improve the +// performance of application code that skips re-rendering unchanged +// results, such as "pure" UI components in a framework like React. +// +// Of course, since canonical objects may be shared widely between +// unrelated consumers, it's important to think of them as immutable, even +// though they are not actually frozen with Object.freeze in production, +// due to the extra performance overhead that comes with frozen objects. +// +// Custom scalar objects whose internal class name is neither Array nor +// Object can be included safely in the admitted tree, but they will not +// be replaced with a canonical version (to put it another way, they are +// assumed to be canonical already). +// +// If we ignore custom objects, no detection of cycles or repeated object +// references is currently required by the StoreReader class, since +// GraphQL result objects are JSON-serializable trees (and thus contain +// neither cycles nor repeated subtrees), so we can avoid the complexity +// of keeping track of objects we've already seen during the recursion of +// the admit method. +// +// In the future, we may consider adding additional cases to the switch // statement to handle other common object types, such as "[object Date]" // objects, as needed. export class Canon { - // All known objects this Canon has admitted. + // Set of all canonical objects this Canon has admitted, allowing + // canon.admit to return previously-canonicalized objects immediately. private known = new (canUseWeakMap ? WeakSet : Set)(); - // Efficient storage/lookup structure for admitting objects. + // Efficient storage/lookup structure for canonical objects. private pool = new KeyTrie<{ array?: any[]; object?: Record; @@ -61,6 +98,9 @@ export class Canon { const node = this.pool.lookupArray(array); if (!node.array) { this.known.add(node.array = array); + // Since canonical arrays may be shared widely between + // unrelated consumers, it's important to regard them as + // immutable, even if they are not frozen in production. if (process.env.NODE_ENV !== "production") { Object.freeze(array); } @@ -92,6 +132,9 @@ export class Canon { keys.sorted.forEach((key, i) => { obj[key] = array[i + 2]; }); + // Since canonical objects may be shared widely between + // unrelated consumers, it's important to regard them as + // immutable, even if they are not frozen in production. if (process.env.NODE_ENV !== "production") { Object.freeze(obj); } From 9c6a09cfcea64abd69daae82a4fb8740e6d21f5b Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 15 Dec 2020 18:23:39 -0500 Subject: [PATCH 017/380] Rename Canon class to ObjectCanon. --- src/cache/inmemory/{canon.ts => object-canon.ts} | 6 +++--- src/cache/inmemory/readFromStore.ts | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) rename src/cache/inmemory/{canon.ts => object-canon.ts} (97%) diff --git a/src/cache/inmemory/canon.ts b/src/cache/inmemory/object-canon.ts similarity index 97% rename from src/cache/inmemory/canon.ts rename to src/cache/inmemory/object-canon.ts index 7572c4fd4fa..2dc82210a7a 100644 --- a/src/cache/inmemory/canon.ts +++ b/src/cache/inmemory/object-canon.ts @@ -23,7 +23,7 @@ class Pass { // which usually means the objects are structurally equivalent (deeply // equal), but don't necessarily use the same memory. // -// Like a literary or musical canon, this Canon class represents a +// Like a literary or musical canon, this ObjectCanon class represents a // collection of unique canonical items (JavaScript objects), with the // important property that canon.admit(a) === canon.admit(b) if a and b // are deeply equal to each other. In terms of the definition above, the @@ -61,8 +61,8 @@ class Pass { // In the future, we may consider adding additional cases to the switch // statement to handle other common object types, such as "[object Date]" // objects, as needed. -export class Canon { - // Set of all canonical objects this Canon has admitted, allowing +export class ObjectCanon { + // Set of all canonical objects this ObjectCanon has admitted, allowing // canon.admit to return previously-canonicalized objects immediately. private known = new (canUseWeakMap ? WeakSet : Set)(); diff --git a/src/cache/inmemory/readFromStore.ts b/src/cache/inmemory/readFromStore.ts index 5414ce26de0..7d487bb890c 100644 --- a/src/cache/inmemory/readFromStore.ts +++ b/src/cache/inmemory/readFromStore.ts @@ -35,7 +35,7 @@ import { getTypenameFromStoreObject } from './helpers'; import { Policies } from './policies'; import { InMemoryCache } from './inMemoryCache'; import { MissingFieldError } from '../core/types/common'; -import { Canon } from './canon'; +import { ObjectCanon } from './object-canon'; export type VariableMap = { [name: string]: any }; @@ -326,7 +326,7 @@ export class StoreReader { return finalResult; } - private canon = new Canon; + private canon = new ObjectCanon; private knownResults = new WeakMap, SelectionSetNode>(); From e8f3ef893c6904594abf399e0ddcd81980c5101c Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 15 Dec 2020 19:53:08 -0500 Subject: [PATCH 018/380] Avoid wrapping non-object values with Pass wrappers. --- src/cache/inmemory/object-canon.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/cache/inmemory/object-canon.ts b/src/cache/inmemory/object-canon.ts index 2dc82210a7a..4e87018ffd0 100644 --- a/src/cache/inmemory/object-canon.ts +++ b/src/cache/inmemory/object-canon.ts @@ -6,6 +6,10 @@ class Pass { constructor(public readonly value: T) {} } +function isObjectOrArray(value: any): boolean { + return !!value && typeof value === "object"; +} + // When programmers talk about the "canonical form" of an object, they // usually have the following meaning in mind, which I've copied from // https://en.wiktionary.org/wiki/canonical_form: @@ -75,15 +79,17 @@ export class ObjectCanon { // Make the ObjectCanon assume this value has already been // canonicalized. - public pass(value: T): Pass; + public pass(value: T): T extends object ? Pass : T; public pass(value: any) { - return new Pass(value); + return isObjectOrArray(value) ? new Pass(value) : value; } // Returns the canonical version of value. public admit(value: T): T; public admit(value: any) { - if (value && typeof value === "object") { + if (isObjectOrArray(value)) { + // If value is a Pass object returned by canon.pass, unwrap it + // as-is, without canonicalizing its value. if (value instanceof Pass) { return value.value; } From 9fe217378f392ae092bab7976951e9b2d038e834 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Wed, 16 Dec 2020 13:15:59 -0500 Subject: [PATCH 019/380] Update optimism and import Trie from @wry/trie instead. As suggested by @hwillson in this comment: https://github.com/apollographql/apollo-client/pull/7439#discussion_r544250587 --- package-lock.json | 24 ++++++++++++++++++++---- package.json | 3 ++- src/cache/inmemory/entityStore.ts | 7 ++++--- src/cache/inmemory/object-canon.ts | 14 +++++++------- src/cache/inmemory/policies.ts | 4 ++-- 5 files changed, 35 insertions(+), 17 deletions(-) diff --git a/package-lock.json b/package-lock.json index 14d2a6fb2fd..e15b92eda36 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2775,6 +2775,21 @@ "tslib": "^1.9.3" } }, + "@wry/trie": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@wry/trie/-/trie-0.2.1.tgz", + "integrity": "sha512-sYkuXZqArky2MLQCv4tLW6hX3N8AfTZ5ZMBc8jC6Yy35WYr82UYLLtjS7k/uRGHOA0yTSjuNadG6QQ6a5CS5hQ==", + "requires": { + "tslib": "^1.14.1" + }, + "dependencies": { + "tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + } + } + }, "abab": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.5.tgz", @@ -8720,11 +8735,12 @@ } }, "optimism": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/optimism/-/optimism-0.13.1.tgz", - "integrity": "sha512-16RRVYZe8ODcUqpabpY7Gb91vCAbdhn8FHjlUb2Hqnjjow1j8Z1dlppds+yAsLbreNTVylLC+tNX6DuC2vt3Kw==", + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/optimism/-/optimism-0.14.0.tgz", + "integrity": "sha512-ygbNt8n4DOCVpkwiLF+IrKKeNHOjtr9aXLWGP9HNJGoblSGsnVbJLstcH6/nE9Xy5ZQtlkSioFQNnthmENW6FQ==", "requires": { - "@wry/context": "^0.5.2" + "@wry/context": "^0.5.2", + "@wry/trie": "^0.2.1" } }, "optionator": { diff --git a/package.json b/package.json index 610e333294f..38f1a8225c4 100644 --- a/package.json +++ b/package.json @@ -77,10 +77,11 @@ "@types/zen-observable": "^0.8.0", "@wry/context": "^0.5.2", "@wry/equality": "^0.3.0", + "@wry/trie": "^0.2.1", "fast-json-stable-stringify": "^2.0.0", "graphql-tag": "^2.11.0", "hoist-non-react-statics": "^3.3.2", - "optimism": "^0.13.1", + "optimism": "^0.14.0", "prop-types": "^15.7.2", "symbol-observable": "^2.0.0", "ts-invariant": "^0.6.0", diff --git a/src/cache/inmemory/entityStore.ts b/src/cache/inmemory/entityStore.ts index a69b6ec599b..26ca0b4b73d 100644 --- a/src/cache/inmemory/entityStore.ts +++ b/src/cache/inmemory/entityStore.ts @@ -1,5 +1,6 @@ -import { dep, OptimisticDependencyFunction, KeyTrie } from 'optimism'; +import { dep, OptimisticDependencyFunction } from 'optimism'; import { equal } from '@wry/equality'; +import { Trie } from '@wry/trie'; import { isReference, @@ -496,7 +497,7 @@ class CacheGroup { // Used by the EntityStore#makeCacheKey method to compute cache keys // specific to this CacheGroup. - public readonly keyMaker = new KeyTrie(canUseWeakMap); + public readonly keyMaker = new Trie(canUseWeakMap); } function makeDepKey(dataId: string, storeFieldName: string) { @@ -543,7 +544,7 @@ export namespace EntityStore { return this; } - public readonly storageTrie = new KeyTrie(canUseWeakMap); + public readonly storageTrie = new Trie(canUseWeakMap); public getStorage(): StorageType { return this.storageTrie.lookupArray(arguments); } diff --git a/src/cache/inmemory/object-canon.ts b/src/cache/inmemory/object-canon.ts index 4e87018ffd0..b35a2ba03c1 100644 --- a/src/cache/inmemory/object-canon.ts +++ b/src/cache/inmemory/object-canon.ts @@ -1,4 +1,4 @@ -import { KeyTrie } from "optimism"; +import { Trie } from "@wry/trie"; import { canUseWeakMap } from "../../utilities"; import { objToStr } from "./helpers"; @@ -71,7 +71,7 @@ export class ObjectCanon { private known = new (canUseWeakMap ? WeakSet : Set)(); // Efficient storage/lookup structure for canonical objects. - private pool = new KeyTrie<{ + private pool = new Trie<{ array?: any[]; object?: Record; keys?: SortedKeysInfo; @@ -98,7 +98,7 @@ export class ObjectCanon { case "[object Array]": { if (this.known.has(value)) return value; const array: any[] = value.map(this.admit, this); - // Arrays are looked up in the KeyTrie using their recursively + // Arrays are looked up in the Trie using their recursively // canonicalized elements, and the known version of the array is // preserved as node.array. const node = this.pool.lookupArray(array); @@ -123,9 +123,9 @@ export class ObjectCanon { keys.sorted.forEach(key => { array.push(this.admit(value[key])); }); - // Objects are looked up in the KeyTrie by their prototype - // (which is *not* recursively canonicalized), followed by a - // JSON representation of their (sorted) keys, followed by the + // Objects are looked up in the Trie by their prototype (which + // is *not* recursively canonicalized), followed by a JSON + // representation of their (sorted) keys, followed by the // sequence of recursively canonicalized values corresponding to // those keys. To keep the final results unambiguous with other // sequences (such as arrays that just happen to contain [proto, @@ -154,7 +154,7 @@ export class ObjectCanon { // It's worthwhile to cache the sorting of arrays of strings, since the // same initial unsorted arrays tend to be encountered many times. - // Fortunately, we can reuse the KeyTrie machinery to look up the sorted + // Fortunately, we can reuse the Trie machinery to look up the sorted // arrays in linear time (which is faster than sorting large arrays). private sortedKeys(obj: object) { const keys = Object.keys(obj); diff --git a/src/cache/inmemory/policies.ts b/src/cache/inmemory/policies.ts index 58db0bdf96f..36dcb94caac 100644 --- a/src/cache/inmemory/policies.ts +++ b/src/cache/inmemory/policies.ts @@ -5,7 +5,7 @@ import { FieldNode, } from 'graphql'; -import { KeyTrie } from 'optimism'; +import { Trie } from '@wry/trie'; import { invariant, InvariantError } from 'ts-invariant'; import { @@ -915,7 +915,7 @@ function keyArgsFnFromSpecifier( function keyFieldsFnFromSpecifier( specifier: KeySpecifier, ): KeyFieldsFunction { - const trie = new KeyTrie<{ + const trie = new Trie<{ aliasMap?: AliasMap; }>(canUseWeakMap); From bdf34915d1de92073e5b58fb41d2ddacc380ab2f Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Wed, 16 Dec 2020 13:42:10 -0500 Subject: [PATCH 020/380] Use firstValueIndex variable to make array indexing clearer. https://github.com/apollographql/apollo-client/pull/7439#discussion_r544246303 --- src/cache/inmemory/object-canon.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/cache/inmemory/object-canon.ts b/src/cache/inmemory/object-canon.ts index b35a2ba03c1..599ad340f70 100644 --- a/src/cache/inmemory/object-canon.ts +++ b/src/cache/inmemory/object-canon.ts @@ -120,6 +120,7 @@ export class ObjectCanon { const array = [proto]; const keys = this.sortedKeys(value); array.push(keys.json); + const firstValueIndex = array.length; keys.sorted.forEach(key => { array.push(this.admit(value[key])); }); @@ -136,7 +137,7 @@ export class ObjectCanon { const obj = node.object = Object.create(proto); this.known.add(obj); keys.sorted.forEach((key, i) => { - obj[key] = array[i + 2]; + obj[key] = array[firstValueIndex + i]; }); // Since canonical objects may be shared widely between // unrelated consumers, it's important to regard them as From d6c58268feee5fed38eca1315d49081c0ee0a875 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Wed, 16 Dec 2020 14:05:31 -0500 Subject: [PATCH 021/380] Add more direct unit tests of ObjectCanon. https://github.com/apollographql/apollo-client/pull/7439#pullrequestreview-553616923 --- src/cache/inmemory/__tests__/object-canon.ts | 108 +++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 src/cache/inmemory/__tests__/object-canon.ts diff --git a/src/cache/inmemory/__tests__/object-canon.ts b/src/cache/inmemory/__tests__/object-canon.ts new file mode 100644 index 00000000000..d109d501d54 --- /dev/null +++ b/src/cache/inmemory/__tests__/object-canon.ts @@ -0,0 +1,108 @@ +import { ObjectCanon } from "../object-canon"; + +describe("ObjectCanon", () => { + it("can canonicalize objects and arrays", () => { + const canon = new ObjectCanon; + + const obj1 = { + a: [1, 2], + b: { + c: [{ + d: "dee", + e: "ee", + }, "f"], + g: "gee", + }, + }; + + const obj2 = { + b: { + g: "gee", + c: [{ + e: "ee", + d: "dee", + }, "f"], + }, + a: [1, 2], + }; + + expect(obj1).toEqual(obj2); + expect(obj1).not.toBe(obj2); + + const c1 = canon.admit(obj1); + const c2 = canon.admit(obj2); + + expect(c1).toBe(c2); + expect(c1).toEqual(obj1); + expect(c1).toEqual(obj2); + expect(c2).toEqual(obj1); + expect(c2).toEqual(obj2); + expect(c1).not.toBe(obj1); + expect(c1).not.toBe(obj2); + expect(c2).not.toBe(obj1); + expect(c2).not.toBe(obj2); + + expect(canon.admit(c1)).toBe(c1); + expect(canon.admit(c2)).toBe(c2); + }); + + it("preserves custom prototypes", () => { + const canon = new ObjectCanon; + + class Custom { + constructor(public value: any) {} + getValue() { return this.value } + } + + const customs = [ + new Custom("oyez"), + new Custom(1234), + new Custom(true), + ]; + + const admitted = canon.admit(customs); + expect(admitted).not.toBe(customs); + expect(admitted).toEqual(customs); + + function check(i: number) { + expect(admitted[i]).toEqual(customs[i]); + expect(admitted[i]).not.toBe(customs[i]); + expect(admitted[i].getValue()).toBe(customs[i].getValue()); + expect(Object.getPrototypeOf(admitted[i])).toBe(Custom.prototype); + expect(admitted[i]).toBeInstanceOf(Custom); + } + check(0); + check(1); + check(2); + + expect(canon.admit(customs)).toBe(admitted); + }); + + it("unwraps Pass wrappers as-is", () => { + const canon = new ObjectCanon; + + const cd = { + c: "see", + d: "dee", + }; + + const obj = { + a: cd, + b: canon.pass(cd), + e: cd, + }; + + function check() { + const admitted = canon.admit(obj); + expect(admitted).not.toBe(obj); + expect(admitted.b).toBe(cd); + expect(admitted.e).toEqual(cd); + expect(admitted.e).not.toBe(cd); + expect(admitted.e).toEqual(admitted.b); + expect(admitted.e).not.toBe(admitted.b); + expect(admitted.e).toBe(admitted.a); + } + check(); + check(); + }); +}); From 48bd0b1d0568f143eb9c8a29e191c9458f25c2b8 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Wed, 16 Dec 2020 14:16:46 -0500 Subject: [PATCH 022/380] Mention PR #7439 in CHANGELOG.md. --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bd817dc3b0..8d108429723 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ TBD ### Improvements +- `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.
+ [@benjamn](https://github.com/benjamn) in [#7439](https://github.com/apollographql/apollo-client/pull/7439) + - Support `client.refetchQueries` as an imperative way to refetch queries, without having to pass `options.refetchQueries` to `client.mutate`.
[@dannycochran](https://github.com/dannycochran) in [#7431](https://github.com/apollographql/apollo-client/pull/7431) From f4f96c0369a6b380a1f9379b667034335cdd3811 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Wed, 16 Dec 2020 14:25:16 -0500 Subject: [PATCH 023/380] Bump @apollo/client npm version to 3.4.0-beta.4. --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9657efdfb94..585283ba10c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-beta.3", + "version": "3.4.0-beta.4", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 6b865af5aab..b8e3052fc8b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-beta.3", + "version": "3.4.0-beta.4", "description": "A fully-featured caching GraphQL client.", "private": true, "keywords": [ From 2aa6af58e0e8ab70feca7d1c22ef462f5e1c8a8e Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Wed, 16 Dec 2020 15:26:59 -0500 Subject: [PATCH 024/380] Add more tests of ObjectCanon prototype preservation. --- src/cache/inmemory/__tests__/object-canon.ts | 32 ++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/cache/inmemory/__tests__/object-canon.ts b/src/cache/inmemory/__tests__/object-canon.ts index d109d501d54..83014a7f3bb 100644 --- a/src/cache/inmemory/__tests__/object-canon.ts +++ b/src/cache/inmemory/__tests__/object-canon.ts @@ -76,6 +76,38 @@ describe("ObjectCanon", () => { check(2); expect(canon.admit(customs)).toBe(admitted); + + function checkProto(proto: null | object) { + const a = Object.create(proto); + const b = Object.create(proto, { + visible: { + value: "bee", + enumerable: true, + }, + hidden: { + value: "invisibee", + enumerable: false, + }, + }); + + const admitted = canon.admit({ a, b }); + + expect(admitted.a).toEqual(a); + expect(admitted.a).not.toBe(a); + + expect(admitted.b).toEqual(b); + expect(admitted.b).not.toBe(b); + + expect(Object.getPrototypeOf(admitted.a)).toBe(proto); + expect(Object.getPrototypeOf(admitted.b)).toBe(proto); + + expect(admitted.b.visible).toBe("bee"); + expect(admitted.b.hidden).toBeUndefined(); + } + checkProto(null); + checkProto({}); + checkProto([1,2,3]); + checkProto(() => "fun"); }); it("unwraps Pass wrappers as-is", () => { From 419e90a73fc17f2a2f33349ee4d55806fcbb5b36 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 12 Jan 2021 17:10:47 -0500 Subject: [PATCH 025/380] Canonicalize empty result objects, too. Small addition to PR #7439. --- src/cache/inmemory/object-canon.ts | 3 +++ src/cache/inmemory/readFromStore.ts | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/cache/inmemory/object-canon.ts b/src/cache/inmemory/object-canon.ts index 599ad340f70..63b4ad2f2de 100644 --- a/src/cache/inmemory/object-canon.ts +++ b/src/cache/inmemory/object-canon.ts @@ -172,6 +172,9 @@ export class ObjectCanon { // Arrays that contain the same elements in a different order can share // the same SortedKeysInfo object, to save memory. private keysByJSON = new Map(); + + // This has to come last because it depends on keysByJSON. + public readonly empty = this.admit({}); } type SortedKeysInfo = { diff --git a/src/cache/inmemory/readFromStore.ts b/src/cache/inmemory/readFromStore.ts index 7d487bb890c..a463ce8af34 100644 --- a/src/cache/inmemory/readFromStore.ts +++ b/src/cache/inmemory/readFromStore.ts @@ -189,7 +189,7 @@ export class StoreReader { !context.policies.rootTypenamesById[objectOrReference.__ref] && !context.store.has(objectOrReference.__ref)) { return { - result: {}, + result: this.canon.empty, missing: [missingFromInvariant( new InvariantError( `Dangling reference to missing ${objectOrReference.__ref} object` From 333ff4899d554f1ece01f1ca3fa942cc0caae28d Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Wed, 20 Jan 2021 11:15:28 -0500 Subject: [PATCH 026/380] Bump bundlesize limit from 24.9kB to 24.95kB. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 61110a3e6e3..d6ef443c3db 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,7 @@ { "name": "apollo-client", "path": "./dist/apollo-client.cjs.min.js", - "maxSize": "25.9 kB" + "maxSize": "25.95 kB" } ], "peerDependencies": { From 1ad904281d6fdff318b808862da6c5e52ecbdfbd Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 26 Jan 2021 10:55:46 -0500 Subject: [PATCH 027/380] Revive zen-observable-ts wrapper for zen-observable npm package (#7615) https://github.com/apollographql/apollo-client/pull/7615 --- package-lock.json | 27 ++++++++++++++++--------- package.json | 3 +-- src/utilities/observables/Observable.ts | 13 +++++++++--- 3 files changed, 29 insertions(+), 14 deletions(-) diff --git a/package-lock.json b/package-lock.json index ab7a42bb4f9..8f89f7a7716 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2750,9 +2750,9 @@ "dev": true }, "@types/zen-observable": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/@types/zen-observable/-/zen-observable-0.8.0.tgz", - "integrity": "sha512-te5lMAWii1uEJ4FwLjzdlbw3+n0FZNOvFXHxQDKeT0dilh7HOzdMzV2TrJVUzq8ep7J4Na8OUYPRLSQkJHAlrg==" + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/@types/zen-observable/-/zen-observable-0.8.2.tgz", + "integrity": "sha512-HrCIVMLjE1MOozVoD86622S7aunluLb2PJdPfb3nYiEtohm8mIB/vyv0Fd37AdeMFrTUQXEunw78YloMA3Qilg==" }, "@ungap/global-this": { "version": "0.4.2", @@ -2902,6 +2902,16 @@ "requires": { "tslib": "^1.9.3" } + }, + "zen-observable-ts": { + "version": "0.8.21", + "resolved": "https://registry.npmjs.org/zen-observable-ts/-/zen-observable-ts-0.8.21.tgz", + "integrity": "sha512-Yj3yXweRc8LdRMrCC8nIc4kkjWecPAUVh0TI0OUrWXx6aX790vLcDlWca6I4vsyCGH3LpWxq0dJRcMOFoVqmeg==", + "dev": true, + "requires": { + "tslib": "^1.9.3", + "zen-observable": "^0.8.0" + } } } }, @@ -11512,13 +11522,12 @@ "integrity": "sha512-PQ2PC7R9rslx84ndNBZB/Dkv8V8fZEpk83RLgXtYd0fwUgEjseMn1Dgajh2x6S8QbZAFa9p2qVCEuYZNgve0dQ==" }, "zen-observable-ts": { - "version": "0.8.21", - "resolved": "https://registry.npmjs.org/zen-observable-ts/-/zen-observable-ts-0.8.21.tgz", - "integrity": "sha512-Yj3yXweRc8LdRMrCC8nIc4kkjWecPAUVh0TI0OUrWXx6aX790vLcDlWca6I4vsyCGH3LpWxq0dJRcMOFoVqmeg==", - "dev": true, + "version": "1.0.0-beta.4", + "resolved": "https://registry.npmjs.org/zen-observable-ts/-/zen-observable-ts-1.0.0-beta.4.tgz", + "integrity": "sha512-EnhcCIPbNSsAboYr6p9bVMFQ6naMK9Io7Qo8knQ9fFoMYUKgH9FHl+1oZYVV7x9N73LG4qU+kYyCf2Cs9MHqbw==", "requires": { - "tslib": "^1.9.3", - "zen-observable": "^0.8.0" + "@types/zen-observable": "^0.8.2", + "zen-observable": "^0.8.15" } } } diff --git a/package.json b/package.json index d6ef443c3db..02154ed399d 100644 --- a/package.json +++ b/package.json @@ -74,7 +74,6 @@ }, "dependencies": { "@graphql-typed-document-node/core": "^3.0.0", - "@types/zen-observable": "^0.8.0", "@wry/context": "^0.5.2", "@wry/equality": "^0.3.0", "@wry/trie": "^0.2.1", @@ -86,7 +85,7 @@ "symbol-observable": "^2.0.0", "ts-invariant": "^0.6.0", "tslib": "^1.10.0", - "zen-observable": "^0.8.14" + "zen-observable-ts": "^1.0.0-beta.4" }, "devDependencies": { "@babel/parser": "7.12.11", diff --git a/src/utilities/observables/Observable.ts b/src/utilities/observables/Observable.ts index fd721adcedd..3d10114c61c 100644 --- a/src/utilities/observables/Observable.ts +++ b/src/utilities/observables/Observable.ts @@ -1,11 +1,17 @@ -import Observable from 'zen-observable'; +import { + Observable, + Observer, + Subscription as ObservableSubscription, +} from 'zen-observable-ts'; // This simplified polyfill attempts to follow the ECMAScript Observable // proposal (https://github.com/zenparsing/es-observable) import 'symbol-observable'; -export type ObservableSubscription = ZenObservable.Subscription; -export type Observer = ZenObservable.Observer; +export type { + Observer, + ObservableSubscription, +}; // Use global module augmentation to add RxJS interop functionality. By // using this approach (instead of subclassing `Observable` and adding an @@ -18,4 +24,5 @@ declare global { } } (Observable.prototype as any)['@@observable'] = function () { return this; }; + export { Observable }; From 7d76c7d23ad3abb2acb17bf4511b56e9b1a5d5a3 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Fri, 29 Jan 2021 17:31:15 -0500 Subject: [PATCH 028/380] Cheapen process.env.NODE_ENV lookups in CommonJS bundles (#7627) --- CHANGELOG.md | 3 +++ config/rollup.config.js | 21 +++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fd5a3e1d63..5dd46d0a759 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ TBD - Support `client.refetchQueries` as an imperative way to refetch queries, without having to pass `options.refetchQueries` to `client.mutate`.
[@dannycochran](https://github.com/dannycochran) in [#7431](https://github.com/apollographql/apollo-client/pull/7431) +- When `@apollo/client` is imported as CommonJS (for example, in Node.js), the global `process` variable is now shadowed with a stripped-down object that includes only `process.env.NODE_ENV` (since that's all Apollo Client needs), eliminating the significant performance penalty of repeatedly accessing `process.env` at runtime.
+ [@benjamn](https://github.com/benjamn) in [#7627](https://github.com/apollographql/apollo-client/pull/7627) + ### Documentation TBD diff --git a/config/rollup.config.js b/config/rollup.config.js index e0bcdd1338a..f7e94bf3031 100644 --- a/config/rollup.config.js +++ b/config/rollup.config.js @@ -70,6 +70,27 @@ function prepareBundle({ sourcemap: true, exports: 'named', externalLiveBindings: false, + // In Node.js, where these CommonJS bundles are most commonly used, + // the expression process.env.NODE_ENV can be very expensive to + // evaluate, because process.env is a wrapper for the actual OS + // environment, and lookups are not cached. We need to preserve the + // syntax of process.env.NODE_ENV expressions for dead code + // elimination to work properly, but we can apply our own caching by + // shadowing the global process variable with a stripped-down object + // that saves a snapshot of process.env.NODE_ENV when the bundle is + // first evaluated. If we ever need other process properties, we can + // add more stubs here. + intro: '!(function (process) {', + outro: [ + '}).call(this, {', + ' env: {', + ' NODE_ENV: typeof process === "object"', + ' && process.env', + ' && process.env.NODE_ENV', + ' || "development"', + ' }', + '});', + ].join('\n'), }, plugins: [ extensions ? nodeResolve({ extensions }) : nodeResolve(), From 1fceb96da0480835c6830c0a854a14cfb03ab96a Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Fri, 29 Jan 2021 17:33:54 -0500 Subject: [PATCH 029/380] Bump @apollo/client npm version to 3.4.0-beta.5. --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8f89f7a7716..0da456482c6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-beta.4", + "version": "3.4.0-beta.5", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 02154ed399d..4631e54b477 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-beta.4", + "version": "3.4.0-beta.5", "description": "A fully-featured caching GraphQL client.", "private": true, "keywords": [ From 597e79d468232d1fca00ad05a0c023cc6fc189c3 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Fri, 29 Jan 2021 17:50:43 -0500 Subject: [PATCH 030/380] Update to graphql-tag@2.12.0 for ECMAScript exports support. https://github.com/apollographql/graphql-tag/pull/325 --- package-lock.json | 16 +++++++++++++--- package.json | 2 +- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0da456482c6..33b0e1d6d89 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4970,9 +4970,19 @@ "dev": true }, "graphql-tag": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/graphql-tag/-/graphql-tag-2.11.0.tgz", - "integrity": "sha512-VmsD5pJqWJnQZMUeRwrDhfgoyqcfwEkvtpANqcoUG8/tOLkwNgU9mzub/Mc78OJMhHjx7gfAMTxzdG43VGg3bA==" + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/graphql-tag/-/graphql-tag-2.12.0.tgz", + "integrity": "sha512-iK040pFYpQpHfqF5UJOlYu2XEw6wx56aiyKJP1zqhxabGssqfbTIqz6U++cBwx/Izad0JNq6IsWvrL+p6d1GOA==", + "requires": { + "tslib": "^1.14.1" + }, + "dependencies": { + "tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + } + } }, "graphql-tools": { "version": "6.0.12", diff --git a/package.json b/package.json index 4631e54b477..c4992f4eccf 100644 --- a/package.json +++ b/package.json @@ -78,7 +78,7 @@ "@wry/equality": "^0.3.0", "@wry/trie": "^0.2.1", "fast-json-stable-stringify": "^2.0.0", - "graphql-tag": "^2.11.0", + "graphql-tag": "^2.12.0", "hoist-non-react-statics": "^3.3.2", "optimism": "^0.14.0", "prop-types": "^15.7.2", From a15a74e50a37ac6caed36a5169c5a374bbc9947e Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Fri, 29 Jan 2021 17:53:12 -0500 Subject: [PATCH 031/380] Bump @apollo/client npm version to 3.4.0-beta.6. --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 33b0e1d6d89..bd095d14d3b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-beta.5", + "version": "3.4.0-beta.6", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index c4992f4eccf..a70ec0fe980 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-beta.5", + "version": "3.4.0-beta.6", "description": "A fully-featured caching GraphQL client.", "private": true, "keywords": [ From 5e9821f9bab3deeeff9a5240218bdf641655a4d9 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 2 Feb 2021 16:31:11 -0500 Subject: [PATCH 032/380] Support subclassing Observable with non-class constructor functions (#7640) --- package-lock.json | 6 +- package.json | 2 +- src/utilities/observables/Observable.ts | 2 + .../observables/__tests__/Observable.ts | 69 +++++++++++++++++++ 4 files changed, 75 insertions(+), 4 deletions(-) create mode 100644 src/utilities/observables/__tests__/Observable.ts diff --git a/package-lock.json b/package-lock.json index bd095d14d3b..977feb821da 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11532,9 +11532,9 @@ "integrity": "sha512-PQ2PC7R9rslx84ndNBZB/Dkv8V8fZEpk83RLgXtYd0fwUgEjseMn1Dgajh2x6S8QbZAFa9p2qVCEuYZNgve0dQ==" }, "zen-observable-ts": { - "version": "1.0.0-beta.4", - "resolved": "https://registry.npmjs.org/zen-observable-ts/-/zen-observable-ts-1.0.0-beta.4.tgz", - "integrity": "sha512-EnhcCIPbNSsAboYr6p9bVMFQ6naMK9Io7Qo8knQ9fFoMYUKgH9FHl+1oZYVV7x9N73LG4qU+kYyCf2Cs9MHqbw==", + "version": "1.0.0-beta.5", + "resolved": "https://registry.npmjs.org/zen-observable-ts/-/zen-observable-ts-1.0.0-beta.5.tgz", + "integrity": "sha512-8EJmjjv0yIsPXta/jn0nFLWfHa0hOqO+6dMqqmHR+Lo5pcrjRFhhOe8sKvzHZDPhCh52R0GMN/4HlaM/ShMRoA==", "requires": { "@types/zen-observable": "^0.8.2", "zen-observable": "^0.8.15" diff --git a/package.json b/package.json index a70ec0fe980..5f49d580b03 100644 --- a/package.json +++ b/package.json @@ -85,7 +85,7 @@ "symbol-observable": "^2.0.0", "ts-invariant": "^0.6.0", "tslib": "^1.10.0", - "zen-observable-ts": "^1.0.0-beta.4" + "zen-observable-ts": "^1.0.0-beta.5" }, "devDependencies": { "@babel/parser": "7.12.11", diff --git a/src/utilities/observables/Observable.ts b/src/utilities/observables/Observable.ts index 3d10114c61c..421e590cfd5 100644 --- a/src/utilities/observables/Observable.ts +++ b/src/utilities/observables/Observable.ts @@ -2,6 +2,7 @@ import { Observable, Observer, Subscription as ObservableSubscription, + Subscriber, } from 'zen-observable-ts'; // This simplified polyfill attempts to follow the ECMAScript Observable @@ -11,6 +12,7 @@ import 'symbol-observable'; export type { Observer, ObservableSubscription, + Subscriber, }; // Use global module augmentation to add RxJS interop functionality. By diff --git a/src/utilities/observables/__tests__/Observable.ts b/src/utilities/observables/__tests__/Observable.ts new file mode 100644 index 00000000000..657c2f1778c --- /dev/null +++ b/src/utilities/observables/__tests__/Observable.ts @@ -0,0 +1,69 @@ +import { Observable, Subscriber } from '../Observable'; + +describe('Observable', () => { + describe('subclassing by non-class constructor functions', () => { + function check(constructor: new (sub: Subscriber) => Observable) { + constructor.prototype = Object.create(Observable.prototype, { + constructor: { + value: constructor, + }, + }); + + const subscriber: Subscriber = observer => { + observer.next(123); + observer.complete(); + }; + + const obs = new constructor(subscriber) as Observable; + + expect(typeof (obs as any).sub).toBe("function"); + expect((obs as any).sub).toBe(subscriber); + + expect(obs).toBeInstanceOf(Observable); + expect(obs).toBeInstanceOf(constructor); + expect(obs.constructor).toBe(constructor); + + return new Promise((resolve, reject) => { + obs.subscribe({ + next: resolve, + error: reject, + }); + }).then(value => { + expect(value).toBe(123); + }); + } + + function newify( + constructor: (sub: Subscriber) => void, + ): new (sub: Subscriber) => Observable { + return constructor as any; + } + + it('simulating super(sub) with Observable.call(this, sub)', () => { + function SubclassWithSuperCall(sub: Subscriber) { + const self = Observable.call(this, sub) || this; + self.sub = sub; + return self; + } + return check(newify(SubclassWithSuperCall)); + }); + + it('simulating super(sub) with Observable.apply(this, arguments)', () => { + function SubclassWithSuperApplyArgs(_sub: Subscriber) { + const self = Observable.apply(this, arguments) || this; + self.sub = _sub; + return self; + } + return check(newify(SubclassWithSuperApplyArgs)); + }); + + it('simulating super(sub) with Observable.apply(this, [sub])', () => { + function SubclassWithSuperApplyArray(...args: [Subscriber]) { + const self = Observable.apply(this, args) || this; + self.sub = args[0]; + return self; + } + return check(newify(SubclassWithSuperApplyArray)); + }); + }); +}); From b62d097145a4846dfb4a46df9cc0a8c951c7086a Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Mon, 1 Feb 2021 11:44:47 -0500 Subject: [PATCH 033/380] Reset queryInfo.lastWrite before refetching query. https://github.com/apollographql/apollo-client/issues/7491#issuecomment-767985363 --- src/core/ObservableQuery.ts | 2 ++ src/core/QueryInfo.ts | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/src/core/ObservableQuery.ts b/src/core/ObservableQuery.ts index 6378ffd425a..567c47ed439 100644 --- a/src/core/ObservableQuery.ts +++ b/src/core/ObservableQuery.ts @@ -247,6 +247,8 @@ export class ObservableQuery< } as TVariables; } + this.queryInfo.resetLastWrite(); + return this.newReobserver(false).reobserve( reobserveOptions, NetworkStatus.refetch, diff --git a/src/core/QueryInfo.ts b/src/core/QueryInfo.ts index 69ef5dea972..ddfde5d7ca0 100644 --- a/src/core/QueryInfo.ts +++ b/src/core/QueryInfo.ts @@ -270,6 +270,10 @@ export class QueryInfo { dmCount: number | undefined; }; + public resetLastWrite() { + this.lastWrite = void 0; + } + private shouldWrite( result: FetchResult, variables: WatchQueryOptions["variables"], From e1e0c1405ac9c1e8e49493574a9c0e80a20c351e Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Wed, 3 Feb 2021 20:04:59 -0500 Subject: [PATCH 034/380] Bump @apollo/client npm version to 3.4.0-beta.7. --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 977feb821da..ef0a858c22f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-beta.6", + "version": "3.4.0-beta.7", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 5f49d580b03..bef37fa79d9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-beta.6", + "version": "3.4.0-beta.7", "description": "A fully-featured caching GraphQL client.", "private": true, "keywords": [ From ea476126630ebe09faa4bf8e49354aafcab71d42 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Fri, 5 Feb 2021 12:43:04 -0500 Subject: [PATCH 035/380] Bump @apollo/client npm version to 3.4.0-beta.8. --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index ef0a858c22f..e4acda48394 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-beta.7", + "version": "3.4.0-beta.8", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 475ec5daa85..1fc5c966968 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-beta.7", + "version": "3.4.0-beta.8", "description": "A fully-featured caching GraphQL client.", "private": true, "keywords": [ From f6b48e15b53b2b99c86e33a14b30afdd772f6f93 Mon Sep 17 00:00:00 2001 From: David Barnes Date: Mon, 8 Feb 2021 09:07:52 -0800 Subject: [PATCH 036/380] Export EntityStore, Policies, and fieldNameFromStoreName from @apollo/client/cache (#7645) --- src/__tests__/__snapshots__/exports.ts.snap | 3 +++ src/cache/index.ts | 9 ++++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/__tests__/__snapshots__/exports.ts.snap b/src/__tests__/__snapshots__/exports.ts.snap index 5f90346e03f..d41a41b752a 100644 --- a/src/__tests__/__snapshots__/exports.ts.snap +++ b/src/__tests__/__snapshots__/exports.ts.snap @@ -63,10 +63,13 @@ exports[`exports of public entry points @apollo/client/cache 1`] = ` Array [ "ApolloCache", "Cache", + "EntityStore", "InMemoryCache", "MissingFieldError", + "Policies", "cacheSlot", "defaultDataIdFromObject", + "fieldNameFromStoreName", "isReference", "makeReference", "makeVar", diff --git a/src/cache/index.ts b/src/cache/index.ts index 96cb9b0436f..d57d526ef8b 100644 --- a/src/cache/index.ts +++ b/src/cache/index.ts @@ -1,7 +1,10 @@ export { Transaction, ApolloCache } from './core/cache'; export { Cache } from './core/types/Cache'; export { DataProxy } from './core/types/DataProxy'; -export { MissingFieldError } from './core/types/common'; +export { + MissingFieldError, + ReadFieldOptions +} from './core/types/common'; export { Reference, @@ -9,6 +12,9 @@ export { makeReference, } from '../utilities'; +export { EntityStore } from './inmemory/entityStore'; +export { fieldNameFromStoreName } from './inmemory/helpers' + export { InMemoryCache, InMemoryCacheConfig, @@ -29,6 +35,7 @@ export { FieldMergeFunction, FieldFunctionOptions, PossibleTypesMap, + Policies, } from './inmemory/policies'; export * from './inmemory/types'; From a8ae338eb223c784ac13b56c9e2aa4e97dccb51f Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 9 Feb 2021 11:09:23 -0500 Subject: [PATCH 037/380] Bump @apollo/client npm version to 3.4.0-beta.9. --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index e4acda48394..2717e2d3516 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-beta.8", + "version": "3.4.0-beta.9", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index b0e93aa7d6a..e024492b187 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-beta.8", + "version": "3.4.0-beta.9", "description": "A fully-featured caching GraphQL client.", "private": true, "keywords": [ From 7433dcbbf2e3376da028c9cd4e90bd83e9cf12a7 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 9 Feb 2021 14:44:38 -0500 Subject: [PATCH 038/380] Bump bundlesize limit from 26kB to 26.1kB. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f3888bc3083..92332ff7d21 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,7 @@ { "name": "apollo-client", "path": "./dist/apollo-client.cjs.min.js", - "maxSize": "26 kB" + "maxSize": "26.1 kB" } ], "peerDependencies": { From dbad63b3977bf4a7dab730bcf93d7ba3d25b4cc2 Mon Sep 17 00:00:00 2001 From: Jason Kossis Date: Tue, 9 Feb 2021 14:47:48 -0500 Subject: [PATCH 039/380] Allow subscriptions to be deduplicated, like queries (#6910) --- CHANGELOG.md | 3 +++ src/core/QueryManager.ts | 1 - 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9200ab16643..e15310b8ace 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,9 @@ TBD - When `@apollo/client` is imported as CommonJS (for example, in Node.js), the global `process` variable is now shadowed with a stripped-down object that includes only `process.env.NODE_ENV` (since that's all Apollo Client needs), eliminating the significant performance penalty of repeatedly accessing `process.env` at runtime.
[@benjamn](https://github.com/benjamn) in [#7627](https://github.com/apollographql/apollo-client/pull/7627) +- Allow identical subscriptions to be deduplicated by default, like queries.
+ [@jkossis](https://github.com/jkossis) in [#6910](https://github.com/apollographql/apollo-client/pull/6910) + ### Documentation TBD diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index abb70d60844..152af9de30b 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -661,7 +661,6 @@ export class QueryManager { query, context, variables, - false, ).map(result => { if (fetchPolicy !== 'no-cache') { // the subscription interface should handle not sending us results we no longer subscribe to. From 26ac02db62f36e3366920374c63bf0c56f2dc0f3 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 9 Feb 2021 14:49:23 -0500 Subject: [PATCH 040/380] Bump @apollo/client npm version to 3.4.0-beta.10. --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index d909849dc37..fc36938b5f5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-beta.9", + "version": "3.4.0-beta.10", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 92332ff7d21..c7a7eed13ec 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-beta.9", + "version": "3.4.0-beta.10", "description": "A fully-featured caching GraphQL client.", "private": true, "keywords": [ From da48ef96f3ca6244bbb173ce03ef752de8b7ce91 Mon Sep 17 00:00:00 2001 From: Albert Iblyaminov <1199630+rieset@users.noreply.github.com> Date: Fri, 11 Dec 2020 05:12:28 +0300 Subject: [PATCH 041/380] Use POST for second request in @apollo/client/link/persisted-queries. The second request after error with full graphql queries should be a POST request. Because there may be more than the limit for a GET request. --- src/link/persisted-queries/index.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/link/persisted-queries/index.ts b/src/link/persisted-queries/index.ts index b4ec109abbc..31b9eb8582b 100644 --- a/src/link/persisted-queries/index.ts +++ b/src/link/persisted-queries/index.ts @@ -186,6 +186,12 @@ export const createPersistedQueryLink = ( includeQuery: true, includeExtensions: supportsPersistedQueries, }, + fetchOptions: { + // Since we're including the full query, which may be + // large, we should send it in the body of a POST request. + // See issue #7456. + method: 'POST', + }, }); if (setFetchOptions) { operation.setContext({ fetchOptions: originalFetchOptions }); From a87bc88b090268993e04955bfe7e22c83686d880 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 9 Feb 2021 15:28:28 -0500 Subject: [PATCH 042/380] Avoid async race to compute/use hash variable. --- src/link/persisted-queries/__tests__/index.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/link/persisted-queries/__tests__/index.ts b/src/link/persisted-queries/__tests__/index.ts index 1779aa3416e..837208bf4ce 100644 --- a/src/link/persisted-queries/__tests__/index.ts +++ b/src/link/persisted-queries/__tests__/index.ts @@ -39,13 +39,12 @@ const errorResponse = JSON.stringify({ errors }); const giveUpResponse = JSON.stringify({ errors: giveUpErrors }); const multiResponse = JSON.stringify({ errors: multipleErrors }); -let hash: string; -(async () => { - hash = await sha256(queryString); -})(); - describe('happy path', () => { - beforeEach(fetch.mockReset); + let hash: string; + beforeEach(async () => { + fetch.mockReset(); + hash = hash || await sha256(queryString); + }); it('sends a sha256 hash of the query under extensions', done => { fetch.mockResponseOnce(response); @@ -221,7 +220,11 @@ describe('happy path', () => { }); describe('failure path', () => { - beforeEach(fetch.mockReset); + let hash: string; + beforeEach(async () => { + fetch.mockReset(); + hash = hash || await sha256(queryString); + }); it('correctly identifies the error shape from the server', done => { fetch.mockResponseOnce(errorResponse); From 77940eb9f416d984c86513daafc9ae5449ffb76f Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 9 Feb 2021 15:34:26 -0500 Subject: [PATCH 043/380] Test POST requests without useGETForHashedQueries. --- src/link/persisted-queries/__tests__/index.ts | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/src/link/persisted-queries/__tests__/index.ts b/src/link/persisted-queries/__tests__/index.ts index 837208bf4ce..ebdc868bcdc 100644 --- a/src/link/persisted-queries/__tests__/index.ts +++ b/src/link/persisted-queries/__tests__/index.ts @@ -264,6 +264,43 @@ describe('failure path', () => { }, done.fail); }); + it('sends POST for both requests without useGETForHashedQueries', done => { + fetch.mockResponseOnce(errorResponse); + fetch.mockResponseOnce(response); + const link = createPersistedQuery({ sha256 }).concat( + createHttpLink(), + ); + execute(link, { query, variables }).subscribe(result => { + expect(result.data).toEqual(data); + const [, failure] = fetch.mock.calls[0]; + expect(failure!.method).toBe('POST'); + expect(JSON.parse(failure!.body!.toString())).toEqual({ + operationName: 'Test', + variables, + extensions: { + persistedQuery: { + version: VERSION, + sha256Hash: hash, + }, + }, + }); + const [, success] = fetch.mock.calls[1]; + expect(success!.method).toBe('POST'); + expect(JSON.parse(success!.body!.toString())).toEqual({ + operationName: 'Test', + query: queryString, + variables, + extensions: { + persistedQuery: { + version: VERSION, + sha256Hash: hash, + }, + }, + }); + done(); + }, done.fail); + }); + it('does not try again after receiving NotSupported error', done => { fetch.mockResponseOnce(giveUpResponse); fetch.mockResponseOnce(response); From cd4cd08aa7268a748f8829c2fd4865ae2236df41 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 9 Feb 2021 15:36:52 -0500 Subject: [PATCH 044/380] Test that POST request is forced when sending full query. --- src/link/persisted-queries/__tests__/index.ts | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/src/link/persisted-queries/__tests__/index.ts b/src/link/persisted-queries/__tests__/index.ts index ebdc868bcdc..d681d99ed92 100644 --- a/src/link/persisted-queries/__tests__/index.ts +++ b/src/link/persisted-queries/__tests__/index.ts @@ -301,6 +301,48 @@ describe('failure path', () => { }, done.fail); }); + // https://github.com/apollographql/apollo-client/pull/7456 + it('forces POST request when sending full query', done => { + fetch.mockResponseOnce(giveUpResponse); + fetch.mockResponseOnce(response); + const link = createPersistedQuery({ + sha256, + disable({ operation }) { + operation.setContext({ + fetchOptions: { + method: 'GET', + }, + }); + return true; + }, + }).concat( + createHttpLink(), + ); + execute(link, { query, variables }).subscribe(result => { + expect(result.data).toEqual(data); + const [, failure] = fetch.mock.calls[0]; + expect(failure!.method).toBe('POST'); + expect(JSON.parse(failure!.body!.toString())).toEqual({ + operationName: 'Test', + variables, + extensions: { + persistedQuery: { + version: VERSION, + sha256Hash: hash, + }, + }, + }); + const [, success] = fetch.mock.calls[1]; + expect(success!.method).toBe('POST'); + expect(JSON.parse(success!.body!.toString())).toEqual({ + operationName: 'Test', + query: queryString, + variables, + }); + done(); + }, done.fail); + }); + it('does not try again after receiving NotSupported error', done => { fetch.mockResponseOnce(giveUpResponse); fetch.mockResponseOnce(response); From 612f6a1ac53105cf262990c525cf5756f2d9e444 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 9 Feb 2021 16:09:57 -0500 Subject: [PATCH 045/380] Mention PR #7456 in CHANGELOG.md. --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e15310b8ace..03676e37db0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,9 @@ TBD - Allow identical subscriptions to be deduplicated by default, like queries.
[@jkossis](https://github.com/jkossis) in [#6910](https://github.com/apollographql/apollo-client/pull/6910) +- Always use `POST` request when falling back to sending full query with `@apollo/client/link/persisted-queries`.
+ [@rieset](https://github.com/rieset) in [#7456](https://github.com/apollographql/apollo-client/pull/7456) + ### Documentation TBD From b1800eaa129e8f91efe7644d4cca28af0e97bf94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arnaud=20Barr=C3=A9?= Date: Tue, 9 Feb 2021 22:33:55 +0100 Subject: [PATCH 046/380] Use Partial typing for fetchMore variables (#7476) --- CHANGELOG.md | 3 +++ src/core/ObservableQuery.ts | 8 ++------ src/core/watchQueryOptions.ts | 4 ++-- src/react/data/QueryData.ts | 4 ++-- src/react/hoc/__tests__/queries/api.test.tsx | 2 +- src/react/hoc/types.ts | 2 +- src/react/types/types.ts | 7 +++---- 7 files changed, 14 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 03676e37db0..6b3cb41e3be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,9 @@ TBD - Always use `POST` request when falling back to sending full query with `@apollo/client/link/persisted-queries`.
[@rieset](https://github.com/rieset) in [#7456](https://github.com/apollographql/apollo-client/pull/7456) +- The `FetchMoreQueryOptions` type now takes two instead of three type parameters (``), thanks to using `Partial` instead of `K extends typeof TVariables` and `Pick`.
+ [@ArnaudBarre](https://github.com/ArnaudBarre) in [#7476](https://github.com/apollographql/apollo-client/pull/7476) + ### Documentation TBD diff --git a/src/core/ObservableQuery.ts b/src/core/ObservableQuery.ts index 0341337a56f..e854a51c09d 100644 --- a/src/core/ObservableQuery.ts +++ b/src/core/ObservableQuery.ts @@ -256,8 +256,8 @@ export class ObservableQuery< ); } - public fetchMore( - fetchMoreOptions: FetchMoreQueryOptions & + public fetchMore( + fetchMoreOptions: FetchMoreQueryOptions & FetchMoreOptions, ): Promise> { const combinedOptions = { @@ -412,10 +412,6 @@ once, rather than every time you call fetchMore.`); * * @param variables: The new set of variables. If there are missing variables, * the previous values of those variables will be used. - * - * @param tryFetch: Try and fetch new results even if the variables haven't - * changed (we may still just hit the store, but if there's nothing in there - * this will refetch) */ public setVariables( variables: TVariables, diff --git a/src/core/watchQueryOptions.ts b/src/core/watchQueryOptions.ts index 3cc707ad204..c10dfce557b 100644 --- a/src/core/watchQueryOptions.ts +++ b/src/core/watchQueryOptions.ts @@ -108,9 +108,9 @@ export interface WatchQueryOptions ) => WatchQueryFetchPolicy); } -export interface FetchMoreQueryOptions { +export interface FetchMoreQueryOptions { query?: DocumentNode | TypedDocumentNode; - variables?: Pick; + variables?: Partial; context?: any; } diff --git a/src/react/data/QueryData.ts b/src/react/data/QueryData.ts index 276339b49f0..980d74f891d 100644 --- a/src/react/data/QueryData.ts +++ b/src/react/data/QueryData.ts @@ -493,8 +493,8 @@ export class QueryData extends OperationData { private obsRefetch = (variables?: Partial) => this.currentObservable?.refetch(variables); - private obsFetchMore = ( - fetchMoreOptions: FetchMoreQueryOptions & + private obsFetchMore = ( + fetchMoreOptions: FetchMoreQueryOptions & FetchMoreOptions ) => this.currentObservable!.fetchMore(fetchMoreOptions); diff --git a/src/react/hoc/__tests__/queries/api.test.tsx b/src/react/hoc/__tests__/queries/api.test.tsx index 566ea979284..b3719c86ad0 100644 --- a/src/react/hoc/__tests__/queries/api.test.tsx +++ b/src/react/hoc/__tests__/queries/api.test.tsx @@ -258,7 +258,7 @@ describe('[queries] api', () => { }; type Data = typeof data1; - type Variables = typeof vars1; + type Variables = { cursor: number | undefined }; const link = mockSingleLink( { request: { query, variables: vars1 }, result: { data: data1 } }, diff --git a/src/react/hoc/types.ts b/src/react/hoc/types.ts index 4c0ec8c3ec8..dddfe8d6f1f 100644 --- a/src/react/hoc/types.ts +++ b/src/react/hoc/types.ts @@ -24,7 +24,7 @@ export interface QueryControls< loading: boolean; variables: TGraphQLVariables; fetchMore: ( - fetchMoreOptions: FetchMoreQueryOptions & + fetchMoreOptions: FetchMoreQueryOptions & FetchMoreOptions ) => Promise>; refetch: (variables?: TGraphQLVariables) => Promise>; diff --git a/src/react/types/types.ts b/src/react/types/types.ts index 2ebfebce434..e08054bd9d9 100644 --- a/src/react/types/types.ts +++ b/src/react/types/types.ts @@ -63,14 +63,13 @@ export type ObservableQueryFields = Pick< | 'refetch' | 'variables' > & { - fetchMore: (( - fetchMoreOptions: FetchMoreQueryOptions & + fetchMore: (( + fetchMoreOptions: FetchMoreQueryOptions & FetchMoreOptions ) => Promise>) & - (( + (( fetchMoreOptions: { query?: DocumentNode | TypedDocumentNode } & FetchMoreQueryOptions< TVariables2, - K, TData > & FetchMoreOptions From d4d3e05f11c7e0265931d189c191184522731851 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Sat, 13 Feb 2021 20:17:22 -0500 Subject: [PATCH 047/380] Bump @apollo/client npm version to 3.4.0-beta.11. --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index a17bd872bf6..d40ee0edfdf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-beta.10", + "version": "3.4.0-beta.11", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 037b5a5cbba..449e25aa400 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-beta.10", + "version": "3.4.0-beta.11", "description": "A fully-featured caching GraphQL client.", "private": true, "keywords": [ From 76987080111898c330878921c6231597eaab06ca Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Mon, 22 Feb 2021 11:28:16 -0800 Subject: [PATCH 048/380] Update to zen-observable-ts@1.0.0 (not beta). --- package-lock.json | 6 +++--- package.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index cd86c500528..f84876fa858 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11397,9 +11397,9 @@ "integrity": "sha512-PQ2PC7R9rslx84ndNBZB/Dkv8V8fZEpk83RLgXtYd0fwUgEjseMn1Dgajh2x6S8QbZAFa9p2qVCEuYZNgve0dQ==" }, "zen-observable-ts": { - "version": "1.0.0-beta.5", - "resolved": "https://registry.npmjs.org/zen-observable-ts/-/zen-observable-ts-1.0.0-beta.5.tgz", - "integrity": "sha512-8EJmjjv0yIsPXta/jn0nFLWfHa0hOqO+6dMqqmHR+Lo5pcrjRFhhOe8sKvzHZDPhCh52R0GMN/4HlaM/ShMRoA==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/zen-observable-ts/-/zen-observable-ts-1.0.0.tgz", + "integrity": "sha512-KmWcbz+9kKUeAQ8btY8m1SsEFgBcp7h/Uf3V5quhan7ZWdjGsf0JcGLULQiwOZibbFWnHkYq8Nn2AZbJabovQg==", "requires": { "@types/zen-observable": "^0.8.2", "zen-observable": "^0.8.15" diff --git a/package.json b/package.json index 23280530315..a725b800d23 100644 --- a/package.json +++ b/package.json @@ -86,7 +86,7 @@ "symbol-observable": "^2.0.0", "ts-invariant": "^0.6.0", "tslib": "^1.10.0", - "zen-observable-ts": "^1.0.0-beta.5" + "zen-observable-ts": "^1.0.0" }, "devDependencies": { "@babel/parser": "7.12.16", From ed17774bb2162b617086660eff9f3994f98842fb Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 2 Mar 2021 11:13:24 -0800 Subject: [PATCH 049/380] Allow merging existing references with incoming non-normalized objects. --- src/cache/inmemory/policies.ts | 35 ++++++++++++++++++++++------------ 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/src/cache/inmemory/policies.ts b/src/cache/inmemory/policies.ts index 36dcb94caac..679fcdf7cdd 100644 --- a/src/cache/inmemory/policies.ts +++ b/src/cache/inmemory/policies.ts @@ -23,7 +23,12 @@ import { canUseWeakMap, compact, } from '../../utilities'; -import { IdGetter, ReadMergeModifyContext, MergeInfo } from "./types"; +import { + IdGetter, + MergeInfo, + NormalizedCache, + ReadMergeModifyContext, +} from "./types"; import { hasOwn, fieldNameFromStoreName, @@ -41,7 +46,6 @@ import { ReadFieldOptions, CanReadFunction, } from '../core/types/common'; -import { FieldValueGetter } from './entityStore'; export type TypePolicies = { [__typename: string]: TypePolicy; @@ -788,7 +792,7 @@ export class Policies { // FieldFunctionOptions object and calling mergeTrueFn, we can // simply call mergeObjects, as mergeTrueFn would. return makeMergeObjectsFunction( - context.store.getFieldValue + context.store, )(existing as StoreObject, incoming as StoreObject); } @@ -832,7 +836,7 @@ function makeFieldFunctionOptions( const storeFieldName = policies.getStoreFieldName(fieldSpec); const fieldName = fieldNameFromStoreName(storeFieldName); const variables = fieldSpec.variables || context.variables; - const { getFieldValue, toReference, canRead } = context.store; + const { toReference, canRead } = context.store; return { args: argsFromFieldSpecifier(fieldSpec), @@ -867,12 +871,12 @@ function makeFieldFunctionOptions( return policies.readField(options, context); }, - mergeObjects: makeMergeObjectsFunction(getFieldValue), + mergeObjects: makeMergeObjectsFunction(context.store), }; } function makeMergeObjectsFunction( - getFieldValue: FieldValueGetter, + store: NormalizedCache, ): MergeObjectsFunction { return function mergeObjects(existing, incoming) { if (Array.isArray(existing) || Array.isArray(incoming)) { @@ -885,17 +889,24 @@ function makeMergeObjectsFunction( // types of options.mergeObjects. if (existing && typeof existing === "object" && incoming && typeof incoming === "object") { - const eType = getFieldValue(existing, "__typename"); - const iType = getFieldValue(incoming, "__typename"); + const eType = store.getFieldValue(existing, "__typename"); + const iType = store.getFieldValue(incoming, "__typename"); const typesDiffer = eType && iType && eType !== iType; - if (typesDiffer || - !storeValueIsStoreObject(existing) || - !storeValueIsStoreObject(incoming)) { + if (typesDiffer) { return incoming; } - return { ...existing, ...incoming }; + if (isReference(existing) && + storeValueIsStoreObject(incoming)) { + store.merge(existing.__ref, incoming); + return existing; + } + + if (storeValueIsStoreObject(existing) && + storeValueIsStoreObject(incoming)) { + return { ...existing, ...incoming }; + } } return incoming; From 287a43fe0ad72d2748837548cc15261e8a346275 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 2 Mar 2021 12:36:15 -0800 Subject: [PATCH 050/380] Allow reversing argument types passed to store.merge. Until now, we have only supported store.merge(id, object), which updates the existing entity identified by id, letting new fields from the provided object take precedence when fields overlap. This commit makes it possible to call store.merge(object, id), which also updates the object identified by id using fields from the provided object, but preserves fields already in the store when there is overlap. --- src/cache/inmemory/entityStore.ts | 32 ++++++++++++++++++++++++++++--- src/cache/inmemory/types.ts | 8 +++++++- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/src/cache/inmemory/entityStore.ts b/src/cache/inmemory/entityStore.ts index 26ca0b4b73d..e1eb7e95b33 100644 --- a/src/cache/inmemory/entityStore.ts +++ b/src/cache/inmemory/entityStore.ts @@ -1,4 +1,5 @@ import { dep, OptimisticDependencyFunction } from 'optimism'; +import invariant from 'ts-invariant'; import { equal } from '@wry/equality'; import { Trie } from '@wry/trie'; @@ -94,13 +95,38 @@ export abstract class EntityStore implements NormalizedCache { } } - public merge(dataId: string, incoming: StoreObject): void { - const existing = this.lookup(dataId); + public merge( + older: string | StoreObject, + newer: StoreObject | string, + ): void { + let dataId: string | undefined; + + const existing: StoreObject | undefined = + typeof older === "string" + ? this.lookup(dataId = older) + : older; + + const incoming: StoreObject | undefined = + typeof newer === "string" + ? this.lookup(dataId = newer) + : newer; + + // If newer was a string ID, but that ID was not defined in this store, + // then there are no fields to be merged, so we're done. + if (!incoming) return; + + invariant( + typeof dataId === "string", + "store.merge expects a string ID", + ); + const merged: StoreObject = new DeepMerger(storeObjectReconciler).merge(existing, incoming); + // Even if merged === existing, existing may have come from a lower // layer, so we always need to set this.data[dataId] on this level. this.data[dataId] = merged; + if (merged !== existing) { delete this.refs[dataId]; if (this.group.caching) { @@ -142,7 +168,7 @@ export abstract class EntityStore implements NormalizedCache { }); Object.keys(fieldsToDirty).forEach( - fieldName => this.group.dirty(dataId, fieldName)); + fieldName => this.group.dirty(dataId as string, fieldName)); } } } diff --git a/src/cache/inmemory/types.ts b/src/cache/inmemory/types.ts index 283f2871021..13959a0c3c2 100644 --- a/src/cache/inmemory/types.ts +++ b/src/cache/inmemory/types.ts @@ -33,7 +33,13 @@ export declare type IdGetter = ( export interface NormalizedCache { has(dataId: string): boolean; get(dataId: string, fieldName: string): StoreValue; - merge(dataId: string, incoming: StoreObject): void; + + // The store.merge method allows either argument to be a string ID, but + // the other argument has to be a StoreObject. Either way, newer fields + // always take precedence over older fields. + merge(olderId: string, newerObject: StoreObject): void; + merge(olderObject: StoreObject, newerId: string): void; + modify(dataId: string, fields: Modifiers | Modifier): boolean; delete(dataId: string, fieldName?: string): boolean; clear(): void; From bf1196556dc0a4ca843b9b268eca2cafc0eb527a Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 2 Mar 2021 12:46:25 -0800 Subject: [PATCH 051/380] Allow merging existing non-normalized objects with incoming references. --- src/cache/inmemory/policies.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/cache/inmemory/policies.ts b/src/cache/inmemory/policies.ts index 679fcdf7cdd..ff77e7f46b2 100644 --- a/src/cache/inmemory/policies.ts +++ b/src/cache/inmemory/policies.ts @@ -903,6 +903,12 @@ function makeMergeObjectsFunction( return existing; } + if (storeValueIsStoreObject(existing) && + isReference(incoming)) { + store.merge(existing, incoming.__ref); + return incoming; + } + if (storeValueIsStoreObject(existing) && storeValueIsStoreObject(incoming)) { return { ...existing, ...incoming }; From 08451e82023594cffdd8eced23443295a7a4e0d5 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 2 Mar 2021 14:30:52 -0800 Subject: [PATCH 052/380] Test merging non-normalized objects with references. --- src/cache/inmemory/__tests__/policies.ts | 182 +++++++++++++++++++++++ 1 file changed, 182 insertions(+) diff --git a/src/cache/inmemory/__tests__/policies.ts b/src/cache/inmemory/__tests__/policies.ts index 33fc3bc4b99..1753d1c33c6 100644 --- a/src/cache/inmemory/__tests__/policies.ts +++ b/src/cache/inmemory/__tests__/policies.ts @@ -4294,6 +4294,188 @@ describe("type policies", function () { expect(personMergeCount).toBe(3); }); + it("can force merging references with non-normalized objects", function () { + const nameQuery = gql` + query GetName { + viewer { + name + } + } + `; + + const emailQuery = gql` + query GetEmail { + viewer { + id + email + } + } + `; + + check(new InMemoryCache({ + typePolicies: { + Query: { + fields: { + viewer: { + merge: true, + }, + }, + }, + }, + })); + + check(new InMemoryCache({ + typePolicies: { + User: { + merge: true, + }, + }, + })); + + function check(cache: InMemoryCache) { + // Write nameQuery first, so the existing data will be a + // non-normalized object when we write emailQuery next. + cache.writeQuery({ + query: nameQuery, + data: { + viewer: { + __typename: "User", + name: "Alice", + }, + }, + }); + + expect(cache.extract()).toEqual({ + ROOT_QUERY: { + __typename: "Query", + viewer: { + __typename: "User", + name: "Alice", + }, + }, + }); + + cache.writeQuery({ + query: emailQuery, + data: { + viewer: { + __typename: "User", + id: 12345, + email: "alice@example.com", + }, + }, + }); + + expect(cache.extract()).toEqual({ + ROOT_QUERY: { + __typename: "Query", + viewer: { + __ref: "User:12345", + }, + }, + "User:12345": { + __typename: "User", + name: "Alice", + id: 12345, + email: "alice@example.com", + }, + }); + + expect(cache.readQuery({ + query: nameQuery, + })).toEqual({ + viewer: { + __typename: "User", + name: "Alice", + }, + }); + + expect(cache.readQuery({ + query: emailQuery, + })).toEqual({ + viewer: { + __typename: "User", + id: 12345, + email: "alice@example.com", + }, + }); + + cache.reset(); + expect(cache.extract()).toEqual({}); + + // Write emailQuery first, so the existing data will be a + // normalized reference when we write nameQuery next. + cache.writeQuery({ + query: emailQuery, + data: { + viewer: { + __typename: "User", + id: 12345, + email: "alice@example.com", + }, + }, + }); + + expect(cache.extract()).toEqual({ + "User:12345": { + id: 12345, + __typename: "User", + email: "alice@example.com" + }, + ROOT_QUERY: { + __typename: "Query", + viewer: { + __ref: "User:12345", + }, + }, + }); + + cache.writeQuery({ + query: nameQuery, + data: { + viewer: { + __typename: "User", + name: "Alice", + }, + }, + }); + + expect(cache.extract()).toEqual({ + "User:12345": { + id: 12345, + __typename: "User", + email: "alice@example.com", + name: "Alice", + }, + ROOT_QUERY: { + __typename: "Query", + viewer: { + __ref: "User:12345", + }, + }, + }); + + expect(cache.readQuery({ + query: nameQuery, + })).toEqual({ + viewer: { + __typename: "User", + name: "Alice", + }, + }); + + expect(cache.readQuery({ + query: emailQuery, + })).toEqual({ + viewer: { + __typename: "User", + id: 12345, + email: "alice@example.com", + }, + }); + } + }); + it("can force merging with inherited field merge function", function () { let authorMergeCount = 0; From 2553695750f62657542792e22d0abe9b50a7dab2 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Wed, 3 Mar 2021 12:28:01 -0800 Subject: [PATCH 053/380] Mention PR #7778 in CHANGELOG.md. --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a62bce6c2f7..21b09c1d7d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,9 @@ TBD - The `FetchMoreQueryOptions` type now takes two instead of three type parameters (``), thanks to using `Partial` instead of `K extends typeof TVariables` and `Pick`.
[@ArnaudBarre](https://github.com/ArnaudBarre) in [#7476](https://github.com/apollographql/apollo-client/pull/7476) +- Allow `merge: true` field policy to merge `Reference` objects with non-normalized objects, and vice-versa.
+ [@benjamn](https://github.com/benjamn) in [#7778](https://github.com/apollographql/apollo-client/pull/7778) + ### Documentation TBD From 9e16daba1dd2645b5baf16879467e85cdb62cf54 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Wed, 3 Mar 2021 12:31:55 -0800 Subject: [PATCH 054/380] Bump @apollo/client npm version to 3.4.0-beta.12. --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 84d179f8f23..eee36fac108 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-beta.11", + "version": "3.4.0-beta.12", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 0bdaca9a2ba..9585da54ef0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-beta.11", + "version": "3.4.0-beta.12", "description": "A fully-featured caching GraphQL client.", "private": true, "keywords": [ From 9fb964e75923439f1495a55551cfbcda04e64a33 Mon Sep 17 00:00:00 2001 From: Mike Deverell Date: Fri, 5 Mar 2021 12:43:06 -0500 Subject: [PATCH 055/380] Prefer `import * as` for imports whose types are re-exported (#7742) Fixes #7741. Using `import React from 'react'` causes issues when the import makes its way into the d.ts file. Users without `esModuleInterop: true` or `allowSyntheticDefaultImports: true` (or `skipLibCheck: true`) in their tsconfig files will get errors from typescript about not being able to default-import using that syntax. This PR fixes the specific imports that would cause issues due to their types being re-exported in `@apollo/client`'s types. --- CHANGELOG.md | 3 +++ src/link/persisted-queries/__tests__/react.tsx | 4 ++-- src/react/components/Mutation.tsx | 2 +- src/react/components/Query.tsx | 2 +- src/react/components/Subscription.tsx | 2 +- src/react/context/ApolloConsumer.tsx | 2 +- src/react/context/ApolloContext.ts | 2 +- src/react/context/ApolloProvider.tsx | 2 +- src/react/hoc/hoc-utils.tsx | 2 +- src/react/hoc/mutation-hoc.tsx | 2 +- src/react/hoc/query-hoc.tsx | 2 +- src/react/hoc/subscription-hoc.tsx | 2 +- src/react/hoc/withApollo.tsx | 2 +- src/react/hooks/useApolloClient.ts | 2 +- src/react/ssr/getDataFromTree.ts | 2 +- src/utilities/testing/mocking/MockedProvider.tsx | 2 +- 16 files changed, 19 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 21b09c1d7d2..a1b5a5b7e1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,9 @@ TBD - Allow `merge: true` field policy to merge `Reference` objects with non-normalized objects, and vice-versa.
[@benjamn](https://github.com/benjamn) in [#7778](https://github.com/apollographql/apollo-client/pull/7778) +- Prefer `import * as namepace ...` for imports whose types are re-exported (and thus may appear in `.d.ts` files).
+ [@devrelm](https://github.com/devrelm) in [#7742](https://github.com/apollographql/apollo-client/pull/7742) + ### Documentation TBD diff --git a/src/link/persisted-queries/__tests__/react.tsx b/src/link/persisted-queries/__tests__/react.tsx index 5c458cd59e1..a9595a1b1ac 100644 --- a/src/link/persisted-queries/__tests__/react.tsx +++ b/src/link/persisted-queries/__tests__/react.tsx @@ -1,5 +1,5 @@ -import React from 'react'; -import ReactDOM from 'react-dom/server'; +import * as React from 'react'; +import * as ReactDOM from 'react-dom/server'; import gql from 'graphql-tag'; import { print } from 'graphql'; import { sha256 } from 'crypto-hash'; diff --git a/src/react/components/Mutation.tsx b/src/react/components/Mutation.tsx index bd2ed3bb83d..0a1f7569039 100644 --- a/src/react/components/Mutation.tsx +++ b/src/react/components/Mutation.tsx @@ -1,4 +1,4 @@ -import PropTypes from 'prop-types'; +import * as PropTypes from 'prop-types'; import { OperationVariables } from '../../core'; import { MutationComponentOptions } from './types'; diff --git a/src/react/components/Query.tsx b/src/react/components/Query.tsx index f875e3d7d32..ee7b79c3253 100644 --- a/src/react/components/Query.tsx +++ b/src/react/components/Query.tsx @@ -1,4 +1,4 @@ -import PropTypes from 'prop-types'; +import * as PropTypes from 'prop-types'; import { OperationVariables } from '../../core'; import { QueryComponentOptions } from './types'; diff --git a/src/react/components/Subscription.tsx b/src/react/components/Subscription.tsx index c7962dddbd3..696c201936f 100644 --- a/src/react/components/Subscription.tsx +++ b/src/react/components/Subscription.tsx @@ -1,4 +1,4 @@ -import PropTypes from 'prop-types'; +import * as PropTypes from 'prop-types'; import { OperationVariables } from '../../core'; import { SubscriptionComponentOptions } from './types'; diff --git a/src/react/context/ApolloConsumer.tsx b/src/react/context/ApolloConsumer.tsx index 97316113c1b..c6ba6fffdc2 100644 --- a/src/react/context/ApolloConsumer.tsx +++ b/src/react/context/ApolloConsumer.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import * as React from 'react'; import { invariant } from 'ts-invariant'; import { ApolloClient } from '../../core'; diff --git a/src/react/context/ApolloContext.ts b/src/react/context/ApolloContext.ts index 1fac5bc5c9d..9e899297881 100644 --- a/src/react/context/ApolloContext.ts +++ b/src/react/context/ApolloContext.ts @@ -1,4 +1,4 @@ -import React from 'react'; +import * as React from 'react'; import { ApolloClient } from '../../core'; import { canUseWeakMap } from '../../utilities'; diff --git a/src/react/context/ApolloProvider.tsx b/src/react/context/ApolloProvider.tsx index adc6d66dd3a..5f678db8c91 100644 --- a/src/react/context/ApolloProvider.tsx +++ b/src/react/context/ApolloProvider.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import * as React from 'react'; import { invariant } from 'ts-invariant'; import { ApolloClient } from '../../core'; diff --git a/src/react/hoc/hoc-utils.tsx b/src/react/hoc/hoc-utils.tsx index 1a80dd6d151..58266adcc3a 100644 --- a/src/react/hoc/hoc-utils.tsx +++ b/src/react/hoc/hoc-utils.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import * as React from 'react'; import { invariant } from 'ts-invariant'; import { OperationVariables } from '../../core'; import { IDocumentDefinition } from '../parser'; diff --git a/src/react/hoc/mutation-hoc.tsx b/src/react/hoc/mutation-hoc.tsx index 5159753e678..6f81396d664 100644 --- a/src/react/hoc/mutation-hoc.tsx +++ b/src/react/hoc/mutation-hoc.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import * as React from 'react'; import { DocumentNode } from 'graphql'; import hoistNonReactStatics from 'hoist-non-react-statics'; diff --git a/src/react/hoc/query-hoc.tsx b/src/react/hoc/query-hoc.tsx index 428a64714d9..7eebb07ad26 100644 --- a/src/react/hoc/query-hoc.tsx +++ b/src/react/hoc/query-hoc.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import * as React from 'react'; import { DocumentNode } from 'graphql'; import hoistNonReactStatics from 'hoist-non-react-statics'; diff --git a/src/react/hoc/subscription-hoc.tsx b/src/react/hoc/subscription-hoc.tsx index 906d14a905f..d7b222ee146 100644 --- a/src/react/hoc/subscription-hoc.tsx +++ b/src/react/hoc/subscription-hoc.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import * as React from 'react'; import { DocumentNode } from 'graphql'; import hoistNonReactStatics from 'hoist-non-react-statics'; diff --git a/src/react/hoc/withApollo.tsx b/src/react/hoc/withApollo.tsx index 9b6422b0ef1..15fa8db2b27 100644 --- a/src/react/hoc/withApollo.tsx +++ b/src/react/hoc/withApollo.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import * as React from 'react'; import hoistNonReactStatics from 'hoist-non-react-statics'; import { invariant } from 'ts-invariant'; diff --git a/src/react/hooks/useApolloClient.ts b/src/react/hooks/useApolloClient.ts index 52390348a0a..461635cab7f 100644 --- a/src/react/hooks/useApolloClient.ts +++ b/src/react/hooks/useApolloClient.ts @@ -1,4 +1,4 @@ -import React from 'react'; +import * as React from 'react'; import { invariant } from 'ts-invariant'; import { ApolloClient } from '../../core'; diff --git a/src/react/ssr/getDataFromTree.ts b/src/react/ssr/getDataFromTree.ts index 9d639827a38..8664b08e98d 100644 --- a/src/react/ssr/getDataFromTree.ts +++ b/src/react/ssr/getDataFromTree.ts @@ -1,4 +1,4 @@ -import React from 'react'; +import * as React from 'react'; import { getApolloContext } from '../context'; import { RenderPromises } from './RenderPromises'; diff --git a/src/utilities/testing/mocking/MockedProvider.tsx b/src/utilities/testing/mocking/MockedProvider.tsx index 5612a0f53c5..7168bb2159f 100644 --- a/src/utilities/testing/mocking/MockedProvider.tsx +++ b/src/utilities/testing/mocking/MockedProvider.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import * as React from 'react'; import { ApolloClient, DefaultOptions } from '../../../core'; import { InMemoryCache as Cache } from '../../../cache'; From 16b08e1af9ba9934041298496e167aafb128c15d Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 9 Mar 2021 13:43:59 -0500 Subject: [PATCH 056/380] Teach Rollup example app about a few more react export names. Fixes these errors: https://github.com/apollographql/apollo-client/issues/7741#issuecomment-791641913 --- examples/bundling/tree-shaking/rollup-ac3/rollup.config.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/examples/bundling/tree-shaking/rollup-ac3/rollup.config.js b/examples/bundling/tree-shaking/rollup-ac3/rollup.config.js index 56962ae698d..925d24b5207 100644 --- a/examples/bundling/tree-shaking/rollup-ac3/rollup.config.js +++ b/examples/bundling/tree-shaking/rollup-ac3/rollup.config.js @@ -29,6 +29,8 @@ function build({ outputPrefix, externals = [], gzip = false }) { cjs({ namedExports: { 'react': [ + 'createContext', + 'createElement', 'useRef', 'useContext', 'useReducer', From e280fe2bea4a3d5f08308c9c641326e8fd87a2d1 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 9 Mar 2021 14:05:53 -0500 Subject: [PATCH 057/380] Clarify implications of #7742 in CHANGELOG.md. --- CHANGELOG.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a1b5a5b7e1e..ebae2bd009d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,11 @@ ### Bug fixes TBD +### Potentially breaking changes + +- Internally, Apollo Client now uses namespace syntax (e.g. `import * as React from "react"`) for imports whose types are re-exported (and thus may appear in `.d.ts` files). This change should remove any need to configure `esModuleInterop` or `allowSyntheticDefaultImports` in `tsconfig.json`, but might require updating bundler configurations that specify named exports of the `react` and `prop-types` packages, to include exports like `createContext` and `createElement` ([example](https://github.com/apollographql/apollo-client/commit/16b08e1af9ba9934041298496e167aafb128c15d)).
+ [@devrelm](https://github.com/devrelm) in [#7742](https://github.com/apollographql/apollo-client/pull/7742) + ### Improvements - `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.
@@ -26,9 +31,6 @@ TBD - Allow `merge: true` field policy to merge `Reference` objects with non-normalized objects, and vice-versa.
[@benjamn](https://github.com/benjamn) in [#7778](https://github.com/apollographql/apollo-client/pull/7778) -- Prefer `import * as namepace ...` for imports whose types are re-exported (and thus may appear in `.d.ts` files).
- [@devrelm](https://github.com/devrelm) in [#7742](https://github.com/apollographql/apollo-client/pull/7742) - ### Documentation TBD From a4946ef46814df83888cc86dafa3886a4e240e32 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 9 Mar 2021 17:01:30 -0500 Subject: [PATCH 058/380] Introduce InMemoryCache#batch method. This new method is similar to performTransaction, but takes an extensible options object rather than positional arguments. Among the new options is an onDirty callback function, which will be invoked for each cache watcher affected by the batch transaction. --- src/cache/core/cache.ts | 34 +++++++++++++++ src/cache/inmemory/inMemoryCache.ts | 66 +++++++++++++++++++++-------- src/core/QueryManager.ts | 25 ++++++----- 3 files changed, 97 insertions(+), 28 deletions(-) diff --git a/src/cache/core/cache.ts b/src/cache/core/cache.ts index 9cc45b7c3e1..f72798dea6e 100644 --- a/src/cache/core/cache.ts +++ b/src/cache/core/cache.ts @@ -11,6 +11,28 @@ import { Cache } from './types/Cache'; export type Transaction = (c: ApolloCache) => void; +export type BatchOptions> = { + // 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, + ) => void | false; +}; + export abstract class ApolloCache implements DataProxy { // required to implement // core API @@ -54,6 +76,18 @@ export abstract class ApolloCache 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) { + const optimisticId = + typeof options.optimistic === "string" ? options.optimistic : + options.optimistic === false ? null : void 0; + this.performTransaction(options.transaction, optimisticId); + } + public abstract performTransaction( transaction: Transaction, // Although subclasses may implement recordOptimisticTransaction diff --git a/src/cache/inmemory/inMemoryCache.ts b/src/cache/inmemory/inMemoryCache.ts index 6bc9d859bb8..29c81a677b9 100644 --- a/src/cache/inmemory/inMemoryCache.ts +++ b/src/cache/inmemory/inMemoryCache.ts @@ -4,7 +4,7 @@ import './fixPolyfills'; import { DocumentNode } from 'graphql'; import { dep, wrap } from 'optimism'; -import { ApolloCache } from '../core/cache'; +import { ApolloCache, BatchOptions } from '../core/cache'; import { Cache } from '../core/types/Cache'; import { MissingFieldError } from '../core/types/common'; import { @@ -35,6 +35,13 @@ export interface InMemoryCacheConfig extends ApolloReducerConfig { typePolicies?: TypePolicies; } +interface BroadcastOptions extends Pick< + BatchOptions, + | "onDirty" +> { + fromOptimisticTransaction: boolean; +} + const defaultConfig: InMemoryCacheConfig = { dataIdFromObject: defaultDataIdFromObject, addTypename: true, @@ -302,10 +309,12 @@ export class InMemoryCache extends ApolloCache { private txCount = 0; - public performTransaction( - transaction: (cache: InMemoryCache) => any, - optimisticId?: string | null, - ) { + public batch(options: BatchOptions) { + const { + transaction, + optimistic = true, + } = options; + const perform = (layer?: EntityStore) => { const { data, optimisticData } = this; ++this.txCount; @@ -323,13 +332,13 @@ export class InMemoryCache extends ApolloCache { let fromOptimisticTransaction = false; - if (typeof optimisticId === 'string') { - // Note that there can be multiple layers with the same optimisticId. + if (typeof optimistic === 'string') { + // Note that there can be multiple layers with the same optimistic ID. // When removeOptimistic(id) is called for that id, all matching layers // will be removed, and the remaining layers will be reapplied. - this.optimisticData = this.optimisticData.addLayer(optimisticId, perform); + this.optimisticData = this.optimisticData.addLayer(optimistic, perform); fromOptimisticTransaction = true; - } else if (optimisticId === null) { + } else if (optimistic === false) { // Ensure both this.data and this.optimisticData refer to the root // (non-optimistic) layer of the cache during the transaction. Note // that this.data could be a Layer if we are currently executing an @@ -343,7 +352,20 @@ export class InMemoryCache extends ApolloCache { } // This broadcast does nothing if this.txCount > 0. - this.broadcastWatches(fromOptimisticTransaction); + this.broadcastWatches({ + onDirty: options.onDirty, + fromOptimisticTransaction, + }); + } + + public performTransaction( + transaction: (cache: InMemoryCache) => any, + optimisticId?: string | null, + ) { + return this.batch({ + transaction, + optimistic: optimisticId || (optimisticId !== null), + }); } public transformDocument(document: DocumentNode): DocumentNode { @@ -362,17 +384,17 @@ export class InMemoryCache extends ApolloCache { return document; } - protected broadcastWatches(fromOptimisticTransaction?: boolean) { + protected broadcastWatches(options?: BroadcastOptions) { if (!this.txCount) { - this.watches.forEach(c => this.maybeBroadcastWatch(c, fromOptimisticTransaction)); + this.watches.forEach(c => this.maybeBroadcastWatch(c, options)); } } private maybeBroadcastWatch = wrap(( c: Cache.WatchOptions, - fromOptimisticTransaction?: boolean, + options?: BroadcastOptions, ) => { - return this.broadcastWatch.call(this, c, !!fromOptimisticTransaction); + return this.broadcastWatch.call(this, c, options); }, { makeCacheKey: (c: Cache.WatchOptions) => { // Return a cache key (thus enabling result caching) only if we're @@ -405,7 +427,7 @@ export class InMemoryCache extends ApolloCache { // the recomputation and the broadcast, in most cases. private broadcastWatch( c: Cache.WatchOptions, - fromOptimisticTransaction: boolean, + options?: BroadcastOptions, ) { // First, invalidate any other maybeBroadcastWatch wrapper functions // currently depending on this Cache.WatchOptions object (including @@ -430,8 +452,18 @@ export class InMemoryCache extends ApolloCache { optimistic: c.optimistic, }); - if (c.optimistic && fromOptimisticTransaction) { - diff.fromOptimisticTransaction = true; + if (options) { + if (c.optimistic && + options.fromOptimisticTransaction) { + diff.fromOptimisticTransaction = true; + } + + if (options.onDirty && + options.onDirty.call(this, c, diff) === false) { + // Returning false from the onDirty callback will prevent calling + // c.callback(diff) for this watcher. + return; + } } c.callback(diff); diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index 152af9de30b..fe84f4a7a62 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -346,17 +346,20 @@ export class QueryManager { }); } - cache.performTransaction(c => { - cacheWrites.forEach(write => c.write(write)); - - // If the mutation has some writes associated with it then we need to - // apply those writes to the store by running this reducer again with a - // write action. - const { update } = mutation; - if (update) { - update(c, mutation.result); - } - }, /* non-optimistic transaction: */ null); + cache.batch({ + transaction(c) { + cacheWrites.forEach(write => c.write(write)); + // If the mutation has some writes associated with it then we need to + // apply those writes to the store by running this reducer again with + // a write action. + const { update } = mutation; + if (update) { + update(c, mutation.result); + } + }, + // Write the final mutation.result to the root layer of the cache. + optimistic: false, + }); } } From b5cd6615ccf012deadfb341c69bdcdc51d872963 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 9 Mar 2021 18:51:12 -0500 Subject: [PATCH 059/380] Basic tests of InMemoryCache#batch and onDirty. --- src/cache/inmemory/__tests__/cache.ts | 135 +++++++++++++++++++++++++- 1 file changed, 133 insertions(+), 2 deletions(-) diff --git a/src/cache/inmemory/__tests__/cache.ts b/src/cache/inmemory/__tests__/cache.ts index fcd080404fc..677cb763d77 100644 --- a/src/cache/inmemory/__tests__/cache.ts +++ b/src/cache/inmemory/__tests__/cache.ts @@ -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'; @@ -1327,6 +1327,137 @@ describe('Cache', () => { ); }); + describe('batch', () => { + const last = (array: E[]) => array[array.length - 1]; + + it('calls onDirty for each invalidated watch', () => { + const cache = new InMemoryCache; + + // TODO Is this really necessary? + cache.writeQuery({ + query: gql`query { __typename }`, + data: { + __typename: "Query", + }, + }); + + const aQuery = gql`query { a }`; + const abQuery = gql`query { a b }`; + const bQuery = gql`query { b }`; + + const cancelFns = new Set>(); + + function watch(query: DocumentNode) { + const options: Cache.WatchOptions = { + query, + optimistic: true, + immediate: true, + callback(diff) { + diffs.push(diff); + }, + }; + const diffs: Cache.DiffResult[] = []; + cancelFns.add(cache.watch(options)); + diffs.shift(); // Discard the immediate diff + return { diffs, watch: options }; + } + + const aInfo = watch(aQuery); + const abInfo = watch(abQuery); + const bInfo = watch(bQuery); + + const dirtied = new Map>(); + + 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", + }, + }); + + cancelFns.forEach(cancel => cancel()); + }); + }); + describe('performTransaction', () => { itWithInitialData('will not broadcast mid-transaction', [{}], cache => { let numBroadcasts = 0; @@ -1373,7 +1504,7 @@ describe('Cache', () => { }); }); - describe('performOptimisticTransaction', () => { + describe('recordOptimisticTransaction', () => { itWithInitialData('will only broadcast once', [{}], cache => { let numBroadcasts = 0; From b08c257a60e55e6a23a845f436bfe1534fba1d34 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 9 Mar 2021 20:10:28 -0500 Subject: [PATCH 060/380] Avoid needlessly dirtying ROOT_QUERY.__typename. --- src/cache/inmemory/__tests__/cache.ts | 8 -------- src/cache/inmemory/entityStore.ts | 10 ++++++++++ 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/cache/inmemory/__tests__/cache.ts b/src/cache/inmemory/__tests__/cache.ts index 677cb763d77..9a2bc18de20 100644 --- a/src/cache/inmemory/__tests__/cache.ts +++ b/src/cache/inmemory/__tests__/cache.ts @@ -1333,14 +1333,6 @@ describe('Cache', () => { it('calls onDirty for each invalidated watch', () => { const cache = new InMemoryCache; - // TODO Is this really necessary? - cache.writeQuery({ - query: gql`query { __typename }`, - data: { - __typename: "Query", - }, - }); - const aQuery = gql`query { a }`; const abQuery = gql`query { a b }`; const bQuery = gql`query { b }`; diff --git a/src/cache/inmemory/entityStore.ts b/src/cache/inmemory/entityStore.ts index e1eb7e95b33..659426ee1cb 100644 --- a/src/cache/inmemory/entityStore.ts +++ b/src/cache/inmemory/entityStore.ts @@ -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)); } From 4c0d58fd8f52c6dad70ec2ad036e86a8e1a624f8 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 9 Mar 2021 20:50:01 -0500 Subject: [PATCH 061/380] Test cache.batch with cache.modify and INVALIDATE. --- src/cache/inmemory/__tests__/cache.ts | 107 +++++++++++++++++++++----- 1 file changed, 86 insertions(+), 21 deletions(-) diff --git a/src/cache/inmemory/__tests__/cache.ts b/src/cache/inmemory/__tests__/cache.ts index 9a2bc18de20..5206b2ff00c 100644 --- a/src/cache/inmemory/__tests__/cache.ts +++ b/src/cache/inmemory/__tests__/cache.ts @@ -1330,6 +1330,21 @@ describe('Cache', () => { describe('batch', () => { const last = (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[] = []; + 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; @@ -1337,26 +1352,9 @@ describe('Cache', () => { const abQuery = gql`query { a b }`; const bQuery = gql`query { b }`; - const cancelFns = new Set>(); - - function watch(query: DocumentNode) { - const options: Cache.WatchOptions = { - query, - optimistic: true, - immediate: true, - callback(diff) { - diffs.push(diff); - }, - }; - const diffs: Cache.DiffResult[] = []; - cancelFns.add(cache.watch(options)); - diffs.shift(); // Discard the immediate diff - return { diffs, watch: options }; - } - - const aInfo = watch(aQuery); - const abInfo = watch(abQuery); - const bInfo = watch(bQuery); + const aInfo = watch(cache, aQuery); + const abInfo = watch(cache, abQuery); + const bInfo = watch(cache, bQuery); const dirtied = new Map>(); @@ -1446,7 +1444,74 @@ describe('Cache', () => { }, }); - cancelFns.forEach(cancel => cancel()); + 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.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(); }); }); From 96bc2c2f93a366da7b15558a83d4fd23f36d1a6c Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Thu, 11 Mar 2021 11:44:17 -0500 Subject: [PATCH 062/380] Bump bundlesize limit from 26.1kB to 26.4kB. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index fa28be2e938..6ff6bed7837 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,7 @@ { "name": "apollo-client", "path": "./dist/apollo-client.cjs.min.js", - "maxSize": "26.1 kB" + "maxSize": "26.4 kB" } ], "peerDependencies": { From 215b94f3c2ffe5f6ea91a3998cdfed5695632225 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Thu, 11 Mar 2021 11:47:37 -0500 Subject: [PATCH 063/380] Mention PR #7819 in CHANGELOG.md. --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 18cd346b6d7..bd8f16d1fcc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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.
[@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.
+ [@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`.
[@dannycochran](https://github.com/dannycochran) in [#7431](https://github.com/apollographql/apollo-client/pull/7431) From 9ab14af550d38d19ea0db1d8f0bd821c104ffa02 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Thu, 11 Mar 2021 11:52:38 -0500 Subject: [PATCH 064/380] Bump @apollo/client npm version to 3.4.0-beta.13. --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index fc583b0cdc2..5162e085158 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-beta.12", + "version": "3.4.0-beta.13", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 6ff6bed7837..86bf2552564 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-beta.12", + "version": "3.4.0-beta.13", "description": "A fully-featured caching GraphQL client.", "private": true, "keywords": [ From 2d67ff19728d8b03f697c0fc9292b1a24cea117c Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Mon, 15 Mar 2021 12:52:32 -0400 Subject: [PATCH 065/380] Restore changes from PR #7761 on release-3.4 branch. This reverts commit 49b45e311987cb73b7be038c43acd9a761c6ac46. --- CHANGELOG.md | 2 + src/core/QueryManager.ts | 53 ++++++---- src/core/__tests__/fetchPolicies.ts | 157 ++++++++++++++++++++++++++++ 3 files changed, 190 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bd8f16d1fcc..45f2369efa5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,8 @@ TBD - Maintain serial ordering of `asyncMap` mapping function calls, and prevent potential unhandled `Promise` rejection errors.
[@benjamn](https://github.com/benjamn) in [#7818](https://github.com/apollographql/apollo-client/pull/7818) +- Preserve fetch policy even when `notifyOnNetworkStatusChange` is set
+ [@jcreighton](https://github.com/jcreighton) in [#7761](https://github.com/apollographql/apollo-client/pull/7761) ## Apollo Client 3.3.11 ### Bug fixes diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index fe84f4a7a62..eb10408da84 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -890,7 +890,6 @@ export class QueryManager { const query = this.transform(options.query).document; const variables = this.getVariables(query, options.variables) as TVars; const queryInfo = this.getQuery(queryId); - const oldNetworkStatus = queryInfo.networkStatus; let { fetchPolicy = "cache-first" as WatchQueryFetchPolicy, @@ -900,26 +899,6 @@ export class QueryManager { context = {}, } = options; - const mightUseNetwork = - fetchPolicy === "cache-first" || - fetchPolicy === "cache-and-network" || - fetchPolicy === "network-only" || - fetchPolicy === "no-cache"; - - if (mightUseNetwork && - notifyOnNetworkStatusChange && - typeof oldNetworkStatus === "number" && - oldNetworkStatus !== networkStatus && - isNetworkRequestInFlight(networkStatus)) { - // In order to force delivery of an incomplete cache result with - // loading:true, we tweak the fetchPolicy to include the cache, and - // pretend that returnPartialData was enabled. - if (fetchPolicy !== "cache-first") { - fetchPolicy = "cache-and-network"; - } - returnPartialData = true; - } - const normalized = Object.assign({}, options, { query, variables, @@ -1047,8 +1026,11 @@ export class QueryManager { errorPolicy, returnPartialData, context, + notifyOnNetworkStatusChange, } = options; + const oldNetworkStatus = queryInfo.networkStatus; + queryInfo.init({ document: query, variables, @@ -1101,6 +1083,13 @@ export class QueryManager { errorPolicy, }); + const shouldNotifyOnNetworkStatusChange = () => ( + notifyOnNetworkStatusChange && + typeof oldNetworkStatus === "number" && + oldNetworkStatus !== networkStatus && + isNetworkRequestInFlight(networkStatus) + ); + switch (fetchPolicy) { default: case "cache-first": { const diff = readCache(); @@ -1118,6 +1107,13 @@ export class QueryManager { ]; } + if (shouldNotifyOnNetworkStatusChange()) { + return [ + resultsFromCache(diff), + resultsFromLink(true), + ]; + } + return [ resultsFromLink(true), ]; @@ -1126,7 +1122,7 @@ export class QueryManager { case "cache-and-network": { const diff = readCache(); - if (diff.complete || returnPartialData) { + if (diff.complete || returnPartialData || shouldNotifyOnNetworkStatusChange()) { return [ resultsFromCache(diff), resultsFromLink(true), @@ -1144,9 +1140,22 @@ export class QueryManager { ]; case "network-only": + if (shouldNotifyOnNetworkStatusChange()) { + const diff = readCache(); + + return [ + resultsFromCache(diff), + resultsFromLink(true), + ]; + } + return [resultsFromLink(true)]; case "no-cache": + if (shouldNotifyOnNetworkStatusChange()) { + return [resultsFromCache(queryInfo.getDiff()), resultsFromLink(false)]; + } + return [resultsFromLink(false)]; case "standby": diff --git a/src/core/__tests__/fetchPolicies.ts b/src/core/__tests__/fetchPolicies.ts index b67128eb701..f05b56cfe3d 100644 --- a/src/core/__tests__/fetchPolicies.ts +++ b/src/core/__tests__/fetchPolicies.ts @@ -324,6 +324,163 @@ describe('no-cache', () => { }) .then(resolve, reject); }); + + describe('when notifyOnNetworkStatusChange is set', () => { + itAsync('does not save the data to the cache on success', (resolve, reject) => { + let called = 0; + const inspector = new ApolloLink((operation, forward) => { + called++; + return forward(operation).map(result => { + called++; + return result; + }); + }); + + const client = new ApolloClient({ + link: inspector.concat(createLink(reject)), + cache: new InMemoryCache({ addTypename: false }), + }); + + return client.query({ query, fetchPolicy: 'no-cache', notifyOnNetworkStatusChange: true }).then( + () => client.query({ query }).then(actualResult => { + expect(stripSymbols(actualResult.data)).toEqual(result); + // the second query couldn't read anything from the cache + expect(called).toBe(4); + }), + ).then(resolve, reject); + }); + + itAsync('does not save data to the cache on failure', (resolve, reject) => { + let called = 0; + const inspector = new ApolloLink((operation, forward) => { + called++; + return forward(operation).map(result => { + called++; + return result; + }); + }); + + const client = new ApolloClient({ + link: inspector.concat(createFailureLink()), + cache: new InMemoryCache({ addTypename: false }), + }); + + let didFail = false; + return client + .query({ query, fetchPolicy: 'no-cache', notifyOnNetworkStatusChange: true }) + .catch(e => { + expect(e.message).toMatch('query failed'); + didFail = true; + }) + .then(() => client.query({ query }).then(actualResult => { + expect(stripSymbols(actualResult.data)).toEqual(result); + // the first error doesn't call .map on the inspector + expect(called).toBe(3); + expect(didFail).toBe(true); + })) + .then(resolve, reject); + }); + + itAsync('gives appropriate networkStatus for watched queries', (resolve, reject) => { + const client = new ApolloClient({ + link: ApolloLink.empty(), + cache: new InMemoryCache(), + resolvers: { + Query: { + hero(_data, args) { + return { + __typename: 'Hero', + ...args, + name: 'Luke Skywalker', + }; + }, + }, + }, + }); + + const observable = client.watchQuery({ + query: gql` + query FetchLuke($id: String) { + hero(id: $id) @client { + id + name + } + } + `, + fetchPolicy: 'no-cache', + variables: { id: '1' }, + notifyOnNetworkStatusChange: true, + }); + + function dataWithId(id: number | string) { + return { + hero: { + __typename: 'Hero', + id: String(id), + name: 'Luke Skywalker', + }, + }; + } + + subscribeAndCount(reject, observable, (count, result) => { + if (count === 1) { + expect(result).toEqual({ + data: dataWithId(1), + loading: false, + networkStatus: NetworkStatus.ready, + }); + expect(client.cache.extract(true)).toEqual({}); + return observable.setVariables({ id: '2' }); + } else if (count === 2) { + expect(result).toEqual({ + data: {}, + loading: true, + networkStatus: NetworkStatus.setVariables, + partial: true, + }); + } else if (count === 3) { + expect(result).toEqual({ + data: dataWithId(2), + loading: false, + networkStatus: NetworkStatus.ready, + }); + expect(client.cache.extract(true)).toEqual({}); + return observable.refetch(); + } else if (count === 4) { + expect(result).toEqual({ + data: dataWithId(2), + loading: true, + networkStatus: NetworkStatus.refetch, + }); + expect(client.cache.extract(true)).toEqual({}); + } else if (count === 5) { + expect(result).toEqual({ + data: dataWithId(2), + loading: false, + networkStatus: NetworkStatus.ready, + }); + expect(client.cache.extract(true)).toEqual({}); + return observable.refetch({ id: '3' }); + } else if (count === 6) { + expect(result).toEqual({ + data: {}, + loading: true, + networkStatus: NetworkStatus.setVariables, + partial: true, + }); + expect(client.cache.extract(true)).toEqual({}); + } else if (count === 7) { + expect(result).toEqual({ + data: dataWithId(3), + loading: false, + networkStatus: NetworkStatus.ready, + }); + expect(client.cache.extract(true)).toEqual({}); + resolve(); + } + }); + }); + }); }); describe('cache-first', () => { From cd9306d7f5d1cf3b7ede079aac6e77ac6e7b0257 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 9 Mar 2021 14:31:06 -0500 Subject: [PATCH 066/380] Some superficial/stylistic simplifications. --- src/core/QueryManager.ts | 29 +++++++++++------------------ 1 file changed, 11 insertions(+), 18 deletions(-) diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index eb10408da84..d906530b98e 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -1083,12 +1083,11 @@ export class QueryManager { errorPolicy, }); - const shouldNotifyOnNetworkStatusChange = () => ( + const shouldNotify = notifyOnNetworkStatusChange && typeof oldNetworkStatus === "number" && oldNetworkStatus !== networkStatus && - isNetworkRequestInFlight(networkStatus) - ); + isNetworkRequestInFlight(networkStatus); switch (fetchPolicy) { default: case "cache-first": { @@ -1100,14 +1099,7 @@ export class QueryManager { ]; } - if (returnPartialData) { - return [ - resultsFromCache(diff), - resultsFromLink(true), - ]; - } - - if (shouldNotifyOnNetworkStatusChange()) { + if (returnPartialData || shouldNotify) { return [ resultsFromCache(diff), resultsFromLink(true), @@ -1122,7 +1114,7 @@ export class QueryManager { case "cache-and-network": { const diff = readCache(); - if (diff.complete || returnPartialData || shouldNotifyOnNetworkStatusChange()) { + if (diff.complete || returnPartialData || shouldNotify) { return [ resultsFromCache(diff), resultsFromLink(true), @@ -1140,11 +1132,9 @@ export class QueryManager { ]; case "network-only": - if (shouldNotifyOnNetworkStatusChange()) { - const diff = readCache(); - + if (shouldNotify) { return [ - resultsFromCache(diff), + resultsFromCache(readCache()), resultsFromLink(true), ]; } @@ -1152,8 +1142,11 @@ export class QueryManager { return [resultsFromLink(true)]; case "no-cache": - if (shouldNotifyOnNetworkStatusChange()) { - return [resultsFromCache(queryInfo.getDiff()), resultsFromLink(false)]; + if (shouldNotify) { + return [ + resultsFromCache(queryInfo.getDiff()), + resultsFromLink(false), + ]; } return [resultsFromLink(false)]; From 436e15afac7b61a33b8d907a464d571ad5145c61 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 9 Mar 2021 14:59:37 -0500 Subject: [PATCH 067/380] Make QueryInfo#getDiff return stub result for no-cache policy. --- src/core/QueryInfo.ts | 5 +++++ src/core/QueryManager.ts | 11 +++++++---- src/core/__tests__/fetchPolicies.ts | 2 -- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/core/QueryInfo.ts b/src/core/QueryInfo.ts index 90945fa1189..7d8ae8ac8d0 100644 --- a/src/core/QueryInfo.ts +++ b/src/core/QueryInfo.ts @@ -152,6 +152,11 @@ export class QueryInfo { this.updateWatch(this.variables = variables); + const oq = this.observableQuery; + if (oq && oq.options.fetchPolicy === "no-cache") { + return { complete: false }; + } + return this.diff = this.cache.diff({ query: this.document!, variables, diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index d906530b98e..fa878304820 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -1044,7 +1044,7 @@ export class QueryManager { diff: Cache.DiffResult, networkStatus = queryInfo.networkStatus || NetworkStatus.loading, ) => { - const data = diff.result as TData; + const data = diff.result; if (process.env.NODE_ENV !== 'production' && isNonEmptyArray(diff.missing) && @@ -1055,21 +1055,21 @@ export class QueryManager { }`, diff.missing); } - const fromData = (data: TData) => Observable.of({ + const fromData = (data: TData | undefined) => Observable.of({ data, loading: isNetworkRequestInFlight(networkStatus), networkStatus, ...(diff.complete ? null : { partial: true }), } as ApolloQueryResult); - if (this.transform(query).hasForcedResolvers) { + if (data && this.transform(query).hasForcedResolvers) { return this.localState.runResolvers({ document: query, remoteResult: { data }, context, variables, onlyRunForcedResolvers: true, - }).then(resolved => fromData(resolved.data!)); + }).then(resolved => fromData(resolved.data || void 0)); } return fromData(data); @@ -1144,6 +1144,9 @@ export class QueryManager { case "no-cache": if (shouldNotify) { return [ + // Note that queryInfo.getDiff() for no-cache queries does not call + // cache.diff, but instead returns a { complete: false } stub result + // when there is no queryInfo.diff already defined. resultsFromCache(queryInfo.getDiff()), resultsFromLink(false), ]; diff --git a/src/core/__tests__/fetchPolicies.ts b/src/core/__tests__/fetchPolicies.ts index f05b56cfe3d..16cecb01b92 100644 --- a/src/core/__tests__/fetchPolicies.ts +++ b/src/core/__tests__/fetchPolicies.ts @@ -433,7 +433,6 @@ describe('no-cache', () => { return observable.setVariables({ id: '2' }); } else if (count === 2) { expect(result).toEqual({ - data: {}, loading: true, networkStatus: NetworkStatus.setVariables, partial: true, @@ -463,7 +462,6 @@ describe('no-cache', () => { return observable.refetch({ id: '3' }); } else if (count === 6) { expect(result).toEqual({ - data: {}, loading: true, networkStatus: NetworkStatus.setVariables, partial: true, From f006cd94c12230e41676a9db222c9d3bb06c1843 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Mon, 15 Mar 2021 09:33:16 -0400 Subject: [PATCH 068/380] Add more details to CHANGELOG.md entry for PR #7761. --- CHANGELOG.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 45f2369efa5..14485020966 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ TBD - Internally, Apollo Client now uses namespace syntax (e.g. `import * as React from "react"`) for imports whose types are re-exported (and thus may appear in `.d.ts` files). This change should remove any need to configure `esModuleInterop` or `allowSyntheticDefaultImports` in `tsconfig.json`, but might require updating bundler configurations that specify named exports of the `react` and `prop-types` packages, to include exports like `createContext` and `createElement` ([example](https://github.com/apollographql/apollo-client/commit/16b08e1af9ba9934041298496e167aafb128c15d)).
[@devrelm](https://github.com/devrelm) in [#7742](https://github.com/apollographql/apollo-client/pull/7742) +- Respect `no-cache` fetch policy (by not reading any `data` from the cache) for `loading: true` results triggered by `notifyOnNetworkStatusChange: true`.
+ [@jcreighton](https://github.com/jcreighton) in [#7761](https://github.com/apollographql/apollo-client/pull/7761) + ### Improvements - `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.
@@ -44,8 +47,6 @@ TBD - Maintain serial ordering of `asyncMap` mapping function calls, and prevent potential unhandled `Promise` rejection errors.
[@benjamn](https://github.com/benjamn) in [#7818](https://github.com/apollographql/apollo-client/pull/7818) -- Preserve fetch policy even when `notifyOnNetworkStatusChange` is set
- [@jcreighton](https://github.com/jcreighton) in [#7761](https://github.com/apollographql/apollo-client/pull/7761) ## Apollo Client 3.3.11 ### Bug fixes From feb31e32cff1171cf1bbbfaebf42ff03f1b7ca09 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Mon, 15 Mar 2021 15:53:14 -0400 Subject: [PATCH 069/380] Bump @apollo/client npm version to 3.4.0-beta.14. --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index af995d72fa6..a33c312bbe3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-beta.13", + "version": "3.4.0-beta.14", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index b3a56e4c960..e3f16c6d3c0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-beta.13", + "version": "3.4.0-beta.14", "description": "A fully-featured caching GraphQL client.", "private": true, "keywords": [ From 71be73eaac6edd5487cfb656ec775f25f43b66bf Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Wed, 17 Mar 2021 19:09:03 -0400 Subject: [PATCH 070/380] Update ts-invariant to version 0.7.0. --- package-lock.json | 19 ++++++++++++++++--- package.json | 2 +- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index a33c312bbe3..de50d77c7c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,19 @@ "ts-invariant": "^0.6.0", "tslib": "^1.10.0", "zen-observable": "^0.8.14" + }, + "dependencies": { + "ts-invariant": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/ts-invariant/-/ts-invariant-0.6.2.tgz", + "integrity": "sha512-hsVurayufl1gXg8CHtgZkB7X0KtA3TrI3xcJ9xkRr8FeJHnM/TIEQkgBq9XkpduyBWWUdlRIR9xWf4Lxq3LJTg==", + "dev": true, + "requires": { + "@types/ungap__global-this": "^0.3.1", + "@ungap/global-this": "^0.4.2", + "tslib": "^1.9.3" + } + } } }, "@babel/code-frame": { @@ -11216,9 +11229,9 @@ } }, "ts-invariant": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/ts-invariant/-/ts-invariant-0.6.2.tgz", - "integrity": "sha512-hsVurayufl1gXg8CHtgZkB7X0KtA3TrI3xcJ9xkRr8FeJHnM/TIEQkgBq9XkpduyBWWUdlRIR9xWf4Lxq3LJTg==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/ts-invariant/-/ts-invariant-0.7.0.tgz", + "integrity": "sha512-Ar5Y6ZSWZsN/e6A2WtbK8G0Z/+Qy6wsOOcucdoLQ2JZnbuorlEnXH003Ym6i4+X3C8rZNNmplYuingSQ8JSiWA==", "requires": { "@types/ungap__global-this": "^0.3.1", "@ungap/global-this": "^0.4.2", diff --git a/package.json b/package.json index e3f16c6d3c0..1572ae71cb6 100644 --- a/package.json +++ b/package.json @@ -84,7 +84,7 @@ "optimism": "^0.14.0", "prop-types": "^15.7.2", "symbol-observable": "^2.0.0", - "ts-invariant": "^0.6.2", + "ts-invariant": "^0.7.0", "tslib": "^1.10.0", "zen-observable-ts": "^1.0.0" }, From 4635e9b3877d6489269b7cbdc90ab74fe8441d4d Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Wed, 17 Mar 2021 19:35:00 -0400 Subject: [PATCH 071/380] Bump @apollo/client npm version to 3.4.0-beta.15. --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index de50d77c7c0..e812ac72770 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-beta.14", + "version": "3.4.0-beta.15", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 1572ae71cb6..09486758511 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-beta.14", + "version": "3.4.0-beta.15", "description": "A fully-featured caching GraphQL client.", "private": true, "keywords": [ From b5194e991c8ee02ab6a212b936bcaaa4da53da78 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Mon, 22 Mar 2021 13:23:36 -0400 Subject: [PATCH 072/380] Move withError test helper to inmemory/__tests__/helpers.ts. This allows us to run individual test modules like readFromStore.ts and roundtrip.ts without also triggering all the diffAgainstStore.ts tests. --- .../inmemory/__tests__/diffAgainstStore.ts | 24 ++++--------------- src/cache/inmemory/__tests__/helpers.ts | 18 ++++++++++++++ src/cache/inmemory/__tests__/readFromStore.ts | 7 ++++-- src/cache/inmemory/__tests__/roundtrip.ts | 7 ++++-- 4 files changed, 33 insertions(+), 23 deletions(-) diff --git a/src/cache/inmemory/__tests__/diffAgainstStore.ts b/src/cache/inmemory/__tests__/diffAgainstStore.ts index cf52a593d70..52cf6be8f94 100644 --- a/src/cache/inmemory/__tests__/diffAgainstStore.ts +++ b/src/cache/inmemory/__tests__/diffAgainstStore.ts @@ -1,32 +1,18 @@ import gql, { disableFragmentWarnings } from 'graphql-tag'; -import { defaultNormalizedCacheFactory, writeQueryToStore } from './helpers'; import { StoreReader } from '../readFromStore'; import { StoreWriter } from '../writeToStore'; import { defaultDataIdFromObject } from '../policies'; import { NormalizedCache, Reference } from '../types'; import { InMemoryCache } from '../inMemoryCache'; +import { + defaultNormalizedCacheFactory, + writeQueryToStore, + withError, +} from './helpers'; disableFragmentWarnings(); -export function withError(func: Function, regex?: RegExp) { - let message: string = null as never; - const { error } = console; - console.error = (m: any) => { - message = m; - }; - - try { - const result = func(); - if (regex) { - expect(message).toMatch(regex); - } - return result; - } finally { - console.error = error; - } -} - describe('diffing queries against the store', () => { const cache = new InMemoryCache({ dataIdFromObject: defaultDataIdFromObject, diff --git a/src/cache/inmemory/__tests__/helpers.ts b/src/cache/inmemory/__tests__/helpers.ts index 3a518ff5027..cceaf9b37ca 100644 --- a/src/cache/inmemory/__tests__/helpers.ts +++ b/src/cache/inmemory/__tests__/helpers.ts @@ -48,6 +48,24 @@ export function writeQueryToStore( return store; } +export function withError(func: Function, regex?: RegExp) { + let message: string = null as never; + const { error } = console; + console.error = (m: any) => { + message = m; + }; + + try { + const result = func(); + if (regex) { + expect(message).toMatch(regex); + } + return result; + } finally { + console.error = error; + } +} + describe("defaultNormalizedCacheFactory", function () { it("should return an EntityStore", function () { const store = defaultNormalizedCacheFactory(); diff --git a/src/cache/inmemory/__tests__/readFromStore.ts b/src/cache/inmemory/__tests__/readFromStore.ts index bec47f710eb..b546b4d1f5a 100644 --- a/src/cache/inmemory/__tests__/readFromStore.ts +++ b/src/cache/inmemory/__tests__/readFromStore.ts @@ -7,8 +7,11 @@ import { StoreObject } from '../types'; import { StoreReader } from '../readFromStore'; import { Cache } from '../../core/types/Cache'; import { MissingFieldError } from '../../core/types/common'; -import { defaultNormalizedCacheFactory, readQueryFromStore } from './helpers'; -import { withError } from './diffAgainstStore'; +import { + defaultNormalizedCacheFactory, + readQueryFromStore, + withError, +} from './helpers'; import { makeReference, Reference, diff --git a/src/cache/inmemory/__tests__/roundtrip.ts b/src/cache/inmemory/__tests__/roundtrip.ts index a83590f2e9f..8facc6cbbf5 100644 --- a/src/cache/inmemory/__tests__/roundtrip.ts +++ b/src/cache/inmemory/__tests__/roundtrip.ts @@ -1,12 +1,15 @@ import { DocumentNode } from 'graphql'; import gql from 'graphql-tag'; -import { withError } from './diffAgainstStore'; import { EntityStore } from '../entityStore'; import { StoreReader } from '../readFromStore'; import { StoreWriter } from '../writeToStore'; import { InMemoryCache } from '../inMemoryCache'; -import { writeQueryToStore, readQueryFromStore } from './helpers'; +import { + writeQueryToStore, + readQueryFromStore, + withError, +} from './helpers'; function assertDeeplyFrozen(value: any, stack: any[] = []) { if (value !== null && typeof value === 'object' && stack.indexOf(value) < 0) { From c073615da26018e75288619d2467bc9e10dc694f Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Mon, 22 Mar 2021 16:39:55 -0400 Subject: [PATCH 073/380] Update tslib dependencies to version 2.1.0 (#7863) --- package-lock.json | 198 +++++++++++++++++++++++++++++++++++----------- package.json | 14 ++-- 2 files changed, 159 insertions(+), 53 deletions(-) diff --git a/package-lock.json b/package-lock.json index e812ac72770..8e69a88ed5b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,60 @@ "zen-observable": "^0.8.14" }, "dependencies": { + "@wry/context": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/@wry/context/-/context-0.5.4.tgz", + "integrity": "sha512-/pktJKHUXDr4D6TJqWgudOPJW2Z+Nb+bqk40jufA3uTkLbnCRKdJPiYDIa/c7mfcPH8Hr6O8zjCERpg5Sq04Zg==", + "dev": true, + "requires": { + "tslib": "^1.14.1" + } + }, + "@wry/equality": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@wry/equality/-/equality-0.3.4.tgz", + "integrity": "sha512-1gQQhCPenzxw/1HzLlvSIs/59eBHJf9ZDIussjjZhqNSqQuPKQIzN6SWt4kemvlBPDi7RqMuUa03pId7MAE93g==", + "dev": true, + "requires": { + "tslib": "^1.14.1" + } + }, + "@wry/trie": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@wry/trie/-/trie-0.2.2.tgz", + "integrity": "sha512-OxqBB39x6MfHaa2HpMiRMfhuUnQTddD32Ko020eBeJXq87ivX6xnSSnzKHVbA21p7iqBASz8n/07b6W5wW1BVQ==", + "dev": true, + "requires": { + "tslib": "^1.14.1" + } + }, + "graphql-tag": { + "version": "2.12.3", + "resolved": "https://registry.npmjs.org/graphql-tag/-/graphql-tag-2.12.3.tgz", + "integrity": "sha512-5wJMjSvj30yzdciEuk9dPuUBUR56AqDi3xncoYQl1i42pGdSqOJrJsdb/rz5BDoy+qoGvQwABcBeF0xXY3TrKw==", + "dev": true, + "requires": { + "tslib": "^2.1.0" + }, + "dependencies": { + "tslib": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.1.0.tgz", + "integrity": "sha512-hcVC3wYEziELGGmEEXue7D75zbwIIVUMWAVbHItGPx0ziyXxrOMQx4rQEVEV45Ut/1IotuEvwqPopzIOkDMf0A==", + "dev": true + } + } + }, + "optimism": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/optimism/-/optimism-0.14.1.tgz", + "integrity": "sha512-7+1lSN+LJEtaj3uBLLFk8uFCFKy3txLvcvln5Dh1szXjF9yghEMeWclmnk0qdtYZ+lcMNyu48RmQQRw+LRYKSQ==", + "dev": true, + "requires": { + "@wry/context": "^0.5.2", + "@wry/trie": "^0.2.1" + } + }, "ts-invariant": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/ts-invariant/-/ts-invariant-0.6.2.tgz", @@ -35,6 +89,12 @@ "@ungap/global-this": "^0.4.2", "tslib": "^1.9.3" } + }, + "tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true } } }, @@ -2647,7 +2707,8 @@ "@types/ungap__global-this": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/@types/ungap__global-this/-/ungap__global-this-0.3.1.tgz", - "integrity": "sha512-+/DsiV4CxXl6ZWefwHZDXSe1Slitz21tom38qPCaG0DYCS1NnDPIQDTKcmQ/tvK/edJUKkmuIDBJbmKDiB0r/g==" + "integrity": "sha512-+/DsiV4CxXl6ZWefwHZDXSe1Slitz21tom38qPCaG0DYCS1NnDPIQDTKcmQ/tvK/edJUKkmuIDBJbmKDiB0r/g==", + "dev": true }, "@types/yargs": { "version": "13.0.8", @@ -2672,37 +2733,31 @@ "@ungap/global-this": { "version": "0.4.4", "resolved": "https://registry.npmjs.org/@ungap/global-this/-/global-this-0.4.4.tgz", - "integrity": "sha512-mHkm6FvepJECMNthFuIgpAEFmPOk71UyXuIxYfjytvFTnSDBIz7jmViO+LfHI/AjrazWije0PnSP3+/NlwzqtA==" + "integrity": "sha512-mHkm6FvepJECMNthFuIgpAEFmPOk71UyXuIxYfjytvFTnSDBIz7jmViO+LfHI/AjrazWije0PnSP3+/NlwzqtA==", + "dev": true }, "@wry/context": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/@wry/context/-/context-0.5.2.tgz", - "integrity": "sha512-B/JLuRZ/vbEKHRUiGj6xiMojST1kHhu4WcreLfNN7q9DqQFrb97cWgf/kiYsPSUCAMVN0HzfFc8XjJdzgZzfjw==", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@wry/context/-/context-0.6.0.tgz", + "integrity": "sha512-sAgendOXR8dM7stJw3FusRxFHF/ZinU0lffsA2YTyyIOfic86JX02qlPqPVqJNZJPAxFt+2EE8bvq6ZlS0Kf+Q==", "requires": { - "tslib": "^1.9.3" + "tslib": "^2.1.0" } }, "@wry/equality": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@wry/equality/-/equality-0.3.0.tgz", - "integrity": "sha512-DRDAu/e3oWBj826OWNV/GCmSdHD248mASXImgNoLE/3SDvpgb+k6G/+TAmdpIB35ju264+kB22Rx92eXg52DnA==", + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@wry/equality/-/equality-0.4.0.tgz", + "integrity": "sha512-DxN/uawWfhRbgYE55zVCPOoe+jvsQ4m7PT1Wlxjyb/LCCLuU1UsucV2BbCxFAX8bjcSueFBbB5Qfj1Zfe8e7Fw==", "requires": { - "tslib": "^1.9.3" + "tslib": "^2.1.0" } }, "@wry/trie": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/@wry/trie/-/trie-0.2.1.tgz", - "integrity": "sha512-sYkuXZqArky2MLQCv4tLW6hX3N8AfTZ5ZMBc8jC6Yy35WYr82UYLLtjS7k/uRGHOA0yTSjuNadG6QQ6a5CS5hQ==", + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@wry/trie/-/trie-0.3.0.tgz", + "integrity": "sha512-Yw1akIogPhAT6XPYsRHlZZIS0tIGmAl9EYXHi2scf7LPKKqdqmow/Hu4kEqP2cJR3EjaU/9L0ZlAjFf3hFxmug==", "requires": { - "tslib": "^1.14.1" - }, - "dependencies": { - "tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" - } + "tslib": "^2.1.0" } }, "abab": { @@ -2808,6 +2863,12 @@ "tslib": "^1.9.3" } }, + "tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true + }, "zen-observable-ts": { "version": "0.8.21", "resolved": "https://registry.npmjs.org/zen-observable-ts/-/zen-observable-ts-0.8.21.tgz", @@ -2849,6 +2910,12 @@ "requires": { "tslib": "^1.9.3" } + }, + "tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true } } }, @@ -3494,6 +3561,14 @@ "requires": { "pascal-case": "^3.1.1", "tslib": "^1.10.0" + }, + "dependencies": { + "tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true + } } }, "camelcase": { @@ -4755,18 +4830,11 @@ "dev": true }, "graphql-tag": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/graphql-tag/-/graphql-tag-2.12.0.tgz", - "integrity": "sha512-iK040pFYpQpHfqF5UJOlYu2XEw6wx56aiyKJP1zqhxabGssqfbTIqz6U++cBwx/Izad0JNq6IsWvrL+p6d1GOA==", + "version": "2.12.3", + "resolved": "https://registry.npmjs.org/graphql-tag/-/graphql-tag-2.12.3.tgz", + "integrity": "sha512-5wJMjSvj30yzdciEuk9dPuUBUR56AqDi3xncoYQl1i42pGdSqOJrJsdb/rz5BDoy+qoGvQwABcBeF0xXY3TrKw==", "requires": { - "tslib": "^1.14.1" - }, - "dependencies": { - "tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" - } + "tslib": "^2.1.0" } }, "graphql-tools": { @@ -9063,6 +9131,14 @@ "dev": true, "requires": { "tslib": "^1.10.0" + }, + "dependencies": { + "tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true + } } }, "lru-cache": { @@ -9275,6 +9351,14 @@ "requires": { "lower-case": "^2.0.1", "tslib": "^1.10.0" + }, + "dependencies": { + "tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true + } } }, "node-abi": { @@ -9530,12 +9614,12 @@ } }, "optimism": { - "version": "0.14.0", - "resolved": "https://registry.npmjs.org/optimism/-/optimism-0.14.0.tgz", - "integrity": "sha512-ygbNt8n4DOCVpkwiLF+IrKKeNHOjtr9aXLWGP9HNJGoblSGsnVbJLstcH6/nE9Xy5ZQtlkSioFQNnthmENW6FQ==", + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/optimism/-/optimism-0.15.0.tgz", + "integrity": "sha512-KLKl3Kb7hH++s9ewRcBhmfpXgXF0xQ+JZ3xQFuPjnoT6ib2TDmYyVkKENmGxivsN2G3VRxpXuauCkB4GYOhtPw==", "requires": { - "@wry/context": "^0.5.2", - "@wry/trie": "^0.2.1" + "@wry/context": "^0.6.0", + "@wry/trie": "^0.3.0" } }, "optionator": { @@ -9621,6 +9705,14 @@ "requires": { "no-case": "^3.0.3", "tslib": "^1.10.0" + }, + "dependencies": { + "tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true + } } }, "pascalcase": { @@ -10312,6 +10404,14 @@ "dev": true, "requires": { "tslib": "^1.9.0" + }, + "dependencies": { + "tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true + } } }, "safe-buffer": { @@ -11229,13 +11329,11 @@ } }, "ts-invariant": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/ts-invariant/-/ts-invariant-0.7.0.tgz", - "integrity": "sha512-Ar5Y6ZSWZsN/e6A2WtbK8G0Z/+Qy6wsOOcucdoLQ2JZnbuorlEnXH003Ym6i4+X3C8rZNNmplYuingSQ8JSiWA==", + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/ts-invariant/-/ts-invariant-0.7.3.tgz", + "integrity": "sha512-UWDDeovyUTIMWj+45g5nhnl+8oo+GhxL5leTaHn5c8FkQWfh8v66gccLd2/YzVmV5hoQUjCEjhrXnQqVDJdvKA==", "requires": { - "@types/ungap__global-this": "^0.3.1", - "@ungap/global-this": "^0.4.2", - "tslib": "^1.9.3" + "tslib": "^2.1.0" } }, "ts-jest": { @@ -11299,9 +11397,9 @@ } }, "tslib": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.11.1.tgz", - "integrity": "sha512-aZW88SY8kQbU7gpV19lN24LtXh/yD4ZZg6qieAJDDg+YBsJcSmLGK9QpnUjAKVG/xefmvJGd1WUmfpT/g6AJGA==" + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.1.0.tgz", + "integrity": "sha512-hcVC3wYEziELGGmEEXue7D75zbwIIVUMWAVbHItGPx0ziyXxrOMQx4rQEVEV45Ut/1IotuEvwqPopzIOkDMf0A==" }, "tunnel-agent": { "version": "0.6.0", @@ -11553,6 +11651,14 @@ "dev": true, "requires": { "tslib": "^1.8.0" + }, + "dependencies": { + "tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true + } } }, "walker": { diff --git a/package.json b/package.json index 09486758511..fabc1b624c4 100644 --- a/package.json +++ b/package.json @@ -75,17 +75,17 @@ }, "dependencies": { "@graphql-typed-document-node/core": "^3.0.0", - "@wry/context": "^0.5.2", - "@wry/equality": "^0.3.0", - "@wry/trie": "^0.2.1", + "@wry/context": "^0.6.0", + "@wry/equality": "^0.4.0", + "@wry/trie": "^0.3.0", "fast-json-stable-stringify": "^2.0.0", - "graphql-tag": "^2.12.0", + "graphql-tag": "^2.12.3", "hoist-non-react-statics": "^3.3.2", - "optimism": "^0.14.0", + "optimism": "^0.15.0", "prop-types": "^15.7.2", "symbol-observable": "^2.0.0", - "ts-invariant": "^0.7.0", - "tslib": "^1.10.0", + "ts-invariant": "^0.7.3", + "tslib": "^2.1.0", "zen-observable-ts": "^1.0.0" }, "devDependencies": { From 1579afd11a41dc373d26a21c04cbf8a3674b9aa3 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Mon, 22 Mar 2021 15:21:11 -0400 Subject: [PATCH 074/380] Pass makeCacheKey arguments object through to lookupArray. This style prevents TypeScript from unnecessarily generating code to turn the arguments object into a proper Array before passing it to lookupArray, which is capable of handling IArguments objects as-is. --- src/cache/inmemory/entityStore.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/cache/inmemory/entityStore.ts b/src/cache/inmemory/entityStore.ts index 659426ee1cb..ed0e04d986e 100644 --- a/src/cache/inmemory/entityStore.ts +++ b/src/cache/inmemory/entityStore.ts @@ -436,8 +436,9 @@ export abstract class EntityStore implements NormalizedCache { } // Used to compute cache keys specific to this.group. - public makeCacheKey(...args: any[]) { - return this.group.keyMaker.lookupArray(args); + public makeCacheKey(...args: any[]): object; + public makeCacheKey() { + return this.group.keyMaker.lookupArray(arguments); } // Bound function that can be passed around to provide easy access to fields From 87d52610ad47ebad25bc948318de7a5749278e2c Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Fri, 19 Mar 2021 14:56:20 -0400 Subject: [PATCH 075/380] Install permanent Stump layer above EntityStore.Root. The presence of this permanent Layer wrapping the Root EntityStore allows us to read from the store optimistically (that is, registering optimistic dependencies rather than just Root-level non-optimistic dependencies) even when no optimistic Layers are currently active. Previously, those reads would register only non-optimistic dependencies, because they read directly from the Root store. Now, optimistic reads will read "through" the Stump, thereby registering dependencies in the same CacheGroup shared by other optimistic layers. The cached results of these optimistic reads can later be invalidated by optimistic writes, which was not possible before. This fixes a long-standing source of confusion/complexity when working with optimistic updates, by allowing optimistic queries to read from the store in a consistently optimistic fashion, rather than sometimes reading optimistically and sometimes non-optimistically, depending on the dynamic presence or absence of optimistic Layer objects. I chose the name Stump because it's short, and a stump is the part of a tree that's left over (above ground, not counting the roots) after you cut down (or prune back) a tree. --- src/__tests__/client.ts | 5 +- src/cache/inmemory/__tests__/entityStore.ts | 26 +++++++++- src/cache/inmemory/entityStore.ts | 56 +++++++++++++++++---- src/cache/inmemory/inMemoryCache.ts | 8 +-- 4 files changed, 77 insertions(+), 18 deletions(-) diff --git a/src/__tests__/client.ts b/src/__tests__/client.ts index 84536300382..af13183cef1 100644 --- a/src/__tests__/client.ts +++ b/src/__tests__/client.ts @@ -2199,7 +2199,8 @@ describe('client', () => { { const { data, optimisticData } = client.cache as any; expect(optimisticData).not.toBe(data); - expect(optimisticData.parent).toBe(data); + expect(optimisticData.parent).toBe(data.stump); + expect(optimisticData.parent.parent).toBe(data); } mutatePromise @@ -2208,7 +2209,7 @@ describe('client', () => { }) .catch((_: ApolloError) => { const { data, optimisticData } = client.cache as any; - expect(optimisticData).toBe(data); + expect(optimisticData).toBe(data.stump); resolve(); }); }); diff --git a/src/cache/inmemory/__tests__/entityStore.ts b/src/cache/inmemory/__tests__/entityStore.ts index 1de51b2cdcd..5f300956dfc 100644 --- a/src/cache/inmemory/__tests__/entityStore.ts +++ b/src/cache/inmemory/__tests__/entityStore.ts @@ -34,12 +34,12 @@ describe('EntityStore', () => { anotherLayer .removeLayer("with caching") .removeLayer("another layer") - ).toBe(storeWithResultCaching); + ).toBe(storeWithResultCaching.stump); expect(supportsResultCaching(storeWithResultCaching)).toBe(true); const layerWithoutCaching = storeWithoutResultCaching.addLayer("with caching", () => {}); expect(supportsResultCaching(layerWithoutCaching)).toBe(false); - expect(layerWithoutCaching.removeLayer("with caching")).toBe(storeWithoutResultCaching); + expect(layerWithoutCaching.removeLayer("with caching")).toBe(storeWithoutResultCaching.stump); expect(supportsResultCaching(storeWithoutResultCaching)).toBe(false); }); @@ -2413,6 +2413,10 @@ describe('EntityStore', () => { variables: { isbn: "1982103558", }, + // TODO It's a regrettable accident of history that cache.readQuery is + // non-optimistic by default. Perhaps the default can be swapped to true + // in the next major version of Apollo Client. + optimistic: true, }); expect(theEndResult).toEqual(theEndData); @@ -2427,6 +2431,7 @@ describe('EntityStore', () => { variables: { isbn: "1449373321", }, + optimistic: true, })).toBe(diffs[0].result); expect(cache.readQuery({ @@ -2434,6 +2439,7 @@ describe('EntityStore', () => { variables: { isbn: "1982103558", }, + optimistic: true, })).toBe(theEndResult); // Still no additional reads, because both books are cached. @@ -2462,5 +2468,21 @@ describe('EntityStore', () => { "1449373321", "1982103558", ]); + + expect(cache.readQuery({ + query, + variables: { + isbn: "1449373321", + }, + // Read this query non-optimistically, to test that the read function + // runs again, adding "1449373321" again to isbnsWeHaveRead. + optimistic: false, + })).toBe(diffs[0].result); + + expect(isbnsWeHaveRead).toEqual([ + "1449373321", + "1982103558", + "1449373321", + ]); }); }); diff --git a/src/cache/inmemory/entityStore.ts b/src/cache/inmemory/entityStore.ts index ed0e04d986e..f02ad5d8901 100644 --- a/src/cache/inmemory/entityStore.ts +++ b/src/cache/inmemory/entityStore.ts @@ -341,6 +341,17 @@ export abstract class EntityStore implements NormalizedCache { } } + // Remove every Layer, leaving behind only the Root and the Stump. + public prune(): EntityStore { + if (this instanceof Layer) { + const parent = this.removeLayer(this.id); + if (parent !== this) { + return parent.prune(); + } + } + return this; + } + public abstract getStorage( idOrObj: string | StoreObject, ...storeFieldNames: (string | number)[] @@ -547,13 +558,6 @@ function makeDepKey(dataId: string, storeFieldName: string) { export namespace EntityStore { // Refer to this class as EntityStore.Root outside this namespace. export class Root extends EntityStore { - // Although each Root instance gets its own unique CacheGroup object, - // any Layer instances created by calling addLayer need to share a - // single distinct CacheGroup object. Since this shared object must - // outlast the Layer instances themselves, it needs to be created and - // owned by the Root instance. - private sharedLayerGroup: CacheGroup; - constructor({ policies, resultCaching = true, @@ -564,16 +568,19 @@ export namespace EntityStore { seed?: NormalizedCacheObject; }) { super(policies, new CacheGroup(resultCaching)); - this.sharedLayerGroup = new CacheGroup(resultCaching); if (seed) this.replace(seed); } + public readonly stump = new Stump(this); + public addLayer( layerId: string, replay: (layer: EntityStore) => any, ): Layer { - // The replay function will be called in the Layer constructor. - return new Layer(layerId, this, replay, this.sharedLayerGroup); + // Adding an optimistic Layer on top of the Root actually adds the Layer + // on top of the Stump, so the Stump always comes between the Root and + // any Layer objects that we've added. + return this.stump.addLayer(layerId, replay); } public removeLayer(): Root { @@ -657,6 +664,35 @@ class Layer extends EntityStore { } } +// Represents a Layer permanently installed just above the Root, which allows +// reading optimistically (and registering optimistic dependencies) even when +// no optimistic layers are currently active. The stump.group CacheGroup object +// is shared by any/all Layer objects added on top of the Stump. +class Stump extends Layer { + constructor(root: EntityStore.Root) { + super( + "EntityStore.Stump", + root, + () => {}, + new CacheGroup(root.group.caching), + ); + } + + public removeLayer() { + // Never remove the Stump layer. + return this; + } + + public merge() { + // We never want to write any data into the Stump, so we forward any merge + // calls to the Root instead. Another option here would be to throw an + // exception, but the toReference(object, true) function can sometimes + // trigger Stump writes (which used to be Root writes, before the Stump + // concept was introduced). + return this.parent.merge.apply(this.parent, arguments); + } +} + function storeObjectReconciler( existingObject: StoreObject, incomingObject: StoreObject, diff --git a/src/cache/inmemory/inMemoryCache.ts b/src/cache/inmemory/inMemoryCache.ts index 29c81a677b9..ab212046b7b 100644 --- a/src/cache/inmemory/inMemoryCache.ts +++ b/src/cache/inmemory/inMemoryCache.ts @@ -83,7 +83,7 @@ export class InMemoryCache extends ApolloCache { // Passing { resultCaching: false } in the InMemoryCache constructor options // will completely disable dependency tracking, which will improve memory // usage but worsen the performance of repeated reads. - this.data = new EntityStore.Root({ + const rootStore = this.data = new EntityStore.Root({ policies: this.policies, resultCaching: this.config.resultCaching, }); @@ -91,9 +91,9 @@ export class InMemoryCache extends ApolloCache { // When no optimistic writes are currently active, cache.optimisticData === // cache.data, so there are no additional layers on top of the actual data. // When an optimistic update happens, this.optimisticData will become a - // linked list of OptimisticCacheLayer objects that terminates with the + // linked list of EntityStore Layer objects that terminates with the // original this.data cache object. - this.optimisticData = this.data; + this.optimisticData = rootStore.stump; this.storeWriter = new StoreWriter( this, @@ -293,8 +293,8 @@ export class InMemoryCache extends ApolloCache { } public reset(): Promise { + this.optimisticData = this.optimisticData.prune(); this.data.clear(); - this.optimisticData = this.data; this.broadcastWatches(); return Promise.resolve(); } From 1572c95eee1b89bb4338782797bc4a00c12106c4 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Mon, 22 Mar 2021 15:13:43 -0400 Subject: [PATCH 076/380] Make optimistic CacheGroup inherit from non-optimistic CacheGroup. This change means the cached results of optimistic reads can be invalidated by non-optimistic writes, which makes sense because the non-optimistic writes potentially affect data that was previously inherited by optimistic layers and consumed by optimistic reads. I'm not convinced this is absolutely necessary, but it's generally safe to err on the side of over-invalidation. --- src/cache/inmemory/entityStore.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/cache/inmemory/entityStore.ts b/src/cache/inmemory/entityStore.ts index f02ad5d8901..894069fe75a 100644 --- a/src/cache/inmemory/entityStore.ts +++ b/src/cache/inmemory/entityStore.ts @@ -518,7 +518,10 @@ export type FieldValueGetter = EntityStore["getFieldValue"]; class CacheGroup { private d: OptimisticDependencyFunction | null = null; - constructor(public readonly caching: boolean) { + constructor( + public readonly caching: boolean, + private parent: CacheGroup | null = null, + ) { this.d = caching ? dep() : null; } @@ -534,6 +537,9 @@ class CacheGroup { // level of specificity. this.d(makeDepKey(dataId, fieldName)); } + if (this.parent) { + this.parent.depend(dataId, storeFieldName); + } } } @@ -674,7 +680,7 @@ class Stump extends Layer { "EntityStore.Stump", root, () => {}, - new CacheGroup(root.group.caching), + new CacheGroup(root.group.caching, root.group), ); } From 4be203ee14b96b4f0ec9410741876a2a51a3cc5a Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Mon, 22 Mar 2021 15:14:35 -0400 Subject: [PATCH 077/380] Remove unnecessary watchDep member from InMemoryCache class. This maybeBroadcastWatch logic was introduced in #6387 to cope with the possibility that a cache watcher might switch between reading optimistically and non-optimistically over time, depending on the presence/absence of optimistic layers when broadcastWatches was called. Now that cache watchers read consistently optimistically or consistently non-optimistically (thanks to the Stub technique introduced recently), this trick should no longer be necessary. --- src/cache/inmemory/inMemoryCache.ts | 22 +--------------------- 1 file changed, 1 insertion(+), 21 deletions(-) diff --git a/src/cache/inmemory/inMemoryCache.ts b/src/cache/inmemory/inMemoryCache.ts index ab212046b7b..dc62df90e1e 100644 --- a/src/cache/inmemory/inMemoryCache.ts +++ b/src/cache/inmemory/inMemoryCache.ts @@ -2,7 +2,7 @@ import './fixPolyfills'; import { DocumentNode } from 'graphql'; -import { dep, wrap } from 'optimism'; +import { wrap } from 'optimism'; import { ApolloCache, BatchOptions } from '../core/cache'; import { Cache } from '../core/types/Cache'; @@ -225,7 +225,6 @@ export class InMemoryCache extends ApolloCache { if (this.watches.delete(watch) && !this.watches.size) { forgetCache(this); } - this.watchDep.dirty(watch); // Remove this watch from the LRU cache managed by the // maybeBroadcastWatch OptimisticWrapperFunction, to prevent memory // leaks involving the closure of watch.callback. @@ -417,8 +416,6 @@ export class InMemoryCache extends ApolloCache { } }); - private watchDep = dep(); - // This method is wrapped by maybeBroadcastWatch, which is called by // broadcastWatches, so that we compute and broadcast results only when // the data that would be broadcast might have changed. It would be @@ -429,23 +426,6 @@ export class InMemoryCache extends ApolloCache { c: Cache.WatchOptions, options?: BroadcastOptions, ) { - // First, invalidate any other maybeBroadcastWatch wrapper functions - // currently depending on this Cache.WatchOptions object (including - // the one currently calling broadcastWatch), so they will be included - // in the next broadcast, even if the result they receive is the same - // as the previous result they received. This is important because we - // are about to deliver a different result to c.callback, so any - // previous results should have a chance to be redelivered. - this.watchDep.dirty(c); - - // Next, re-depend on this.watchDep for just this invocation of - // maybeBroadcastWatch (this is a no-op if broadcastWatch was not - // called by maybeBroadcastWatch). This allows only the most recent - // maybeBroadcastWatch invocation for this watcher to remain cached, - // enabling re-broadcast of previous results even if they have not - // changed since they were previously delivered. - this.watchDep(c); - const diff = this.diff({ query: c.query, variables: c.variables, From 532de448c25db2e3cbcc05675237a0e38e6dc00a Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 16 Mar 2021 19:27:11 -0400 Subject: [PATCH 078/380] Add Cache.WatchOptions["lastDiff"] deduplication to broadcastWatch. The goal of broadcastWatches is to notify cache watchers of any new data resulting from cache writes. However, it's possible for cache writes to invalidate watched queries in a way that does not result in any differences in the resulting data, so this watch.lastDiff caching saves us from triggering a redundant broadcast of exactly the same data again. Note: thanks to #7439, when two result objects are deeply equal to each another, they will automatically also be === to each other, which is what allows us to get away with the !== check in this code. --- src/cache/core/types/Cache.ts | 1 + src/cache/inmemory/__tests__/cache.ts | 27 ++++--------------- src/cache/inmemory/__tests__/readFromStore.ts | 23 ++++++++-------- src/cache/inmemory/inMemoryCache.ts | 4 ++- 4 files changed, 20 insertions(+), 35 deletions(-) diff --git a/src/cache/core/types/Cache.ts b/src/cache/core/types/Cache.ts index 281e0a1599a..6389d121bd5 100644 --- a/src/cache/core/types/Cache.ts +++ b/src/cache/core/types/Cache.ts @@ -28,6 +28,7 @@ export namespace Cache { export interface WatchOptions extends ReadOptions { immediate?: boolean; callback: WatchCallback; + lastDiff?: DiffResult; } export interface EvictOptions { diff --git a/src/cache/inmemory/__tests__/cache.ts b/src/cache/inmemory/__tests__/cache.ts index 5206b2ff00c..6545a68dba1 100644 --- a/src/cache/inmemory/__tests__/cache.ts +++ b/src/cache/inmemory/__tests__/cache.ts @@ -1492,22 +1492,11 @@ describe('Cache', () => { 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); + // No new diffs should have been generated, since we only invalidated + // fields using cache.modify, and did not change any field values. + expect(aInfo.diffs).toEqual([]); + expect(abInfo.diffs).toEqual([]); + expect(bInfo.diffs).toEqual([]); aInfo.cancel(); abInfo.cancel(); @@ -1686,7 +1675,6 @@ describe("InMemoryCache#broadcastWatches", function () { expect(receivedCallbackResults).toEqual([ received1, // New results: - received1, received2, ]); @@ -1714,7 +1702,6 @@ describe("InMemoryCache#broadcastWatches", function () { }]; expect(receivedCallbackResults).toEqual([ - received1, received1, received2, // New results: @@ -1734,16 +1721,12 @@ describe("InMemoryCache#broadcastWatches", function () { }]; expect(receivedCallbackResults).toEqual([ - received1, received1, received2, received3, received4, // New results: - received1, received2AllCaps, - received3, - received4, ]); }); }); diff --git a/src/cache/inmemory/__tests__/readFromStore.ts b/src/cache/inmemory/__tests__/readFromStore.ts index b546b4d1f5a..2a25c064df9 100644 --- a/src/cache/inmemory/__tests__/readFromStore.ts +++ b/src/cache/inmemory/__tests__/readFromStore.ts @@ -1419,10 +1419,10 @@ describe('reading from the store', () => { const diffs: Cache.DiffResult[] = []; - function watch() { + function watch(immediate = true) { return cache.watch({ query: rulerQuery, - immediate: true, + immediate, optimistic: true, callback(diff) { diffs.push(diff); @@ -1764,14 +1764,10 @@ describe('reading from the store', () => { diffWithZeusAsRuler, ]); - // Rewatch the rulerQuery, which will populate the same diffs array - // that we were using before. - const cancel2 = watch(); - - const diffWithApolloAsRuler = { - complete: true, - result: apolloRulerResult, - }; + // Rewatch the rulerQuery, but avoid delivering an immediate initial + // result (by passing false), so that we can use cache.modify to + // trigger the delivery of diffWithApolloAsRuler below. + const cancel2 = watch(false); expect(diffs).toEqual([ initialDiff, @@ -1779,7 +1775,6 @@ describe('reading from the store', () => { diffWithoutDevouredSons, diffWithChildrenOfZeus, diffWithZeusAsRuler, - diffWithApolloAsRuler, ]); cache.modify({ @@ -1797,6 +1792,11 @@ describe('reading from the store', () => { cancel2(); + const diffWithApolloAsRuler = { + complete: true, + result: apolloRulerResult, + }; + // The cache.modify call should have triggered another diff, since we // overwrote the ROOT_QUERY.ruler field with a valid Reference to the // Apollo entity object. @@ -1807,7 +1807,6 @@ describe('reading from the store', () => { diffWithChildrenOfZeus, diffWithZeusAsRuler, diffWithApolloAsRuler, - diffWithApolloAsRuler, ]); expect( diff --git a/src/cache/inmemory/inMemoryCache.ts b/src/cache/inmemory/inMemoryCache.ts index dc62df90e1e..99b0107880b 100644 --- a/src/cache/inmemory/inMemoryCache.ts +++ b/src/cache/inmemory/inMemoryCache.ts @@ -446,6 +446,8 @@ export class InMemoryCache extends ApolloCache { } } - c.callback(diff); + if (!c.lastDiff || c.lastDiff.result !== diff.result) { + c.callback(c.lastDiff = diff); + } } } From c1f3490a24edd9ffb23e492bd3f7aaf162f9144c Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Fri, 12 Mar 2021 11:31:49 -0500 Subject: [PATCH 079/380] Make BroadcastOptions a subset of BatchOptions. --- src/cache/inmemory/inMemoryCache.ts | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/src/cache/inmemory/inMemoryCache.ts b/src/cache/inmemory/inMemoryCache.ts index 99b0107880b..423437fe29d 100644 --- a/src/cache/inmemory/inMemoryCache.ts +++ b/src/cache/inmemory/inMemoryCache.ts @@ -35,12 +35,11 @@ export interface InMemoryCacheConfig extends ApolloReducerConfig { typePolicies?: TypePolicies; } -interface BroadcastOptions extends Pick< +type BroadcastOptions = Pick< BatchOptions, | "onDirty" -> { - fromOptimisticTransaction: boolean; -} + | "optimistic" +> const defaultConfig: InMemoryCacheConfig = { dataIdFromObject: defaultDataIdFromObject, @@ -329,14 +328,11 @@ export class InMemoryCache extends ApolloCache { } }; - let fromOptimisticTransaction = false; - if (typeof optimistic === 'string') { // Note that there can be multiple layers with the same optimistic ID. // When removeOptimistic(id) is called for that id, all matching layers // will be removed, and the remaining layers will be reapplied. this.optimisticData = this.optimisticData.addLayer(optimistic, perform); - fromOptimisticTransaction = true; } else if (optimistic === false) { // Ensure both this.data and this.optimisticData refer to the root // (non-optimistic) layer of the cache during the transaction. Note @@ -351,10 +347,7 @@ export class InMemoryCache extends ApolloCache { } // This broadcast does nothing if this.txCount > 0. - this.broadcastWatches({ - onDirty: options.onDirty, - fromOptimisticTransaction, - }); + this.broadcastWatches(options); } public performTransaction( @@ -434,7 +427,7 @@ export class InMemoryCache extends ApolloCache { if (options) { if (c.optimistic && - options.fromOptimisticTransaction) { + typeof options.optimistic === "string") { diff.fromOptimisticTransaction = true; } From f20b4a4ae0afc9b33464bfb35da4e6d492a7e7da Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Wed, 24 Mar 2021 16:58:28 -0400 Subject: [PATCH 080/380] Bump @apollo/client npm version to 3.4.0-beta.16. --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4ff8de6fa9d..bf756e85d16 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-beta.15", + "version": "3.4.0-beta.16", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 7eee6641071..42dd692029c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-beta.15", + "version": "3.4.0-beta.16", "description": "A fully-featured caching GraphQL client.", "private": true, "keywords": [ From 278e0b39c3a5d89341fe71e9a5c1a421424b7678 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Wed, 3 Feb 2021 12:53:24 -0500 Subject: [PATCH 081/380] Reorganize interface types to simplify Cache.WriteOptions usage. --- src/cache/core/cache.ts | 41 ++++++++++++------------ src/cache/core/types/Cache.ts | 5 +-- src/cache/core/types/DataProxy.ts | 17 +++------- src/cache/inmemory/__tests__/helpers.ts | 14 +++++--- src/cache/inmemory/__tests__/policies.ts | 2 +- src/cache/inmemory/inMemoryCache.ts | 8 +---- src/cache/inmemory/writeToStore.ts | 30 +++-------------- 7 files changed, 44 insertions(+), 73 deletions(-) diff --git a/src/cache/core/cache.ts b/src/cache/core/cache.ts index f72798dea6e..7b9c55e8886 100644 --- a/src/cache/core/cache.ts +++ b/src/cache/core/cache.ts @@ -167,27 +167,28 @@ export abstract class ApolloCache implements DataProxy { }); } - public writeQuery( - options: Cache.WriteQueryOptions, - ): Reference | undefined { - return this.write({ - dataId: options.id || 'ROOT_QUERY', - result: options.data, - query: options.query, - variables: options.variables, - broadcast: options.broadcast, - }); + public writeQuery({ + id, + data, + ...options + }: Cache.WriteQueryOptions): Reference | undefined { + return this.write(Object.assign(options, { + dataId: id || 'ROOT_QUERY', + result: data, + })); } - public writeFragment( - options: Cache.WriteFragmentOptions, - ): Reference | undefined { - return this.write({ - dataId: options.id, - result: options.data, - variables: options.variables, - query: this.getFragmentDoc(options.fragment, options.fragmentName), - broadcast: options.broadcast, - }); + public writeFragment({ + id, + data, + fragment, + fragmentName, + ...options + }: Cache.WriteFragmentOptions): Reference | undefined { + return this.write(Object.assign(options, { + query: this.getFragmentDoc(fragment, fragmentName), + dataId: id, + result: data, + })); } } diff --git a/src/cache/core/types/Cache.ts b/src/cache/core/types/Cache.ts index 6389d121bd5..deca58cd166 100644 --- a/src/cache/core/types/Cache.ts +++ b/src/cache/core/types/Cache.ts @@ -13,10 +13,11 @@ export namespace Cache { } export interface WriteOptions - extends DataProxy.Query { + extends Omit, "id">, + Omit, "data"> + { dataId?: string; result: TResult; - broadcast?: boolean; } export interface DiffOptions extends ReadOptions { diff --git a/src/cache/core/types/DataProxy.ts b/src/cache/core/types/DataProxy.ts index 0d6f0a774b5..e80dcb47218 100644 --- a/src/cache/core/types/DataProxy.ts +++ b/src/cache/core/types/DataProxy.ts @@ -84,8 +84,7 @@ export namespace DataProxy { optimistic?: boolean; } - export interface WriteQueryOptions - extends Query { + export interface WriteOptions { /** * The data you will be writing to the store. */ @@ -96,17 +95,11 @@ export namespace DataProxy { broadcast?: boolean; } + export interface WriteQueryOptions + extends Query, WriteOptions {} + export interface WriteFragmentOptions - extends Fragment { - /** - * The data you will be writing to the store. - */ - data: TData; - /** - * Whether to notify query watchers (default: true). - */ - broadcast?: boolean; - } + extends Fragment, WriteOptions {} export type DiffResult = { result?: T; diff --git a/src/cache/inmemory/__tests__/helpers.ts b/src/cache/inmemory/__tests__/helpers.ts index cceaf9b37ca..38225053e3f 100644 --- a/src/cache/inmemory/__tests__/helpers.ts +++ b/src/cache/inmemory/__tests__/helpers.ts @@ -6,7 +6,8 @@ import { import { EntityStore } from "../entityStore"; import { InMemoryCache } from "../inMemoryCache"; import { StoreReader } from "../readFromStore"; -import { StoreWriter, WriteToStoreOptions } from "../writeToStore"; +import { StoreWriter } from "../writeToStore"; +import { Cache } from "../../../core"; export function defaultNormalizedCacheFactory( seed?: NormalizedCacheObject, @@ -19,11 +20,10 @@ export function defaultNormalizedCacheFactory( }); } -interface WriteQueryToStoreOptions -extends Omit { +interface WriteQueryToStoreOptions extends Cache.WriteOptions { writer: StoreWriter; store?: NormalizedCache; -} +}; export function readQueryFromStore( reader: StoreReader, @@ -43,8 +43,12 @@ export function writeQueryToStore( store = new EntityStore.Root({ policies: options.writer.cache.policies, }), + ...writeOptions } = options; - options.writer.writeToStore({ ...options, dataId, store }); + options.writer.writeToStore(store, { + ...writeOptions, + dataId, + }); return store; } diff --git a/src/cache/inmemory/__tests__/policies.ts b/src/cache/inmemory/__tests__/policies.ts index 1753d1c33c6..7b7148b402a 100644 --- a/src/cache/inmemory/__tests__/policies.ts +++ b/src/cache/inmemory/__tests__/policies.ts @@ -769,7 +769,7 @@ describe("type policies", function () { }, }; - function check( + function check( query: DocumentNode | TypedDocumentNode, variables?: TVars, ) { diff --git a/src/cache/inmemory/inMemoryCache.ts b/src/cache/inmemory/inMemoryCache.ts index 423437fe29d..e06fed3d4aa 100644 --- a/src/cache/inmemory/inMemoryCache.ts +++ b/src/cache/inmemory/inMemoryCache.ts @@ -148,13 +148,7 @@ export class InMemoryCache extends ApolloCache { public write(options: Cache.WriteOptions): Reference | undefined { try { ++this.txCount; - return this.storeWriter.writeToStore({ - store: this.data, - query: options.query, - result: options.result, - dataId: options.dataId, - variables: options.variables, - }); + return this.storeWriter.writeToStore(this.data, options); } finally { if (!--this.txCount && options.broadcast !== false) { this.broadcastWatches(); diff --git a/src/cache/inmemory/writeToStore.ts b/src/cache/inmemory/writeToStore.ts index 0f91ee7de6b..cf9a41623a5 100644 --- a/src/cache/inmemory/writeToStore.ts +++ b/src/cache/inmemory/writeToStore.ts @@ -1,4 +1,4 @@ -import { SelectionSetNode, FieldNode, DocumentNode } from 'graphql'; +import { SelectionSetNode, FieldNode } from 'graphql'; import { invariant, InvariantError } from 'ts-invariant'; import { equal } from '@wry/equality'; @@ -27,6 +27,7 @@ import { makeProcessedFieldsMerger, fieldNameFromStoreName, storeValueIsStoreObj import { StoreReader } from './readFromStore'; import { InMemoryCache } from './inMemoryCache'; import { EntityStore } from './entityStore'; +import { Cache } from '../../core'; export interface WriteContext extends ReadMergeModifyContext { readonly written: { @@ -45,41 +46,18 @@ interface ProcessSelectionSetOptions { mergeTree: MergeTree; } -export interface WriteToStoreOptions { - query: DocumentNode; - result: Object; - dataId?: string; - store: NormalizedCache; - variables?: Object; -} - export class StoreWriter { constructor( public readonly cache: InMemoryCache, private reader?: StoreReader, ) {} - /** - * Writes the result of a query to the store. - * - * @param result The result object returned for the query document. - * - * @param query The query document whose result we are writing to the store. - * - * @param store The {@link NormalizedCache} used by Apollo for the `data` portion of the store. - * - * @param variables A map from the name of a variable to its value. These variables can be - * referenced by the query document. - * - * @return A `Reference` to the written object. - */ - public writeToStore({ + public writeToStore(store: NormalizedCache, { query, result, dataId, - store, variables, - }: WriteToStoreOptions): Reference | undefined { + }: Cache.WriteOptions): Reference | undefined { const operationDefinition = getOperationDefinition(query)!; const merger = makeProcessedFieldsMerger(); From 9410f1784b88fea74f574e40f5876d8f553aafee Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Mon, 1 Feb 2021 14:14:27 -0500 Subject: [PATCH 082/380] Allow cache write operations to overwrite existing data. By default, cache writes always overwrite existing field data, so this may not seem like a very consequential change. However, when a field has a merge function defined, the merge function will be called whenever incoming data is written to the field, receiving as its first two parameters (0) any existing data (possibly undefined) and (1) the incoming data. Since merge functions generally attempt to combine incoming data with existing data, when a merge function is defined, there is no easy way to tell the cache to _replace_ existing field data with incoming field data, instead of combining them. The overwrite:true option enables the replacement behavior by causing undefined to be passed as the existing data to any merge functions involved in the cache write, so the merge function behaves as if this was the first time any data had been written for the field in question, effectively replacing any existing data. --- src/cache/core/types/DataProxy.ts | 5 +++++ src/cache/inmemory/policies.ts | 11 ++++++++++- src/cache/inmemory/writeToStore.ts | 8 ++++++-- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/cache/core/types/DataProxy.ts b/src/cache/core/types/DataProxy.ts index e80dcb47218..a6e008416cb 100644 --- a/src/cache/core/types/DataProxy.ts +++ b/src/cache/core/types/DataProxy.ts @@ -93,6 +93,11 @@ export namespace DataProxy { * Whether to notify query watchers (default: true). */ broadcast?: boolean; + /** + * When true, ignore existing field data rather than merging it with + * incoming data (default: false). + */ + overwrite?: boolean; } export interface WriteQueryOptions diff --git a/src/cache/inmemory/policies.ts b/src/cache/inmemory/policies.ts index ff77e7f46b2..bd19344b0f6 100644 --- a/src/cache/inmemory/policies.ts +++ b/src/cache/inmemory/policies.ts @@ -46,6 +46,7 @@ import { ReadFieldOptions, CanReadFunction, } from '../core/types/common'; +import { WriteContext } from './writeToStore'; export type TypePolicies = { [__typename: string]: TypePolicy; @@ -784,7 +785,7 @@ export class Policies { existing: StoreValue, incoming: StoreValue, { field, typename, merge }: MergeInfo, - context: ReadMergeModifyContext, + context: WriteContext, storage?: StorageType, ) { if (merge === mergeTrueFn) { @@ -802,6 +803,14 @@ export class Policies { return incoming; } + // If cache.writeQuery or cache.writeFragment was called with + // options.overwrite set to true, we still call merge functions, but + // the existing data is always undefined, so the merge function will + // not attempt to combine the incoming data with the existing data. + if (context.overwrite) { + existing = void 0; + } + return merge(existing, incoming, makeFieldFunctionOptions( this, // Unlike options.readField for read functions, we do not fall diff --git a/src/cache/inmemory/writeToStore.ts b/src/cache/inmemory/writeToStore.ts index cf9a41623a5..0f0dae2316b 100644 --- a/src/cache/inmemory/writeToStore.ts +++ b/src/cache/inmemory/writeToStore.ts @@ -36,6 +36,8 @@ export interface WriteContext extends ReadMergeModifyContext { readonly fragmentMap?: FragmentMap; // General-purpose deep-merge function for use during writes. merge(existing: T, incoming: T): T; + // If true, merge functions will be called with undefined existing data. + overwrite: boolean; }; interface ProcessSelectionSetOptions { @@ -57,6 +59,7 @@ export class StoreWriter { result, dataId, variables, + overwrite, }: Cache.WriteOptions): Reference | undefined { const operationDefinition = getOperationDefinition(query)!; const merger = makeProcessedFieldsMerger(); @@ -80,6 +83,7 @@ export class StoreWriter { variables, varString: JSON.stringify(variables), fragmentMap: createFragmentMap(getFragmentDefinitions(query)), + overwrite: !!overwrite, }, }); @@ -264,7 +268,7 @@ export class StoreWriter { incomingFields = this.applyMerges(mergeTree, entityRef, incomingFields, context); } - if (process.env.NODE_ENV !== "production") { + if (process.env.NODE_ENV !== "production" && !context.overwrite) { const hasSelectionSet = (storeFieldName: string) => fieldsWithSelectionSets.has(fieldNameFromStoreName(storeFieldName)); const fieldsWithSelectionSets = new Set(); @@ -338,7 +342,7 @@ export class StoreWriter { mergeTree: MergeTree, existing: StoreValue, incoming: T, - context: ReadMergeModifyContext, + context: WriteContext, getStorageArgs?: Parameters, ): T { if (mergeTree.map.size && !isReference(incoming)) { From a16205e42866987dbbea1e5d065dab5f7e500454 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Mon, 8 Mar 2021 18:58:39 -0500 Subject: [PATCH 083/380] Make refetch results overwrite existing data. This is how I would ideally like to fix #7491, but I'm worried it could be a breaking change for any application code that relies on refetch results getting merged with existing data, rather than replacing it (which is what refetch was originally meant to do, since AC2). I will follow this commit with one that makes this the overwrite behavior opt-in (and thus backwards compatible). --- src/core/QueryInfo.ts | 11 +++++++++-- src/core/QueryManager.ts | 36 +++++++++++++++++++++++------------- 2 files changed, 32 insertions(+), 15 deletions(-) diff --git a/src/core/QueryInfo.ts b/src/core/QueryInfo.ts index 7d8ae8ac8d0..671a0261b18 100644 --- a/src/core/QueryInfo.ts +++ b/src/core/QueryInfo.ts @@ -25,6 +25,12 @@ export type QueryStoreValue = Pick; +export const enum CacheWriteBehavior { + FORBID, + OVERWRITE, + MERGE, +}; + const destructiveMethodCounts = new ( canUseWeakMap ? WeakMap : Map ), number>(); @@ -307,7 +313,7 @@ export class QueryInfo { | "variables" | "fetchPolicy" | "errorPolicy">, - allowCacheWrite: boolean, + cacheWriteBehavior: CacheWriteBehavior, ) { this.graphQLErrors = isNonEmptyArray(result.errors) ? result.errors : []; @@ -318,7 +324,7 @@ export class QueryInfo { if (options.fetchPolicy === 'no-cache') { this.diff = { result: result.data, complete: true }; - } else if (!this.stopped && allowCacheWrite) { + } else if (!this.stopped && cacheWriteBehavior !== CacheWriteBehavior.FORBID) { if (shouldWriteResult(result, options.errorPolicy)) { // Using a transaction here so we have a chance to read the result // back from the cache before the watch callback fires as a result @@ -330,6 +336,7 @@ export class QueryInfo { query: this.document!, data: result.data as T, variables: options.variables, + overwrite: cacheWriteBehavior === CacheWriteBehavior.OVERWRITE, }); this.lastWrite = { diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index 04f3392e28c..985953a3690 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -38,7 +38,12 @@ import { } from './types'; import { LocalState } from './LocalState'; -import { QueryInfo, QueryStoreValue, shouldWriteResult } from './QueryInfo'; +import { + QueryInfo, + QueryStoreValue, + shouldWriteResult, + CacheWriteBehavior, +} from './QueryInfo'; const { hasOwnProperty } = Object.prototype; @@ -822,7 +827,7 @@ export class QueryManager { private getResultsFromLink( queryInfo: QueryInfo, - allowCacheWrite: boolean, + cacheWriteBehavior: CacheWriteBehavior, options: Pick, | "variables" | "context" @@ -848,7 +853,7 @@ export class QueryManager { graphQLErrors: result.errors, })); } - queryInfo.markResult(result, options, allowCacheWrite); + queryInfo.markResult(result, options, cacheWriteBehavior); queryInfo.markReady(); } @@ -1076,8 +1081,13 @@ export class QueryManager { return fromData(data); }; - const resultsFromLink = (allowCacheWrite: boolean) => - this.getResultsFromLink(queryInfo, allowCacheWrite, { + const cacheWriteBehavior = + fetchPolicy === "no-cache" ? CacheWriteBehavior.FORBID : + networkStatus === NetworkStatus.refetch ? CacheWriteBehavior.OVERWRITE : + CacheWriteBehavior.MERGE; + + const resultsFromLink = () => + this.getResultsFromLink(queryInfo, cacheWriteBehavior, { variables, context, fetchPolicy, @@ -1103,12 +1113,12 @@ export class QueryManager { if (returnPartialData || shouldNotify) { return [ resultsFromCache(diff), - resultsFromLink(true), + resultsFromLink(), ]; } return [ - resultsFromLink(true), + resultsFromLink(), ]; } @@ -1118,12 +1128,12 @@ export class QueryManager { if (diff.complete || returnPartialData || shouldNotify) { return [ resultsFromCache(diff), - resultsFromLink(true), + resultsFromLink(), ]; } return [ - resultsFromLink(true), + resultsFromLink(), ]; } @@ -1136,11 +1146,11 @@ export class QueryManager { if (shouldNotify) { return [ resultsFromCache(readCache()), - resultsFromLink(true), + resultsFromLink(), ]; } - return [resultsFromLink(true)]; + return [resultsFromLink()]; case "no-cache": if (shouldNotify) { @@ -1149,11 +1159,11 @@ export class QueryManager { // cache.diff, but instead returns a { complete: false } stub result // when there is no queryInfo.diff already defined. resultsFromCache(queryInfo.getDiff()), - resultsFromLink(false), + resultsFromLink(), ]; } - return [resultsFromLink(false)]; + return [resultsFromLink()]; case "standby": return []; From d87333bff032ec1fab703e2cb28134200d82e340 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Mon, 8 Mar 2021 19:03:43 -0500 Subject: [PATCH 084/380] Introduce ObservableQuery#refresh as an alternative to refetch. The difference is that refresh overwrites existing fields by not passing existing data to field merge functions, whereas refetch passes existing data to merge functions, thereby combining it with the refetch result. If we continue down this path, we can say "the solution to #7491 is to switch from using refetch to using refresh," but that involves conscious effort by the developer, and would mean maintaining two very similar methods of the ObservableQuery class (refetch and refresh). --- src/core/ObservableQuery.ts | 18 ++++++++++++++++-- src/core/QueryManager.ts | 6 +++--- src/core/networkStatus.ts | 15 ++++++++++++++- .../client/__snapshots__/Query.test.tsx.snap | 1 + src/react/data/QueryData.ts | 4 ++++ src/react/hoc/types.ts | 3 ++- src/react/types/types.ts | 1 + 7 files changed, 41 insertions(+), 7 deletions(-) diff --git a/src/core/ObservableQuery.ts b/src/core/ObservableQuery.ts index 76b31b12d51..5d9c285906f 100644 --- a/src/core/ObservableQuery.ts +++ b/src/core/ObservableQuery.ts @@ -224,6 +224,16 @@ export class ObservableQuery< * the previous values of those variables will be used. */ public refetch(variables?: Partial): Promise> { + return this.refresh(variables, NetworkStatus.refetch); + } + + // Forces a network query, like fetchMore and refetch, but overwrites + // existing cache fields with incoming data, rather than merging field + // values. Can be useful for restarting paginated fields with fresh data. + public refresh( + variables?: Partial, + networkStatus = NetworkStatus.refresh, + ): Promise> { const reobserveOptions: Partial> = { // Always disable polling for refetches. pollInterval: 0, @@ -250,9 +260,13 @@ export class ObservableQuery< this.queryInfo.resetLastWrite(); - return this.newReobserver(false).reobserve( + return ( + networkStatus === NetworkStatus.refresh + ? this.getReobserver() + : this.newReobserver(false) + ).reobserve( reobserveOptions, - NetworkStatus.refetch, + networkStatus, ); } diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index 985953a3690..57ed32579b3 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -889,7 +889,7 @@ export class QueryManager { options: WatchQueryOptions, // The initial networkStatus for this fetch, most often // NetworkStatus.loading, but also possibly fetchMore, poll, refetch, - // or setVariables. + // refresh, or setVariables. networkStatus = NetworkStatus.loading, ): Concast> { const query = this.transform(options.query).document; @@ -1022,7 +1022,7 @@ export class QueryManager { options: WatchQueryOptions, // The initial networkStatus for this fetch, most often // NetworkStatus.loading, but also possibly fetchMore, poll, refetch, - // or setVariables. + // refresh, or setVariables. networkStatus: NetworkStatus, ): ConcastSourcesIterable> { const { @@ -1083,7 +1083,7 @@ export class QueryManager { const cacheWriteBehavior = fetchPolicy === "no-cache" ? CacheWriteBehavior.FORBID : - networkStatus === NetworkStatus.refetch ? CacheWriteBehavior.OVERWRITE : + networkStatus === NetworkStatus.refresh ? CacheWriteBehavior.OVERWRITE : CacheWriteBehavior.MERGE; const resultsFromLink = () => diff --git a/src/core/networkStatus.ts b/src/core/networkStatus.ts index 08915e3a702..9c9c736e675 100644 --- a/src/core/networkStatus.ts +++ b/src/core/networkStatus.ts @@ -27,6 +27,18 @@ export enum NetworkStatus { */ refetch = 4, + /** + * Similar to NetworkStatus.refetch, but existing cache fields will be + * overwritten by the incoming network data, rather than merging. Useful + * for restarting a paginated field with fresh initial data. + * + * This enum value uses the string "refetch" rather than a number, + * because it was added after the other values, and we did not want to + * change the existing numbers. Any other values added in the future + * should also use strings rather than numbers. + */ + refresh = "refresh", + /** * Indicates that a polling query is currently in flight. So for example if you are polling a * query every 10 seconds then the network status will switch to `poll` every 10 seconds whenever @@ -52,5 +64,6 @@ export enum NetworkStatus { export function isNetworkRequestInFlight( networkStatus?: NetworkStatus, ): boolean { - return networkStatus ? networkStatus < 7 : false; + return networkStatus === "refresh" || + (typeof networkStatus === "number" && networkStatus < 7); } diff --git a/src/react/components/__tests__/client/__snapshots__/Query.test.tsx.snap b/src/react/components/__tests__/client/__snapshots__/Query.test.tsx.snap index 8e19a2575ff..1a05e8fa09f 100644 --- a/src/react/components/__tests__/client/__snapshots__/Query.test.tsx.snap +++ b/src/react/components/__tests__/client/__snapshots__/Query.test.tsx.snap @@ -28,6 +28,7 @@ Object { "networkStatus": 1, "previousData": undefined, "refetch": [Function], + "refresh": [Function], "startPolling": [Function], "stopPolling": [Function], "subscribeToMore": [Function], diff --git a/src/react/data/QueryData.ts b/src/react/data/QueryData.ts index c14d5b671f9..f16179123c4 100644 --- a/src/react/data/QueryData.ts +++ b/src/react/data/QueryData.ts @@ -495,6 +495,9 @@ export class QueryData extends OperationData< private obsRefetch = (variables?: Partial) => this.currentObservable?.refetch(variables); + private obsRefresh = (variables?: Partial) => + this.currentObservable?.refresh(variables); + private obsFetchMore = ( fetchMoreOptions: FetchMoreQueryOptions & FetchMoreOptions @@ -530,6 +533,7 @@ export class QueryData extends OperationData< return { variables: this.currentObservable?.variables, refetch: this.obsRefetch, + refresh: this.obsRefresh, fetchMore: this.obsFetchMore, updateQuery: this.obsUpdateQuery, startPolling: this.obsStartPolling, diff --git a/src/react/hoc/types.ts b/src/react/hoc/types.ts index dddfe8d6f1f..a9abe3e9455 100644 --- a/src/react/hoc/types.ts +++ b/src/react/hoc/types.ts @@ -1,4 +1,4 @@ -import { ApolloClient } from '../../core'; +import { ApolloClient, ObservableQuery } from '../../core'; import { ApolloError } from '../../errors'; import { ApolloQueryResult, @@ -28,6 +28,7 @@ export interface QueryControls< FetchMoreOptions ) => Promise>; refetch: (variables?: TGraphQLVariables) => Promise>; + refresh: ObservableQuery["refresh"]; startPolling: (pollInterval: number) => void; stopPolling: () => void; subscribeToMore: (options: SubscribeToMoreOptions) => () => void; diff --git a/src/react/types/types.ts b/src/react/types/types.ts index 62e84dcbe7b..b735e5afa25 100644 --- a/src/react/types/types.ts +++ b/src/react/types/types.ts @@ -61,6 +61,7 @@ export type ObservableQueryFields = Pick< | 'subscribeToMore' | 'updateQuery' | 'refetch' + | 'refresh' | 'variables' > & { fetchMore: (( From 59f6ff0e437a2dff60161a8fb4c7341d5a373bb9 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Wed, 24 Mar 2021 17:15:06 -0400 Subject: [PATCH 085/380] Make refetch replacement behavior opt-in without adding refresh. This reverts commit d87333bff032ec1fab703e2cb28134200d82e340 and introduces a new option for WatchQueryOptions called refetchPolicy. Because of the backwards compatibility concerns raised in #7810, the default setting for refetchPolicy needs to be "merge", though we hope developers will switch to refetchPolicy: "overwrite" using the explicit option once Apollo Client v3.4 is released, to achieve the behavior I described in my comment https://github.com/apollographql/apollo-client/issues/7491#issuecomment-772115227. We try to avoid introducing ad hoc new options, especially when we think one of the behaviors is preferable (overwriting, in this case). However, since not all observableQuery.refetch call sites are under the developer's control (for example, see refetchQueries after a mutation), it seemed important to have a way to _alter_ the behavior of refetch, rather than a new alternative method (refresh). If one of these behaviors ("merge" or "overwrite") becomes a clear favorite after Apollo Client v3.4 is released, we may either swap the default or remove the refetchPolicy option altogether in Apollo Client 4. --- src/core/ObservableQuery.ts | 18 ++---------- src/core/QueryManager.ts | 28 ++++++++++--------- src/core/networkStatus.ts | 15 +--------- src/core/watchQueryOptions.ts | 8 ++++++ .../client/__snapshots__/Query.test.tsx.snap | 1 - src/react/data/QueryData.ts | 4 --- src/react/hoc/types.ts | 3 +- src/react/types/types.ts | 1 - 8 files changed, 27 insertions(+), 51 deletions(-) diff --git a/src/core/ObservableQuery.ts b/src/core/ObservableQuery.ts index 5d9c285906f..76b31b12d51 100644 --- a/src/core/ObservableQuery.ts +++ b/src/core/ObservableQuery.ts @@ -224,16 +224,6 @@ export class ObservableQuery< * the previous values of those variables will be used. */ public refetch(variables?: Partial): Promise> { - return this.refresh(variables, NetworkStatus.refetch); - } - - // Forces a network query, like fetchMore and refetch, but overwrites - // existing cache fields with incoming data, rather than merging field - // values. Can be useful for restarting paginated fields with fresh data. - public refresh( - variables?: Partial, - networkStatus = NetworkStatus.refresh, - ): Promise> { const reobserveOptions: Partial> = { // Always disable polling for refetches. pollInterval: 0, @@ -260,13 +250,9 @@ export class ObservableQuery< this.queryInfo.resetLastWrite(); - return ( - networkStatus === NetworkStatus.refresh - ? this.getReobserver() - : this.newReobserver(false) - ).reobserve( + return this.newReobserver(false).reobserve( reobserveOptions, - networkStatus, + NetworkStatus.refetch, ); } diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index 57ed32579b3..0455f811dc1 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -889,7 +889,7 @@ export class QueryManager { options: WatchQueryOptions, // The initial networkStatus for this fetch, most often // NetworkStatus.loading, but also possibly fetchMore, poll, refetch, - // refresh, or setVariables. + // or setVariables. networkStatus = NetworkStatus.loading, ): Concast> { const query = this.transform(options.query).document; @@ -1019,22 +1019,20 @@ export class QueryManager { private fetchQueryByPolicy( queryInfo: QueryInfo, - options: WatchQueryOptions, - // The initial networkStatus for this fetch, most often - // NetworkStatus.loading, but also possibly fetchMore, poll, refetch, - // refresh, or setVariables. - networkStatus: NetworkStatus, - ): ConcastSourcesIterable> { - const { - query, + { query, variables, fetchPolicy, + refetchPolicy, errorPolicy, returnPartialData, context, notifyOnNetworkStatusChange, - } = options; - + }: WatchQueryOptions, + // The initial networkStatus for this fetch, most often + // NetworkStatus.loading, but also possibly fetchMore, poll, refetch, + // or setVariables. + networkStatus: NetworkStatus, + ): ConcastSourcesIterable> { const oldNetworkStatus = queryInfo.networkStatus; queryInfo.init({ @@ -1083,8 +1081,12 @@ export class QueryManager { const cacheWriteBehavior = fetchPolicy === "no-cache" ? CacheWriteBehavior.FORBID : - networkStatus === NetworkStatus.refresh ? CacheWriteBehavior.OVERWRITE : - CacheWriteBehavior.MERGE; + ( // Watched queries must opt into overwriting existing data on refetch, + // by passing refetchPolicy: "overwrite" in their WatchQueryOptions. + networkStatus === NetworkStatus.refetch && + refetchPolicy === "overwrite" + ) ? CacheWriteBehavior.OVERWRITE + : CacheWriteBehavior.MERGE; const resultsFromLink = () => this.getResultsFromLink(queryInfo, cacheWriteBehavior, { diff --git a/src/core/networkStatus.ts b/src/core/networkStatus.ts index 9c9c736e675..08915e3a702 100644 --- a/src/core/networkStatus.ts +++ b/src/core/networkStatus.ts @@ -27,18 +27,6 @@ export enum NetworkStatus { */ refetch = 4, - /** - * Similar to NetworkStatus.refetch, but existing cache fields will be - * overwritten by the incoming network data, rather than merging. Useful - * for restarting a paginated field with fresh initial data. - * - * This enum value uses the string "refetch" rather than a number, - * because it was added after the other values, and we did not want to - * change the existing numbers. Any other values added in the future - * should also use strings rather than numbers. - */ - refresh = "refresh", - /** * Indicates that a polling query is currently in flight. So for example if you are polling a * query every 10 seconds then the network status will switch to `poll` every 10 seconds whenever @@ -64,6 +52,5 @@ export enum NetworkStatus { export function isNetworkRequestInFlight( networkStatus?: NetworkStatus, ): boolean { - return networkStatus === "refresh" || - (typeof networkStatus === "number" && networkStatus < 7); + return networkStatus ? networkStatus < 7 : false; } diff --git a/src/core/watchQueryOptions.ts b/src/core/watchQueryOptions.ts index c10dfce557b..9507212f595 100644 --- a/src/core/watchQueryOptions.ts +++ b/src/core/watchQueryOptions.ts @@ -65,6 +65,14 @@ export interface QueryOptions { */ fetchPolicy?: FetchPolicy; + /** + * Specifies whether a {@link NetworkStatus.refetch} operation should merge + * incoming field data with existing data, or overwrite the existing data. + * Overwriting is probably preferable, but merging is currently the default + * behavior, for backwards compatibility with Apollo Client 3.x. + */ + refetchPolicy?: "merge" | "overwrite"; + /** * The time interval (in milliseconds) on which this query should be * refetched from the server. diff --git a/src/react/components/__tests__/client/__snapshots__/Query.test.tsx.snap b/src/react/components/__tests__/client/__snapshots__/Query.test.tsx.snap index 1a05e8fa09f..8e19a2575ff 100644 --- a/src/react/components/__tests__/client/__snapshots__/Query.test.tsx.snap +++ b/src/react/components/__tests__/client/__snapshots__/Query.test.tsx.snap @@ -28,7 +28,6 @@ Object { "networkStatus": 1, "previousData": undefined, "refetch": [Function], - "refresh": [Function], "startPolling": [Function], "stopPolling": [Function], "subscribeToMore": [Function], diff --git a/src/react/data/QueryData.ts b/src/react/data/QueryData.ts index f16179123c4..c14d5b671f9 100644 --- a/src/react/data/QueryData.ts +++ b/src/react/data/QueryData.ts @@ -495,9 +495,6 @@ export class QueryData extends OperationData< private obsRefetch = (variables?: Partial) => this.currentObservable?.refetch(variables); - private obsRefresh = (variables?: Partial) => - this.currentObservable?.refresh(variables); - private obsFetchMore = ( fetchMoreOptions: FetchMoreQueryOptions & FetchMoreOptions @@ -533,7 +530,6 @@ export class QueryData extends OperationData< return { variables: this.currentObservable?.variables, refetch: this.obsRefetch, - refresh: this.obsRefresh, fetchMore: this.obsFetchMore, updateQuery: this.obsUpdateQuery, startPolling: this.obsStartPolling, diff --git a/src/react/hoc/types.ts b/src/react/hoc/types.ts index a9abe3e9455..dddfe8d6f1f 100644 --- a/src/react/hoc/types.ts +++ b/src/react/hoc/types.ts @@ -1,4 +1,4 @@ -import { ApolloClient, ObservableQuery } from '../../core'; +import { ApolloClient } from '../../core'; import { ApolloError } from '../../errors'; import { ApolloQueryResult, @@ -28,7 +28,6 @@ export interface QueryControls< FetchMoreOptions ) => Promise>; refetch: (variables?: TGraphQLVariables) => Promise>; - refresh: ObservableQuery["refresh"]; startPolling: (pollInterval: number) => void; stopPolling: () => void; subscribeToMore: (options: SubscribeToMoreOptions) => () => void; diff --git a/src/react/types/types.ts b/src/react/types/types.ts index b735e5afa25..62e84dcbe7b 100644 --- a/src/react/types/types.ts +++ b/src/react/types/types.ts @@ -61,7 +61,6 @@ export type ObservableQueryFields = Pick< | 'subscribeToMore' | 'updateQuery' | 'refetch' - | 'refresh' | 'variables' > & { fetchMore: (( From 05834c52a7caeb37709bb18abb8d0fda549cdbd2 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Wed, 24 Mar 2021 18:03:05 -0400 Subject: [PATCH 086/380] Make BaseQueryOptions inherit from WatchQueryOptions. --- src/react/types/types.ts | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/src/react/types/types.ts b/src/react/types/types.ts index 62e84dcbe7b..cbe3d2750fb 100644 --- a/src/react/types/types.ts +++ b/src/react/types/types.ts @@ -4,7 +4,7 @@ import { TypedDocumentNode } from '@graphql-typed-document-node/core'; import { Observable } from '../../utilities'; import { FetchResult } from '../../link/core'; -import { ApolloClient } from '../../core'; +import { ApolloClient, WatchQueryOptions } from '../../core'; import { ApolloError } from '../../errors'; import { ApolloQueryResult, @@ -30,18 +30,11 @@ export type CommonOptions = TOptions & { /* Query types */ -export interface BaseQueryOptions { +export interface BaseQueryOptions +extends Omit, "query"> { ssr?: boolean; - variables?: TVariables; - fetchPolicy?: WatchQueryFetchPolicy; - nextFetchPolicy?: WatchQueryFetchPolicy; - errorPolicy?: ErrorPolicy; - pollInterval?: number; client?: ApolloClient; - notifyOnNetworkStatusChange?: boolean; context?: Context; - partialRefetch?: boolean; - returnPartialData?: boolean; } export interface QueryFunctionOptions< From dfc26095a99ae10cbccbec6d24dbcafd00bc71df Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Wed, 24 Mar 2021 18:07:00 -0400 Subject: [PATCH 087/380] Test options.refetchPolicy via React useQuery API. --- src/react/hooks/__tests__/useQuery.test.tsx | 366 +++++++++++++++++++- 1 file changed, 365 insertions(+), 1 deletion(-) diff --git a/src/react/hooks/__tests__/useQuery.test.tsx b/src/react/hooks/__tests__/useQuery.test.tsx index 642335f6ecb..8f8a4d83062 100644 --- a/src/react/hooks/__tests__/useQuery.test.tsx +++ b/src/react/hooks/__tests__/useQuery.test.tsx @@ -1,7 +1,7 @@ import React, { useState, useReducer, Fragment } from 'react'; import { DocumentNode, GraphQLError } from 'graphql'; import gql from 'graphql-tag'; -import { render, cleanup, wait } from '@testing-library/react'; +import { render, cleanup, wait, act } from '@testing-library/react'; import { ApolloClient, NetworkStatus, TypedDocumentNode, WatchQueryFetchPolicy } from '../../../core'; import { InMemoryCache } from '../../../cache'; @@ -1578,6 +1578,370 @@ describe('useQuery Hook', () => { }); }); + describe('options.refetchPolicy', () => { + const query = gql` + query GetPrimes ($min: number, $max: number) { + primes(min: $min, max: $max) + } + `; + + const mocks = [ + { + request: { + query, + variables: { min: 0, max: 12 }, + }, + result: { + data: { + primes: [2, 3, 5, 7, 11], + } + } + }, + { + request: { + query, + variables: { min: 12, max: 30 }, + }, + result: { + data: { + primes: [13, 17, 19, 23, 29], + } + } + }, + ]; + + itAsync('should support explicit "overwrite"', (resolve, reject) => { + const mergeParams: [any, any][] = []; + const cache = new InMemoryCache({ + typePolicies: { + Query: { + fields: { + primes: { + keyArgs: false, + merge(existing, incoming) { + mergeParams.push([existing, incoming]); + return existing ? [ + ...existing, + ...incoming, + ] : incoming; + }, + }, + }, + }, + }, + }); + + let renderCount = 0; + + function App() { + const { + loading, + networkStatus, + data, + error, + refetch, + } = useQuery(query, { + variables: { min: 0, max: 12 }, + notifyOnNetworkStatusChange: true, + // This is the key line in this test. + refetchPolicy: "overwrite", + }); + + switch (++renderCount) { + case 1: + expect(loading).toBeTruthy(); + expect(error).toBeUndefined(); + expect(data).toBeUndefined(); + expect(typeof refetch).toBe('function'); + break; + case 2: + expect(loading).toBe(false); + expect(error).toBeUndefined(); + expect(data).toEqual({ + primes: [2, 3, 5, 7, 11], + }); + expect(mergeParams).toEqual([ + [void 0, [2, 3, 5, 7, 11]], + ]); + act(() => { + refetch({ + min: 12, + max: 30, + }).then(result => { + expect(result).toEqual({ + loading: false, + networkStatus: NetworkStatus.ready, + data: { + primes: [13, 17, 19, 23, 29], + }, + }); + }); + }); + break; + case 3: + expect(loading).toBe(true); + expect(error).toBeUndefined(); + expect(data).toEqual({ + // We get the stale data because we configured keyArgs: false. + primes: [2, 3, 5, 7, 11], + }); + // This networkStatus is setVariables instead of refetch because + // we called refetch with new variables. + expect(networkStatus).toBe(NetworkStatus.setVariables); + break; + case 4: + expect(loading).toBe(false); + expect(error).toBeUndefined(); + expect(data).toEqual({ + primes: [13, 17, 19, 23, 29], + }); + expect(mergeParams).toEqual([ + [void 0, [2, 3, 5, 7, 11]], + // Without refetchPolicy: "overwrite", this array will be all 10 + // primes (2 through 29) together. + [void 0, [13, 17, 19, 23, 29]], + ]); + break; + default: + reject("too many renders"); + } + + return null; + } + + render( + + + + ); + + return wait(() => { + expect(renderCount).toBe(4); + }).then(resolve, reject); + }); + + itAsync('should support explicit "merge"', (resolve, reject) => { + const mergeParams: [any, any][] = []; + const cache = new InMemoryCache({ + typePolicies: { + Query: { + fields: { + primes: { + keyArgs: false, + merge(existing, incoming) { + mergeParams.push([existing, incoming]); + return existing ? [ + ...existing, + ...incoming, + ] : incoming; + }, + }, + }, + }, + }, + }); + + let renderCount = 0; + + function App() { + const { + loading, + networkStatus, + data, + error, + refetch, + } = useQuery(query, { + variables: { min: 0, max: 12 }, + notifyOnNetworkStatusChange: true, + // This is the key line in this test. + refetchPolicy: "merge", + }); + + switch (++renderCount) { + case 1: + expect(loading).toBeTruthy(); + expect(error).toBeUndefined(); + expect(data).toBeUndefined(); + expect(typeof refetch).toBe('function'); + break; + case 2: + expect(loading).toBe(false); + expect(error).toBeUndefined(); + expect(data).toEqual({ + primes: [2, 3, 5, 7, 11], + }); + expect(mergeParams).toEqual([ + [void 0, [2, 3, 5, 7, 11]], + ]); + act(() => { + refetch({ + min: 12, + max: 30, + }).then(result => { + expect(result).toEqual({ + loading: false, + networkStatus: NetworkStatus.ready, + data: { + primes: [2, 3, 5, 7, 11, 13, 17, 19, 23, 29], + }, + }); + }); + }); + break; + case 3: + expect(loading).toBe(true); + expect(error).toBeUndefined(); + expect(data).toEqual({ + // We get the stale data because we configured keyArgs: false. + primes: [2, 3, 5, 7, 11], + }); + // This networkStatus is setVariables instead of refetch because + // we called refetch with new variables. + expect(networkStatus).toBe(NetworkStatus.setVariables); + break; + case 4: + expect(loading).toBe(false); + expect(error).toBeUndefined(); + expect(data).toEqual({ + // Thanks to refetchPolicy: "merge". + primes: [2, 3, 5, 7, 11, 13, 17, 19, 23, 29], + }); + expect(mergeParams).toEqual([ + [void 0, [2, 3, 5, 7, 11]], + // This indicates concatenation happened. + [[2, 3, 5, 7, 11], [13, 17, 19, 23, 29]], + ]); + break; + default: + reject("too many renders"); + } + + return null; + } + + render( + + + + ); + + return wait(() => { + expect(renderCount).toBe(4); + }).then(resolve, reject); + }); + + // TODO The default refetchPolicy probably should change to "overwrite" + // when we release the next major version of Apollo Client (v4). + itAsync('should assume default refetchPolicy value is "merge"', (resolve, reject) => { + const mergeParams: [any, any][] = []; + const cache = new InMemoryCache({ + typePolicies: { + Query: { + fields: { + primes: { + keyArgs: false, + merge(existing, incoming) { + mergeParams.push([existing, incoming]); + return existing ? [ + ...existing, + ...incoming, + ] : incoming; + }, + }, + }, + }, + }, + }); + + let renderCount = 0; + + function App() { + const { + loading, + networkStatus, + data, + error, + refetch, + } = useQuery(query, { + variables: { min: 0, max: 12 }, + notifyOnNetworkStatusChange: true, + // Intentionally not passing refetchPolicy. + }); + + switch (++renderCount) { + case 1: + expect(loading).toBeTruthy(); + expect(error).toBeUndefined(); + expect(data).toBeUndefined(); + expect(typeof refetch).toBe('function'); + break; + case 2: + expect(loading).toBe(false); + expect(error).toBeUndefined(); + expect(data).toEqual({ + primes: [2, 3, 5, 7, 11], + }); + expect(mergeParams).toEqual([ + [void 0, [2, 3, 5, 7, 11]], + ]); + act(() => { + refetch({ + min: 12, + max: 30, + }).then(result => { + expect(result).toEqual({ + loading: false, + networkStatus: NetworkStatus.ready, + data: { + primes: [2, 3, 5, 7, 11, 13, 17, 19, 23, 29], + }, + }); + }); + }); + break; + case 3: + expect(loading).toBe(true); + expect(error).toBeUndefined(); + expect(data).toEqual({ + // We get the stale data because we configured keyArgs: false. + primes: [2, 3, 5, 7, 11], + }); + // This networkStatus is setVariables instead of refetch because + // we called refetch with new variables. + expect(networkStatus).toBe(NetworkStatus.setVariables); + break; + case 4: + expect(loading).toBe(false); + expect(error).toBeUndefined(); + expect(data).toEqual({ + // Thanks to refetchPolicy: "merge". + primes: [2, 3, 5, 7, 11, 13, 17, 19, 23, 29], + }); + expect(mergeParams).toEqual([ + [void 0, [2, 3, 5, 7, 11]], + // This indicates concatenation happened. + [[2, 3, 5, 7, 11], [13, 17, 19, 23, 29]], + ]); + break; + default: + reject("too many renders"); + } + + return null; + } + + render( + + + + ); + + return wait(() => { + expect(renderCount).toBe(4); + }).then(resolve, reject); + }); + }); + describe('Partial refetching', () => { itAsync( 'should attempt a refetch when the query result was marked as being ' + From c4943eec985b962bc01f005a6deb0ff185b6a8b3 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Wed, 24 Mar 2021 19:18:23 -0400 Subject: [PATCH 088/380] Move refetchPolicy from QueryOptions to WatchQueryOptions. --- src/core/watchQueryOptions.ts | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/core/watchQueryOptions.ts b/src/core/watchQueryOptions.ts index 9507212f595..495a641b6f7 100644 --- a/src/core/watchQueryOptions.ts +++ b/src/core/watchQueryOptions.ts @@ -65,14 +65,6 @@ export interface QueryOptions { */ fetchPolicy?: FetchPolicy; - /** - * Specifies whether a {@link NetworkStatus.refetch} operation should merge - * incoming field data with existing data, or overwrite the existing data. - * Overwriting is probably preferable, but merging is currently the default - * behavior, for backwards compatibility with Apollo Client 3.x. - */ - refetchPolicy?: "merge" | "overwrite"; - /** * The time interval (in milliseconds) on which this query should be * refetched from the server. @@ -114,6 +106,13 @@ export interface WatchQueryOptions this: WatchQueryOptions, lastFetchPolicy: WatchQueryFetchPolicy, ) => WatchQueryFetchPolicy); + /** + * Specifies whether a {@link NetworkStatus.refetch} operation should merge + * incoming field data with existing data, or overwrite the existing data. + * Overwriting is probably preferable, but merging is currently the default + * behavior, for backwards compatibility with Apollo Client 3.x. + */ + refetchPolicy?: "merge" | "overwrite"; } export interface FetchMoreQueryOptions { From e33250bfb0a569bcafbcc7d8630d62e653c878c5 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Wed, 24 Mar 2021 19:18:23 -0400 Subject: [PATCH 089/380] Rename refetchPolicy to refetchWritePolicy. I don't want the similarity of "refetchPolicy" and "fetchPolicy" to suggest a refetchPolicy is somehow a fetchPolicy for refetches. I think refetchWritePolicy better reflects purpose of this option, which is to configure how field data is written into the cache after a refetch. --- src/core/QueryManager.ts | 6 +++--- src/core/watchQueryOptions.ts | 4 +++- src/react/hooks/__tests__/useQuery.test.tsx | 20 ++++++++++---------- 3 files changed, 16 insertions(+), 14 deletions(-) diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index 0455f811dc1..27cbe61bbfc 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -1022,7 +1022,7 @@ export class QueryManager { { query, variables, fetchPolicy, - refetchPolicy, + refetchWritePolicy, errorPolicy, returnPartialData, context, @@ -1082,9 +1082,9 @@ export class QueryManager { const cacheWriteBehavior = fetchPolicy === "no-cache" ? CacheWriteBehavior.FORBID : ( // Watched queries must opt into overwriting existing data on refetch, - // by passing refetchPolicy: "overwrite" in their WatchQueryOptions. + // by passing refetchWritePolicy: "overwrite" in their WatchQueryOptions. networkStatus === NetworkStatus.refetch && - refetchPolicy === "overwrite" + refetchWritePolicy === "overwrite" ) ? CacheWriteBehavior.OVERWRITE : CacheWriteBehavior.MERGE; diff --git a/src/core/watchQueryOptions.ts b/src/core/watchQueryOptions.ts index 495a641b6f7..c00e77cc969 100644 --- a/src/core/watchQueryOptions.ts +++ b/src/core/watchQueryOptions.ts @@ -24,6 +24,8 @@ export type FetchPolicy = export type WatchQueryFetchPolicy = FetchPolicy | 'cache-and-network'; +export type RefetchWritePolicy = "merge" | "overwrite"; + /** * errorPolicy determines the level of events for errors in the execution result. The options are: * - none (default): any errors from the request are treated like runtime errors and the observable is stopped (XXX this is default to lower breaking changes going from AC 1.0 => 2.0) @@ -112,7 +114,7 @@ export interface WatchQueryOptions * Overwriting is probably preferable, but merging is currently the default * behavior, for backwards compatibility with Apollo Client 3.x. */ - refetchPolicy?: "merge" | "overwrite"; + refetchWritePolicy?: RefetchWritePolicy; } export interface FetchMoreQueryOptions { diff --git a/src/react/hooks/__tests__/useQuery.test.tsx b/src/react/hooks/__tests__/useQuery.test.tsx index 8f8a4d83062..276a4f1a711 100644 --- a/src/react/hooks/__tests__/useQuery.test.tsx +++ b/src/react/hooks/__tests__/useQuery.test.tsx @@ -1578,7 +1578,7 @@ describe('useQuery Hook', () => { }); }); - describe('options.refetchPolicy', () => { + describe('options.refetchWritePolicy', () => { const query = gql` query GetPrimes ($min: number, $max: number) { primes(min: $min, max: $max) @@ -1644,7 +1644,7 @@ describe('useQuery Hook', () => { variables: { min: 0, max: 12 }, notifyOnNetworkStatusChange: true, // This is the key line in this test. - refetchPolicy: "overwrite", + refetchWritePolicy: "overwrite", }); switch (++renderCount) { @@ -1697,8 +1697,8 @@ describe('useQuery Hook', () => { }); expect(mergeParams).toEqual([ [void 0, [2, 3, 5, 7, 11]], - // Without refetchPolicy: "overwrite", this array will be all 10 - // primes (2 through 29) together. + // Without refetchWritePolicy: "overwrite", this array will be + // all 10 primes (2 through 29) together. [void 0, [13, 17, 19, 23, 29]], ]); break; @@ -1754,7 +1754,7 @@ describe('useQuery Hook', () => { variables: { min: 0, max: 12 }, notifyOnNetworkStatusChange: true, // This is the key line in this test. - refetchPolicy: "merge", + refetchWritePolicy: "merge", }); switch (++renderCount) { @@ -1803,7 +1803,7 @@ describe('useQuery Hook', () => { expect(loading).toBe(false); expect(error).toBeUndefined(); expect(data).toEqual({ - // Thanks to refetchPolicy: "merge". + // Thanks to refetchWritePolicy: "merge". primes: [2, 3, 5, 7, 11, 13, 17, 19, 23, 29], }); expect(mergeParams).toEqual([ @@ -1830,9 +1830,9 @@ describe('useQuery Hook', () => { }).then(resolve, reject); }); - // TODO The default refetchPolicy probably should change to "overwrite" + // TODO The default refetchWritePolicy probably should change to "overwrite" // when we release the next major version of Apollo Client (v4). - itAsync('should assume default refetchPolicy value is "merge"', (resolve, reject) => { + itAsync('should assume default refetchWritePolicy value is "merge"', (resolve, reject) => { const mergeParams: [any, any][] = []; const cache = new InMemoryCache({ typePolicies: { @@ -1865,7 +1865,7 @@ describe('useQuery Hook', () => { } = useQuery(query, { variables: { min: 0, max: 12 }, notifyOnNetworkStatusChange: true, - // Intentionally not passing refetchPolicy. + // Intentionally not passing refetchWritePolicy. }); switch (++renderCount) { @@ -1914,7 +1914,7 @@ describe('useQuery Hook', () => { expect(loading).toBe(false); expect(error).toBeUndefined(); expect(data).toEqual({ - // Thanks to refetchPolicy: "merge". + // Thanks to refetchWritePolicy: "merge". primes: [2, 3, 5, 7, 11, 13, 17, 19, 23, 29], }); expect(mergeParams).toEqual([ From fdaa09f0c5d922ddf16f9e4373dbc57513518a49 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Thu, 25 Mar 2021 18:13:44 -0400 Subject: [PATCH 090/380] Explain PR #7810 in CHANGELOG.md. --- CHANGELOG.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 43b7f1ab6b8..16872c372fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,22 @@ TBD - `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.
[@benjamn](https://github.com/benjamn) in [#7819](https://github.com/apollographql/apollo-client/pull/7819) +- In Apollo Client 2.x, `refetch` would always replace existing data in the cache. With the introduction of field policy `merge` functions in Apollo Client 3, existing field values can be inappropriately combined with incoming field values by a custom `merge` function that does not realize a `refetch` has happened. + + To give you more control over this behavior, we have introduced an `overwrite?: boolean = false` option for `cache.writeQuery` and `cache.writeFragment`, and an option called `refetchWritePolicy?: "merge" | "overwrite"` for `client.watchQuery`, `useQuery`, and other functions that accept `WatchQueryOptions`. You can use these options to make sure any `merge` functions involved in cache writes for `refetch` operations get invoked with `undefined` as their first argument, which simulates the absence of any existing data, while still giving the `merge` function a chance to determine the internal representation of the incoming data. + + The default behaviors are `overwrite: false` and `refetchWritePolicy: "merge"`, but you can change the default `refetchWritePolicy` value using `defaultOptions.watchQuery`: + ```ts + new ApolloClient({ + defaultOptions: { + watchQuery: { + refetchWritePolicy: "overwrite", + }, + }, + }) + ``` + [@benjamn](https://github.com/benjamn) in [#7810](https://github.com/apollographql/apollo-client/pull/7810) + - Support `client.refetchQueries` as an imperative way to refetch queries, without having to pass `options.refetchQueries` to `client.mutate`.
[@dannycochran](https://github.com/dannycochran) in [#7431](https://github.com/apollographql/apollo-client/pull/7431) From c3d410f5562bb1eea491513d8bdd093aa66594f7 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Thu, 25 Mar 2021 18:20:52 -0400 Subject: [PATCH 091/380] Bump @apollo/client npm version to 3.4.0-beta.17. --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index bf756e85d16..4593eded9f3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-beta.16", + "version": "3.4.0-beta.17", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 42dd692029c..cd6640076c4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-beta.16", + "version": "3.4.0-beta.17", "description": "A fully-featured caching GraphQL client.", "private": true, "keywords": [ From e3eb80daab0b4b924d32537046c32caf596d5d12 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Fri, 28 Aug 2020 13:23:31 -0400 Subject: [PATCH 092/380] Support options.reobserveQuery callback for mutations. --- src/core/QueryManager.ts | 16 ++++++++++++++++ src/core/types.ts | 7 +++++++ src/core/watchQueryOptions.ts | 8 +++++++- src/react/types/types.ts | 6 +++++- 4 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index 27cbe61bbfc..4ab9f725691 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -35,6 +35,7 @@ import { NetworkStatus, isNetworkRequestInFlight } from './networkStatus'; import { ApolloQueryResult, OperationVariables, + ReobserveQueryCallback, } from './types'; import { LocalState } from './LocalState'; @@ -135,6 +136,7 @@ export class QueryManager { refetchQueries = [], awaitRefetchQueries = false, update: updateWithProxyFn, + reobserveQuery, errorPolicy = 'none', fetchPolicy, context = {}, @@ -218,6 +220,7 @@ export class QueryManager { errorPolicy, updateQueries, update: updateWithProxyFn, + reobserveQuery, }); } catch (e) { error = new ApolloError({ @@ -301,6 +304,7 @@ export class QueryManager { cache: ApolloCache, result: FetchResult, ) => void; + reobserveQuery?: ReobserveQueryCallback; }, cache = this.cache, ) { @@ -362,8 +366,20 @@ export class QueryManager { update(c, mutation.result); } }, + // Write the final mutation.result to the root layer of the cache. optimistic: false, + + onDirty(watch, diff) { + if (mutation.reobserveQuery) { + // TODO Get ObservableQuery from watch, somehow. + // TODO Call mutation.reobserveQuery with that ObservableQuery. + // TODO Store results in an array for the mutation to await. + + // Skip the normal broadcast of this result. + return false; + } + }, }); } } diff --git a/src/core/types.ts b/src/core/types.ts index b139672fc52..b90b84e35e0 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -5,11 +5,18 @@ import { ApolloError } from '../errors'; import { QueryInfo } from './QueryInfo'; import { NetworkStatus } from './networkStatus'; import { Resolver } from './LocalState'; +import { ObservableQuery } from './ObservableQuery'; +import { Cache } from '../cache'; export { TypedDocumentNode } from '@graphql-typed-document-node/core'; export type QueryListener = (queryInfo: QueryInfo) => void; +export type ReobserveQueryCallback = ( + observableQuery: ObservableQuery, + diff: Cache.DiffResult, +) => void | Promise; + export type OperationVariables = Record; export type PureQueryOptions = { diff --git a/src/core/watchQueryOptions.ts b/src/core/watchQueryOptions.ts index c00e77cc969..e6e2fd0efe9 100644 --- a/src/core/watchQueryOptions.ts +++ b/src/core/watchQueryOptions.ts @@ -3,7 +3,7 @@ import { TypedDocumentNode } from '@graphql-typed-document-node/core'; import { ApolloCache } from '../cache'; import { FetchResult } from '../link/core'; -import { MutationQueryReducersMap } from './types'; +import { MutationQueryReducersMap, ReobserveQueryCallback } from './types'; import { PureQueryOptions, OperationVariables } from './types'; /** @@ -241,6 +241,12 @@ export interface MutationBaseOptions< */ update?: MutationUpdaterFn; + /** + * A function that will be called for each ObservableQuery affected by + * this mutation, after the mutation has completed. + */ + reobserveQuery?: ReobserveQueryCallback; + /** * Specifies the {@link ErrorPolicy} to be used for this operation */ diff --git a/src/react/types/types.ts b/src/react/types/types.ts index cbe3d2750fb..c91f837f342 100644 --- a/src/react/types/types.ts +++ b/src/react/types/types.ts @@ -4,9 +4,9 @@ import { TypedDocumentNode } from '@graphql-typed-document-node/core'; import { Observable } from '../../utilities'; import { FetchResult } from '../../link/core'; -import { ApolloClient, WatchQueryOptions } from '../../core'; import { ApolloError } from '../../errors'; import { + ApolloClient, ApolloQueryResult, ErrorPolicy, FetchMoreOptions, @@ -17,7 +17,9 @@ import { ObservableQuery, OperationVariables, PureQueryOptions, + ReobserveQueryCallback, WatchQueryFetchPolicy, + WatchQueryOptions, } from '../../core'; /* Common types */ @@ -148,6 +150,7 @@ export interface BaseMutationOptions< awaitRefetchQueries?: boolean; errorPolicy?: ErrorPolicy; update?: MutationUpdaterFn; + reobserveQuery?: ReobserveQueryCallback; client?: ApolloClient; notifyOnNetworkStatusChange?: boolean; context?: Context; @@ -166,6 +169,7 @@ export interface MutationFunctionOptions< refetchQueries?: Array | RefetchQueriesFunction; awaitRefetchQueries?: boolean; update?: MutationUpdaterFn; + reobserveQuery?: ReobserveQueryCallback; context?: Context; fetchPolicy?: WatchQueryFetchPolicy; } From ead7e413385f0afceb3de23d7c9805644421fcf0 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Thu, 11 Mar 2021 11:31:39 -0500 Subject: [PATCH 093/380] Use watch.watcher to obtain ObservableQuery in markMutationResult. --- src/cache/core/types/Cache.ts | 5 ++++- src/core/QueryInfo.ts | 3 ++- src/core/QueryManager.ts | 20 +++++++++++--------- 3 files changed, 17 insertions(+), 11 deletions(-) diff --git a/src/cache/core/types/Cache.ts b/src/cache/core/types/Cache.ts index deca58cd166..4eacf9e22f3 100644 --- a/src/cache/core/types/Cache.ts +++ b/src/cache/core/types/Cache.ts @@ -26,7 +26,10 @@ export namespace Cache { // declaring the returnPartialData option. } - export interface WatchOptions extends ReadOptions { + export interface WatchOptions< + Watcher extends object = Record + > extends ReadOptions { + watcher?: Watcher; immediate?: boolean; callback: WatchCallback; lastDiff?: DiffResult; diff --git a/src/core/QueryInfo.ts b/src/core/QueryInfo.ts index 671a0261b18..5b77231d34a 100644 --- a/src/core/QueryInfo.ts +++ b/src/core/QueryInfo.ts @@ -261,7 +261,7 @@ export class QueryInfo { // updateWatch method. private cancel() {} - private lastWatch?: Cache.WatchOptions; + private lastWatch?: Cache.WatchOptions; private updateWatch(variables = this.variables) { const oq = this.observableQuery; @@ -276,6 +276,7 @@ export class QueryInfo { query: this.document!, variables, optimistic: true, + watcher: this, callback: diff => this.setDiff(diff), }); } diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index 4ab9f725691..fd1cbfffe5e 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -355,6 +355,8 @@ export class QueryManager { }); } + const reobserveResults = []; + cache.batch({ transaction(c) { cacheWrites.forEach(write => c.write(write)); @@ -370,16 +372,16 @@ export class QueryManager { // Write the final mutation.result to the root layer of the cache. optimistic: false, - onDirty(watch, diff) { - if (mutation.reobserveQuery) { - // TODO Get ObservableQuery from watch, somehow. - // TODO Call mutation.reobserveQuery with that ObservableQuery. - // TODO Store results in an array for the mutation to await. - - // Skip the normal broadcast of this result. - return false; + onDirty: mutation.reobserveQuery && ((watch, diff) => { + if (watch.watcher instanceof QueryInfo) { + const oq = watch.watcher.observableQuery; + if (oq) { + reobserveResults.push(mutation.reobserveQuery!(oq, diff)); + // Prevent the normal cache broadcast of this result. + return false; + } } - }, + }), }); } } From eec6c476ad8dc9131fcc8571fec92aa0197d823f Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 8 Sep 2020 15:47:36 -0400 Subject: [PATCH 094/380] Return Promise from markMutationResult for awaiting affected queries. --- src/core/QueryManager.ts | 65 +++++++++++++++++++++------------------- 1 file changed, 34 insertions(+), 31 deletions(-) diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index fd1cbfffe5e..2924c46584b 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -186,23 +186,23 @@ export class QueryManager { return new Promise((resolve, reject) => { let storeResult: FetchResult | null; - let error: ApolloError; - self.getObservableFromLink( - mutation, - { - ...context, - optimisticResponse, - }, - variables, - false, - ).subscribe({ - next(result: FetchResult) { + return asyncMap( + self.getObservableFromLink( + mutation, + { + ...context, + optimisticResponse, + }, + variables, + false, + ), + + (result: FetchResult) => { if (graphQLResultHasError(result) && errorPolicy === 'none') { - error = new ApolloError({ + throw new ApolloError({ graphQLErrors: result.errors, }); - return; } if (mutationStoreValue) { @@ -210,9 +210,15 @@ export class QueryManager { mutationStoreValue.error = null; } + storeResult = result; + if (fetchPolicy !== 'no-cache') { try { - self.markMutationResult({ + // Returning the result of markMutationResult here makes the + // mutation await any Promise that markMutationResult returns, + // since we are returning this Promise from the asyncMap mapping + // function. + return self.markMutationResult({ mutationId, result, document: mutation, @@ -223,49 +229,42 @@ export class QueryManager { reobserveQuery, }); } catch (e) { - error = new ApolloError({ + // Likewise, throwing an error from the asyncMap mapping function + // will result in calling the subscribed error handler function. + throw new ApolloError({ networkError: e, }); - return; } } - - storeResult = result; }, + ).subscribe({ error(err: Error) { if (mutationStoreValue) { mutationStoreValue.loading = false; mutationStoreValue.error = err; } + if (optimisticResponse) { self.cache.removeOptimistic(mutationId); } + self.broadcastQueries(); + reject( - new ApolloError({ + err instanceof ApolloError ? err : new ApolloError({ networkError: err, }), ); }, complete() { - if (error && mutationStoreValue) { - mutationStoreValue.loading = false; - mutationStoreValue.error = error; - } - if (optimisticResponse) { self.cache.removeOptimistic(mutationId); } self.broadcastQueries(); - if (error) { - reject(error); - return; - } - // allow for conditional refetches // XXX do we want to make this the only API one day? if (typeof refetchQueries === 'function') { @@ -307,7 +306,7 @@ export class QueryManager { reobserveQuery?: ReobserveQueryCallback; }, cache = this.cache, - ) { + ): Promise { if (shouldWriteResult(mutation.result, mutation.errorPolicy)) { const cacheWrites: Cache.WriteOptions[] = [{ result: mutation.result.data, @@ -355,7 +354,7 @@ export class QueryManager { }); } - const reobserveResults = []; + const reobserveResults: any[] = []; cache.batch({ transaction(c) { @@ -383,7 +382,11 @@ export class QueryManager { } }), }); + + return Promise.all(reobserveResults).then(() => void 0); } + + return Promise.resolve(); } public markMutationOptimistic( From 504cc577b56b3ad068abe1aa78f10e35a5a2b06b Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 8 Sep 2020 14:54:30 -0400 Subject: [PATCH 095/380] Adapt refetchQueries tests for options.reobserveQuery. --- src/core/__tests__/QueryManager/index.ts | 201 +++++++++++++++++++++++ 1 file changed, 201 insertions(+) diff --git a/src/core/__tests__/QueryManager/index.ts b/src/core/__tests__/QueryManager/index.ts index 232ea8a167c..4e0c2ba6a76 100644 --- a/src/core/__tests__/QueryManager/index.ts +++ b/src/core/__tests__/QueryManager/index.ts @@ -5168,6 +5168,207 @@ describe('QueryManager', () => { }); }); + describe('reobserveQuery', () => { + const mutation = gql` + mutation changeAuthorName { + changeAuthorName(newName: "Jack Smith") { + firstName + lastName + } + } + `; + + const mutationData = { + changeAuthorName: { + firstName: 'Jack', + lastName: 'Smith', + }, + }; + + const query = gql` + query getAuthors($id: ID!) { + author(id: $id) { + firstName + lastName + } + } + `; + + const data = { + author: { + firstName: 'John', + lastName: 'Smith', + }, + }; + + const secondReqData = { + author: { + firstName: 'Jane', + lastName: 'Johnson', + }, + }; + + const variables = { id: '1234' }; + + function makeQueryManager(reject: (reason?: any) => void) { + return mockQueryManager( + reject, + { + request: { query, variables }, + result: { data }, + }, + { + request: { query, variables }, + result: { data: secondReqData }, + }, + { + request: { query: mutation }, + result: { data: mutationData }, + }, + ); + } + + itAsync('should refetch the right query when a result is successfully returned', (resolve, reject) => { + const queryManager = makeQueryManager(reject); + + const observable = queryManager.watchQuery({ + query, + variables, + notifyOnNetworkStatusChange: false, + }); + + return observableToPromise( + { observable }, + result => { + expect(stripSymbols(result.data)).toEqual(data); + + queryManager.mutate({ + mutation, + + update(cache) { + cache.modify({ + fields: { + author(_, { INVALIDATE }) { + return INVALIDATE; + }, + }, + }); + }, + + reobserveQuery(obsQuery) { + expect(obsQuery.options.query).toBe(query); + return obsQuery.refetch(); + }, + }); + }, + + result => { + expect(stripSymbols(observable.getCurrentResult().data)).toEqual( + secondReqData, + ); + expect(stripSymbols(result.data)).toEqual(secondReqData); + }, + ).then(resolve, reject); + }); + + itAsync('should refetch using the original query context (if any)', (resolve, reject) => { + const queryManager = makeQueryManager(reject); + + const headers = { + someHeader: 'some value', + }; + + const observable = queryManager.watchQuery({ + query, + variables, + context: { + headers, + }, + notifyOnNetworkStatusChange: false, + }); + + return observableToPromise( + { observable }, + result => { + expect(result.data).toEqual(data); + + queryManager.mutate({ + mutation, + + update(cache) { + cache.modify({ + fields: { + author(_, { INVALIDATE }) { + return INVALIDATE; + }, + }, + }); + }, + + reobserveQuery(obsQuery) { + expect(obsQuery.options.query).toBe(query); + return obsQuery.refetch(); + }, + }); + }, + + result => { + expect(result.data).toEqual(secondReqData); + const context = (queryManager.link as MockApolloLink).operation!.getContext(); + expect(context.headers).not.toBeUndefined(); + expect(context.headers.someHeader).toEqual(headers.someHeader); + }, + ).then(resolve, reject); + }); + + itAsync('should refetch using the specified context, if provided', (resolve, reject) => { + const queryManager = makeQueryManager(reject); + + const observable = queryManager.watchQuery({ + query, + variables, + notifyOnNetworkStatusChange: false, + }); + + const headers = { + someHeader: 'some value', + }; + + return observableToPromise( + { observable }, + result => { + expect(result.data).toEqual(data); + + queryManager.mutate({ + mutation, + + update(cache) { + cache.evict({ fieldName: "author" }); + }, + + reobserveQuery(obsQuery) { + expect(obsQuery.options.query).toBe(query); + return obsQuery.reobserve({ + fetchPolicy: "network-only", + context: { + ...obsQuery.options.context, + headers, + }, + }); + }, + }); + }, + + result => { + expect(result.data).toEqual(secondReqData); + const context = (queryManager.link as MockApolloLink).operation!.getContext(); + expect(context.headers).not.toBeUndefined(); + expect(context.headers.someHeader).toEqual(headers.someHeader); + }, + ).then(resolve, reject); + }); + }); + describe('awaitRefetchQueries', () => { const awaitRefetchTest = ({ awaitRefetchQueries, testQueryError = false }: MutationBaseOptions & { testQueryError?: boolean }) => From ddf6297ccf043cd070e715cc316285348d4ab5a3 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 8 Sep 2020 16:20:19 -0400 Subject: [PATCH 096/380] Test that mutations await promises returned by reobserveQuery. --- src/core/__tests__/QueryManager/index.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/core/__tests__/QueryManager/index.ts b/src/core/__tests__/QueryManager/index.ts index 4e0c2ba6a76..a0fddec6f31 100644 --- a/src/core/__tests__/QueryManager/index.ts +++ b/src/core/__tests__/QueryManager/index.ts @@ -5237,12 +5237,14 @@ describe('QueryManager', () => { notifyOnNetworkStatusChange: false, }); + let finishedRefetch = false; + return observableToPromise( { observable }, result => { expect(stripSymbols(result.data)).toEqual(data); - queryManager.mutate({ + return queryManager.mutate({ mutation, update(cache) { @@ -5257,8 +5259,15 @@ describe('QueryManager', () => { reobserveQuery(obsQuery) { expect(obsQuery.options.query).toBe(query); - return obsQuery.refetch(); + return obsQuery.refetch().then(async () => { + // Wait a bit to make sure the mutation really awaited the + // refetching of the query. + await new Promise(resolve => setTimeout(resolve, 100)); + finishedRefetch = true; + }); }, + }).then(() => { + expect(finishedRefetch).toBe(true); }); }, @@ -5267,6 +5276,7 @@ describe('QueryManager', () => { secondReqData, ); expect(stripSymbols(result.data)).toEqual(secondReqData); + expect(finishedRefetch).toBe(true); }, ).then(resolve, reject); }); From bafda5909e5f76582f4bdefaa4bcaa3011dd2bb5 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Thu, 11 Mar 2021 18:00:11 -0500 Subject: [PATCH 097/380] Add tests of reobserveQuery with useMutation. --- .../hooks/__tests__/useMutation.test.tsx | 171 +++++++++++++++++- 1 file changed, 170 insertions(+), 1 deletion(-) diff --git a/src/react/hooks/__tests__/useMutation.test.tsx b/src/react/hooks/__tests__/useMutation.test.tsx index de541810bb7..ed0d4893baa 100644 --- a/src/react/hooks/__tests__/useMutation.test.tsx +++ b/src/react/hooks/__tests__/useMutation.test.tsx @@ -3,11 +3,13 @@ import { DocumentNode, GraphQLError } from 'graphql'; import gql from 'graphql-tag'; import { render, cleanup, wait } from '@testing-library/react'; -import { ApolloClient } from '../../../core'; +import { ApolloClient, ApolloQueryResult, Cache, NetworkStatus, ObservableQuery, TypedDocumentNode } from '../../../core'; import { InMemoryCache } from '../../../cache'; import { itAsync, MockedProvider, mockSingleLink } from '../../../testing'; import { ApolloProvider } from '../../context'; +import { useQuery } from '../useQuery'; import { useMutation } from '../useMutation'; +import { act } from 'react-dom/test-utils'; describe('useMutation Hook', () => { interface Todo { @@ -503,4 +505,171 @@ describe('useMutation Hook', () => { }).then(resolve, reject); }); }); + + describe('refetching queries', () => { + itAsync('can pass reobserveQuery to useMutation', (resolve, reject) => { + interface TData { + todoCount: number; + } + const countQuery: TypedDocumentNode = gql` + query Count { todoCount @client } + `; + + const optimisticResponse = { + __typename: 'Mutation', + createTodo: { + id: 1, + description: 'TEMPORARY', + priority: 'High', + __typename: 'Todo' + } + }; + + const variables = { + description: 'Get milk!' + }; + + const client = new ApolloClient({ + cache: new InMemoryCache({ + typePolicies: { + Query: { + fields: { + todoCount(count = 0) { + return count; + }, + }, + }, + }, + }), + + link: mockSingleLink({ + request: { + query: CREATE_TODO_MUTATION, + variables, + }, + result: { data: CREATE_TODO_RESULT }, + }).setOnError(reject), + }); + + // The goal of this test is to make sure reobserveQuery gets called as + // part of the createTodo mutation, so we use this reobservePromise to + // await the calling of reobserveQuery. + interface ReobserveResults { + obsQuery: ObservableQuery; + diff: Cache.DiffResult; + result: ApolloQueryResult; + } + let reobserveResolve: (results: ReobserveResults) => any; + const reobservePromise = new Promise(resolve => { + reobserveResolve = resolve; + }); + let finishedReobserving = false; + + let renderCount = 0; + function Component() { + const count = useQuery(countQuery); + + const [createTodo, { loading, data }] = + useMutation(CREATE_TODO_MUTATION, { + optimisticResponse, + + update(cache, mutationResult) { + const result = cache.readQuery({ + query: countQuery, + }); + + cache.writeQuery({ + query: countQuery, + data: { + todoCount: (result ? result.todoCount : 0) + 1, + }, + }); + }, + }); + + switch (++renderCount) { + case 1: + expect(count.loading).toBe(false); + expect(count.data).toEqual({ todoCount: 0 }); + + expect(loading).toBeFalsy(); + expect(data).toBeUndefined(); + + act(() => { + createTodo({ + variables, + reobserveQuery(obsQuery, diff) { + return obsQuery.reobserve().then(result => { + finishedReobserving = true; + reobserveResolve({ obsQuery, diff, result }); + }); + }, + }); + }); + + break; + case 2: + expect(count.loading).toBe(false); + expect(count.data).toEqual({ todoCount: 0 }); + + expect(loading).toBeTruthy(); + expect(data).toBeUndefined(); + + expect(finishedReobserving).toBe(false); + break; + case 3: + expect(count.loading).toBe(false); + expect(count.data).toEqual({ todoCount: 1 }); + + expect(loading).toBe(true); + expect(data).toBeUndefined(); + + expect(finishedReobserving).toBe(false); + break; + case 4: + expect(count.loading).toBe(false); + expect(count.data).toEqual({ todoCount: 1 }); + + expect(loading).toBe(false); + expect(data).toEqual(CREATE_TODO_RESULT); + + expect(finishedReobserving).toBe(true); + break; + default: + reject("too many renders"); + } + + return null; + } + + render( + + + + ); + + return reobservePromise.then(results => { + expect(finishedReobserving).toBe(true); + + expect(results.diff).toEqual({ + complete: true, + result: { + todoCount: 1, + }, + }); + + expect(results.result).toEqual({ + loading: false, + networkStatus: NetworkStatus.ready, + data: { + todoCount: 1, + }, + }); + + return wait(() => { + expect(renderCount).toBe(4); + }).then(resolve, reject); + }); + }); + }); }); From ac87e61d63e32082d36a11295f232eb2e6e414a2 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Thu, 25 Mar 2021 18:26:06 -0400 Subject: [PATCH 098/380] Bump bundlesize limit from 26.4kB to 26.5kB. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index cd6640076c4..1610e66fe0c 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,7 @@ { "name": "apollo-client", "path": "./dist/apollo-client.cjs.min.js", - "maxSize": "26.4 kB" + "maxSize": "26.5 kB" } ], "peerDependencies": { From 892375c9dc50530d6878a12c5d2e2042da107551 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Fri, 26 Mar 2021 16:37:49 -0400 Subject: [PATCH 099/380] Mention PR #7827 in CHANGELOG.md. --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 16872c372fc..06c78997dab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,9 @@ TBD ``` [@benjamn](https://github.com/benjamn) in [#7810](https://github.com/apollographql/apollo-client/pull/7810) +- Mutations now accept an optional callback function called `reobserveQuery`, which will be passed the `ObservableQuery` and `Cache.DiffResult` objects for any queries invalidated by cache writes performed by the mutation's final `update` function. Using `reobserveQuery`, you can override the default `FetchPolicy` of the query, by (for example) calling `ObservableQuery` methods like `refetch` to force a network request. This automatic detection of invalidated queries provides an alternative to manually enumerating queries using the `refetchQueries` mutation option. Also, if you return a `Promise` from `reobserveQuery`, the mutation will automatically await that `Promise`, rendering the `awaitRefetchQueries` option unnecessary.
+ [@benjamn](https://github.com/benjamn) in [#7827](https://github.com/apollographql/apollo-client/pull/7827) + - Support `client.refetchQueries` as an imperative way to refetch queries, without having to pass `options.refetchQueries` to `client.mutate`.
[@dannycochran](https://github.com/dannycochran) in [#7431](https://github.com/apollographql/apollo-client/pull/7431) From 77a556ad966b500d9543408a57ff7726814515df Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Fri, 26 Mar 2021 16:59:16 -0400 Subject: [PATCH 100/380] Bump @apollo/client npm version to 3.4.0-beta.18. --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4593eded9f3..d115baad7a2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-beta.17", + "version": "3.4.0-beta.18", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 1610e66fe0c..f1d8348db7e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-beta.17", + "version": "3.4.0-beta.18", "description": "A fully-featured caching GraphQL client.", "private": true, "keywords": [ From a7837e98697cc879768a08a0b5f026d99af58213 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Fri, 26 Mar 2021 18:59:09 -0400 Subject: [PATCH 101/380] Broadcast watches before transaction when onDirty provided. (#7904) When an `options.onDirty` callback is provided to `cache.batch` (#7819), we want to call `onDirty` with only the `Cache.WatchOptions` objects that were directly affected by `options.transaction`, so it's important to broadcast watches _before_ the transaction, to flush out any pending watches waiting to be broadcast. --- src/cache/inmemory/__tests__/cache.ts | 106 ++++++++++++++++++++++++++ src/cache/inmemory/inMemoryCache.ts | 10 +++ 2 files changed, 116 insertions(+) diff --git a/src/cache/inmemory/__tests__/cache.ts b/src/cache/inmemory/__tests__/cache.ts index 6545a68dba1..c57ea9fc2c3 100644 --- a/src/cache/inmemory/__tests__/cache.ts +++ b/src/cache/inmemory/__tests__/cache.ts @@ -1502,6 +1502,112 @@ describe('Cache', () => { abInfo.cancel(); bInfo.cancel(); }); + + it('does not pass previously invalidated queries to onDirty', () => { + 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); + + cache.writeQuery({ + query: bQuery, + // Writing this data with broadcast:false queues this update for the + // next broadcast, whenever it happens. If that next broadcast is the + // one triggered by cache.batch, the bQuery broadcast could be + // accidentally intercepted by onDirty, even though the transaction + // does not touch the Query.b field. To solve this problem, the batch + // method calls cache.broadcastWatches() before the transaction, when + // options.onDirty is provided. + broadcast: false, + data: { + b: "beeeee", + }, + }); + + const dirtied = new Map>(); + + cache.batch({ + transaction(cache) { + cache.modify({ + fields: { + a(value) { + expect(value).toBe("ay"); + return "ayyyy"; + }, + }, + }); + }, + optimistic: true, + onDirty(watch, diff) { + dirtied.set(watch, 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).toEqual([ + // This diff resulted from the cache.modify call in the cache.batch + // transaction function. + { + complete: true, + result: { + a: "ayyyy", + }, + } + ]); + + expect(abInfo.diffs).toEqual([ + // This diff came from the broadcast of cache.writeQuery data before + // the cache.batch transaction, before any onDirty calls. + { + complete: true, + result: { + a: "ay", + b: "beeeee", + }, + }, + // This diff resulted from the cache.modify call in the cache.batch + // transaction function. + { + complete: true, + result: { + a: "ayyyy", + b: "beeeee", + }, + }, + ]); + + expect(bInfo.diffs).toEqual([ + // This diff came from the broadcast of cache.writeQuery data before + // the cache.batch transaction, before any onDirty calls. + { + complete: true, + result: { + b: "beeeee", + }, + }, + ]); + + aInfo.cancel(); + abInfo.cancel(); + bInfo.cancel(); + }); }); describe('performTransaction', () => { diff --git a/src/cache/inmemory/inMemoryCache.ts b/src/cache/inmemory/inMemoryCache.ts index e06fed3d4aa..b7c74066128 100644 --- a/src/cache/inmemory/inMemoryCache.ts +++ b/src/cache/inmemory/inMemoryCache.ts @@ -322,6 +322,16 @@ export class InMemoryCache extends ApolloCache { } }; + if (options.onDirty) { + // If an options.onDirty callback is provided, we want to call it with + // only the Cache.WatchOptions objects affected by options.transaction, + // so we broadcast watches first, to clear any pending watches waiting + // to be broadcast. + const { onDirty, ...rest } = options; + // Note that rest is just like options, except with onDirty removed. + this.broadcastWatches(rest); + } + if (typeof optimistic === 'string') { // Note that there can be multiple layers with the same optimistic ID. // When removeOptimistic(id) is called for that id, all matching layers From bde6e5e139b825e3bd21a42b5ff2e80b6970cef3 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Fri, 26 Mar 2021 19:10:44 -0400 Subject: [PATCH 102/380] Bump @apollo/client npm version to 3.4.0-beta.19. --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index d115baad7a2..2c128538829 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-beta.18", + "version": "3.4.0-beta.19", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index f1d8348db7e..8940e924818 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-beta.18", + "version": "3.4.0-beta.19", "description": "A fully-featured caching GraphQL client.", "private": true, "keywords": [ From 33e2f198f348565b354dd68fe54def439e593f4c Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Mon, 5 Apr 2021 11:01:05 -0400 Subject: [PATCH 103/380] Handle already-dirty watches more gracefully in cache.batch (#7926) --- package.json | 2 +- src/cache/inmemory/__tests__/cache.ts | 41 ++++++++++++++++---- src/cache/inmemory/inMemoryCache.ts | 55 ++++++++++++++++++++++----- 3 files changed, 81 insertions(+), 17 deletions(-) diff --git a/package.json b/package.json index 8940e924818..335bb79df02 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,7 @@ { "name": "apollo-client", "path": "./dist/apollo-client.cjs.min.js", - "maxSize": "26.5 kB" + "maxSize": "26.55 kB" } ], "peerDependencies": { diff --git a/src/cache/inmemory/__tests__/cache.ts b/src/cache/inmemory/__tests__/cache.ts index c57ea9fc2c3..a0e00d24b6f 100644 --- a/src/cache/inmemory/__tests__/cache.ts +++ b/src/cache/inmemory/__tests__/cache.ts @@ -1537,6 +1537,11 @@ describe('Cache', () => { }, }); + // No diffs reported so far, thanks to broadcast: false. + expect(aInfo.diffs).toEqual([]); + expect(abInfo.diffs).toEqual([]); + expect(bInfo.diffs).toEqual([]); + const dirtied = new Map>(); cache.batch({ @@ -1573,17 +1578,38 @@ describe('Cache', () => { ]); expect(abInfo.diffs).toEqual([ - // This diff came from the broadcast of cache.writeQuery data before - // the cache.batch transaction, before any onDirty calls. + // This diff resulted from the cache.modify call in the cache.batch + // transaction function. { complete: true, result: { - a: "ay", + a: "ayyyy", b: "beeeee", }, }, - // This diff resulted from the cache.modify call in the cache.batch - // transaction function. + ]); + + // No diffs so far for bQuery. + expect(bInfo.diffs).toEqual([]); + + // Trigger broadcast of watchers that were dirty before the cache.batch + // transaction. + cache["broadcastWatches"](); + + expect(aInfo.diffs).toEqual([ + // Same array of diffs as before. + { + complete: true, + result: { + a: "ayyyy", + }, + } + ]); + + expect(abInfo.diffs).toEqual([ + // The abQuery watcher was dirty before the cache.batch transaction, + // but it got picked up in the post-transaction broadcast, which is why + // we do not see another (duplicate) diff here. { complete: true, result: { @@ -1594,8 +1620,9 @@ describe('Cache', () => { ]); expect(bInfo.diffs).toEqual([ - // This diff came from the broadcast of cache.writeQuery data before - // the cache.batch transaction, before any onDirty calls. + // This diff is caused by the data written by cache.writeQuery before + // the cache.batch transaction, but gets broadcast only after the batch + // transaction, by cache["broadcastWatches"]() above. { complete: true, result: { diff --git a/src/cache/inmemory/inMemoryCache.ts b/src/cache/inmemory/inMemoryCache.ts index b7c74066128..5c2b2a9e2dd 100644 --- a/src/cache/inmemory/inMemoryCache.ts +++ b/src/cache/inmemory/inMemoryCache.ts @@ -322,14 +322,26 @@ export class InMemoryCache extends ApolloCache { } }; - if (options.onDirty) { + const { onDirty } = options; + const alreadyDirty = new Set(); + + if (onDirty && !this.txCount) { // If an options.onDirty callback is provided, we want to call it with // only the Cache.WatchOptions objects affected by options.transaction, - // so we broadcast watches first, to clear any pending watches waiting - // to be broadcast. - const { onDirty, ...rest } = options; - // Note that rest is just like options, except with onDirty removed. - this.broadcastWatches(rest); + // but there might be dirty watchers already waiting to be broadcast that + // have nothing to do with the transaction. To prevent including those + // watchers in the post-transaction broadcast, we perform this initial + // broadcast to collect the dirty watchers, so we can re-dirty them later, + // after the post-transaction broadcast, allowing them to receive their + // pending broadcasts the next time broadcastWatches is called, just as + // they would if we never called cache.batch. + this.broadcastWatches({ + ...options, + onDirty(watch) { + alreadyDirty.add(watch); + return false; + }, + }); } if (typeof optimistic === 'string') { @@ -350,8 +362,33 @@ export class InMemoryCache extends ApolloCache { perform(); } - // This broadcast does nothing if this.txCount > 0. - this.broadcastWatches(options); + // Note: if this.txCount > 0, then alreadyDirty.size === 0, so this code + // takes the else branch and calls this.broadcastWatches(options), which + // does nothing when this.txCount > 0. + if (onDirty && alreadyDirty.size) { + this.broadcastWatches({ + ...options, + onDirty(watch, diff) { + const onDirtyResult = onDirty.call(this, watch, diff); + if (onDirtyResult !== false) { + // Since onDirty did not return false, this diff is about to be + // broadcast to watch.callback, so we don't need to re-dirty it + // with the other alreadyDirty watches below. + alreadyDirty.delete(watch); + } + return onDirtyResult; + } + }); + // Silently re-dirty any watches that were already dirty before the + // transaction was performed, and were not broadcast just now. + if (alreadyDirty.size) { + alreadyDirty.forEach(watch => this.maybeBroadcastWatch.dirty(watch)); + } + } else { + // If alreadyDirty is empty or we don't have an options.onDirty function, + // we don't need to go to the trouble of wrapping options.onDirty. + this.broadcastWatches(options); + } } public performTransaction( @@ -390,7 +427,7 @@ export class InMemoryCache extends ApolloCache { c: Cache.WatchOptions, options?: BroadcastOptions, ) => { - return this.broadcastWatch.call(this, c, options); + return this.broadcastWatch(c, options); }, { makeCacheKey: (c: Cache.WatchOptions) => { // Return a cache key (thus enabling result caching) only if we're From 4c622401116d93e52bd789b3f8dda04403c0aac5 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Mon, 5 Apr 2021 12:41:16 -0400 Subject: [PATCH 104/380] Bump bundlesize limit from 26.55kB to 26.6kB. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c5a47eadd83..444084df6b2 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,7 @@ { "name": "apollo-client", "path": "./dist/apollo-client.cjs.min.js", - "maxSize": "26.55 kB" + "maxSize": "26.6 kB" } ], "peerDependencies": { From deaa2d9a8671e1776f1351746c4048e49481b77a Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Mon, 5 Apr 2021 12:42:33 -0400 Subject: [PATCH 105/380] Bump @apollo/client npm version to 3.4.0-beta.20. --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 928e2a0c2b9..7d974365f35 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-beta.19", + "version": "3.4.0-beta.20", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 444084df6b2..141a911169e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-beta.19", + "version": "3.4.0-beta.20", "description": "A fully-featured caching GraphQL client.", "private": true, "keywords": [ From 35158e320489c4e4bb48bdd2f2f4b510c8db6dab Mon Sep 17 00:00:00 2001 From: Danny Cochran Date: Wed, 7 Apr 2021 11:03:35 -0700 Subject: [PATCH 106/380] Increment queryInfo.lastRequestId only when using links to get results (#7956) Co-authored-by: Daniel Co-authored-by: Ben Newman --- CHANGELOG.md | 4 +- src/core/QueryManager.ts | 10 ++-- src/core/__tests__/ObservableQuery.ts | 47 +++++++++---------- src/core/__tests__/QueryManager/index.ts | 59 ++++++++++++++++++++++++ 4 files changed, 90 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 74f45d35254..cad50df5b42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,9 @@ ## Apollo Client 3.4.0 (not yet released) ### Bug fixes -TBD + +- Increment `queryInfo.lastRequestId` only when making a network request through the `ApolloLink` chain, rather than every time `fetchQueryByPolicy` is called.
+ [@dannycochran](https://github.com/dannycochran) in [#7956](https://github.com/apollographql/apollo-client/pull/7956) ### Potentially breaking changes diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index 2924c46584b..a389286adfa 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -855,7 +855,7 @@ export class QueryManager { | "fetchPolicy" | "errorPolicy">, ): Observable> { - const { lastRequestId } = queryInfo; + const requestId = queryInfo.lastRequestId = this.generateRequestId(); return asyncMap( this.getObservableFromLink( @@ -867,7 +867,9 @@ export class QueryManager { result => { const hasErrors = isNonEmptyArray(result.errors); - if (lastRequestId >= queryInfo.lastRequestId) { + // If we interrupted this request by calling getResultsFromLink again + // with the same QueryInfo object, we ignore the old results. + if (requestId >= queryInfo.lastRequestId) { if (hasErrors && options.errorPolicy === "none") { // Throwing here effectively calls observer.error. throw queryInfo.markError(new ApolloError({ @@ -896,7 +898,8 @@ export class QueryManager { ? networkError : new ApolloError({ networkError }); - if (lastRequestId >= queryInfo.lastRequestId) { + // Avoid storing errors from older interrupted queries. + if (requestId >= queryInfo.lastRequestId) { queryInfo.markError(error); } @@ -1059,7 +1062,6 @@ export class QueryManager { queryInfo.init({ document: query, variables, - lastRequestId: this.generateRequestId(), networkStatus, }); diff --git a/src/core/__tests__/ObservableQuery.ts b/src/core/__tests__/ObservableQuery.ts index 6656b1fffc4..d7bf0c9aeeb 100644 --- a/src/core/__tests__/ObservableQuery.ts +++ b/src/core/__tests__/ObservableQuery.ts @@ -15,6 +15,28 @@ import mockQueryManager from '../../utilities/testing/mocking/mockQueryManager'; import mockWatchQuery from '../../utilities/testing/mocking/mockWatchQuery'; import wrap from '../../utilities/testing/wrap'; +export const mockFetchQuery = (queryManager: QueryManager) => { + const fetchQueryObservable = queryManager.fetchQueryObservable; + const fetchQueryByPolicy: QueryManager["fetchQueryByPolicy"] = + (queryManager as any).fetchQueryByPolicy; + + const mock = (original: T) => jest.fn, Parameters>(function () { + return original.apply(queryManager, arguments); + }); + + const mocks = { + fetchQueryObservable: mock(fetchQueryObservable), + fetchQueryByPolicy: mock(fetchQueryByPolicy), + }; + + Object.assign(queryManager, mocks); + + return mocks; +}; + describe('ObservableQuery', () => { // Standard data for all these tests const query = gql` @@ -929,31 +951,6 @@ describe('ObservableQuery', () => { }); describe('refetch', () => { - function mockFetchQuery(queryManager: QueryManager) { - const fetchQueryObservable = queryManager.fetchQueryObservable; - const fetchQueryByPolicy: QueryManager["fetchQueryByPolicy"] = - (queryManager as any).fetchQueryByPolicy; - - const mock = (original: T) => jest.fn< - ReturnType, - Parameters - >(function () { - return original.apply(queryManager, arguments); - }); - - const mocks = { - fetchQueryObservable: mock(fetchQueryObservable), - fetchQueryByPolicy: mock(fetchQueryByPolicy), - }; - - Object.assign(queryManager, mocks); - - return mocks; - } - itAsync('calls fetchRequest with fetchPolicy `network-only` when using a non-networked fetch policy', (resolve, reject) => { const mockedResponses = [ { diff --git a/src/core/__tests__/QueryManager/index.ts b/src/core/__tests__/QueryManager/index.ts index a0fddec6f31..9d406aef35f 100644 --- a/src/core/__tests__/QueryManager/index.ts +++ b/src/core/__tests__/QueryManager/index.ts @@ -37,6 +37,7 @@ import subscribeAndCount from '../../../utilities/testing/subscribeAndCount'; import { stripSymbols } from '../../../utilities/testing/stripSymbols'; import { itAsync } from '../../../utilities/testing/itAsync'; import { ApolloClient } from '../../../core' +import { mockFetchQuery } from '../ObservableQuery'; interface MockedMutation { reject: (reason: any) => any; @@ -2722,6 +2723,64 @@ describe('QueryManager', () => { ]).then(resolve, reject); }); + + itAsync('only increments "queryInfo.lastRequestId" when fetching data from network', (resolve, reject) => { + const query = gql` + query query($id: ID!) { + people_one(id: $id) { + name + } + } + `; + const variables = { id: 1 }; + const dataOne = { + people_one: { + name: 'Luke Skywalker', + }, + }; + const mockedResponses = [ + { + request: { query, variables }, + result: { data: dataOne }, + }, + ]; + + const queryManager = mockQueryManager(reject, ...mockedResponses); + const queryOptions: WatchQueryOptions = { + query, + variables, + fetchPolicy: 'cache-and-network', + }; + const observable = queryManager.watchQuery(queryOptions); + + const mocks = mockFetchQuery(queryManager); + const queryId = '1'; + const getQuery: QueryManager["getQuery"] = + (queryManager as any).getQuery.bind(queryManager); + + subscribeAndCount(reject, observable, async (handleCount) => { + const query = getQuery(queryId); + const fqbpCalls = mocks.fetchQueryByPolicy.mock.calls; + expect(query.lastRequestId).toEqual(1); + expect(fqbpCalls.length).toBe(1); + + // Simulate updating the options of the query, which will trigger + // fetchQueryByPolicy, but it should just read from cache and not + // update "queryInfo.lastRequestId". For more information, see + // https://github.com/apollographql/apollo-client/pull/7956#issue-610298427 + await observable.setOptions({ + ...queryOptions, + fetchPolicy: 'cache-first', + }); + + // "fetchQueryByPolicy" was called, but "lastRequestId" does not update + // since it was able to read from cache. + expect(query.lastRequestId).toEqual(1); + expect(fqbpCalls.length).toBe(2); + resolve(); + }); + }) + describe('polling queries', () => { itAsync('allows you to poll queries', (resolve, reject) => { const query = gql` From 1cdc7e88cc2914a261302ec09f97d02793036ebf Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Wed, 7 Apr 2021 14:04:52 -0400 Subject: [PATCH 107/380] Bump @apollo/client npm version to 3.4.0-beta.21. --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7d974365f35..2304c2d9f7e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-beta.20", + "version": "3.4.0-beta.21", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 141a911169e..4cbf5fbd2f4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-beta.20", + "version": "3.4.0-beta.21", "description": "A fully-featured caching GraphQL client.", "private": true, "keywords": [ From 496b3ec2da33720d6444c4b4a7d4b44a4d994782 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Fri, 9 Apr 2021 13:59:04 -0400 Subject: [PATCH 108/380] Avoid collecting more data in stopped RenderPromises objects. Testing a theory related to issue #7942 that QueryData hangs onto the context object and might attempt to call context.renderPromises methods after the RenderPromises has been cleared by getMarkupFromTree. --- scripts/memory/tests.js | 3 +++ src/react/ssr/RenderPromises.ts | 35 +++++++++++++++++++------------- src/react/ssr/getDataFromTree.ts | 2 +- 3 files changed, 25 insertions(+), 15 deletions(-) diff --git a/scripts/memory/tests.js b/scripts/memory/tests.js index 9ebec44e396..99413446160 100644 --- a/scripts/memory/tests.js +++ b/scripts/memory/tests.js @@ -175,6 +175,9 @@ describe("garbage collection", () => { // won't be garbage collected before this function runs, so we can // verify that renderPromises.clear() was called by getDataFromTree. assert.strictEqual(renderPromisesSet.size, 1); + renderPromisesSet.forEach(rp => { + assert.strictEqual(rp.stopped, true); + }); if (expectedKeys.delete(key) && !expectedKeys.size) { resolve(); diff --git a/src/react/ssr/RenderPromises.ts b/src/react/ssr/RenderPromises.ts index bde05b671f8..b2171ac9e1e 100644 --- a/src/react/ssr/RenderPromises.ts +++ b/src/react/ssr/RenderPromises.ts @@ -26,9 +26,13 @@ export class RenderPromises { // beyond a single call to renderToStaticMarkup. private queryInfoTrie = new Map>(); - public clear() { - this.queryPromises.clear(); - this.queryInfoTrie.clear(); + private stopped = false; + public stop() { + if (!this.stopped) { + this.queryPromises.clear(); + this.queryInfoTrie.clear(); + this.stopped = true; + } } // Registers the server side rendered observable. @@ -36,6 +40,7 @@ export class RenderPromises { observable: ObservableQuery, props: QueryDataOptions ) { + if (this.stopped) return; this.lookupQueryInfo(props).observable = observable; } @@ -50,17 +55,19 @@ export class RenderPromises { queryInstance: QueryData, finish: () => React.ReactNode ): React.ReactNode { - const info = this.lookupQueryInfo(queryInstance.getOptions()); - if (!info.seen) { - this.queryPromises.set( - queryInstance.getOptions(), - new Promise(resolve => { - resolve(queryInstance.fetchData()); - }) - ); - // Render null to abandon this subtree for this rendering, so that we - // can wait for the data to arrive. - return null; + if (!this.stopped) { + const info = this.lookupQueryInfo(queryInstance.getOptions()); + if (!info.seen) { + this.queryPromises.set( + queryInstance.getOptions(), + new Promise(resolve => { + resolve(queryInstance.fetchData()); + }) + ); + // Render null to abandon this subtree for this rendering, so that we + // can wait for the data to arrive. + return null; + } } return finish(); } diff --git a/src/react/ssr/getDataFromTree.ts b/src/react/ssr/getDataFromTree.ts index e55b5983f29..8b9f5fbb809 100644 --- a/src/react/ssr/getDataFromTree.ts +++ b/src/react/ssr/getDataFromTree.ts @@ -53,7 +53,7 @@ export function getMarkupFromTree({ ? renderPromises.consumeAndAwaitPromises().then(process) : html; }).finally(() => { - renderPromises.clear(); + renderPromises.stop(); }); } From c5a79ef1f8d3e44d68e549e18ffdb86e873b128a Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Sat, 10 Apr 2021 10:44:51 -0400 Subject: [PATCH 109/380] Change default refetchWritePolicy to "overwrite" rather than "merge" (#7966) --- CHANGELOG.md | 32 ++++++++++----------- src/core/QueryManager.ts | 2 +- src/react/hooks/__tests__/useQuery.test.tsx | 14 ++++----- 3 files changed, 23 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cad50df5b42..850c86f9cc8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ - Increment `queryInfo.lastRequestId` only when making a network request through the `ApolloLink` chain, rather than every time `fetchQueryByPolicy` is called.
[@dannycochran](https://github.com/dannycochran) in [#7956](https://github.com/apollographql/apollo-client/pull/7956) +- In Apollo Client 2.x, a `refetch` operation would always replace existing data in the cache. With the introduction of field policy `merge` functions in Apollo Client 3, existing field values could be inappropriately combined with incoming field values by a custom `merge` function that does not realize a `refetch` has happened. + + To give you more control over this behavior, we have introduced an `overwrite?: boolean = false` option for `cache.writeQuery` and `cache.writeFragment`, and an option called `refetchWritePolicy?: "merge" | "overwrite"` for `client.watchQuery`, `useQuery`, and other functions that accept `WatchQueryOptions`. You can use these options to make sure any `merge` functions involved in cache writes for `refetch` operations get invoked with `undefined` as their first argument, which simulates the absence of any existing data, while still giving the `merge` function a chance to determine the internal representation of the incoming data. + + The default behaviors are `overwrite: true` and `refetchWritePolicy: "overwrite"`, which restores the Apollo Client 2.x behavior, but (if this change causes any problems for your application) you can easily recover the previous merging behavior by setting a default value for `refetchWritePolicy` in `defaultOptions.watchQuery`: + ```ts + new ApolloClient({ + defaultOptions: { + watchQuery: { + refetchWritePolicy: "merge", + }, + }, + }) + ``` + [@benjamn](https://github.com/benjamn) in [#7810](https://github.com/apollographql/apollo-client/pull/7810) + ### Potentially breaking changes - Internally, Apollo Client now uses namespace syntax (e.g. `import * as React from "react"`) for imports whose types are re-exported (and thus may appear in `.d.ts` files). This change should remove any need to configure `esModuleInterop` or `allowSyntheticDefaultImports` in `tsconfig.json`, but might require updating bundler configurations that specify named exports of the `react` and `prop-types` packages, to include exports like `createContext` and `createElement` ([example](https://github.com/apollographql/apollo-client/commit/16b08e1af9ba9934041298496e167aafb128c15d)).
@@ -21,22 +37,6 @@ - `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.
[@benjamn](https://github.com/benjamn) in [#7819](https://github.com/apollographql/apollo-client/pull/7819) -- In Apollo Client 2.x, `refetch` would always replace existing data in the cache. With the introduction of field policy `merge` functions in Apollo Client 3, existing field values can be inappropriately combined with incoming field values by a custom `merge` function that does not realize a `refetch` has happened. - - To give you more control over this behavior, we have introduced an `overwrite?: boolean = false` option for `cache.writeQuery` and `cache.writeFragment`, and an option called `refetchWritePolicy?: "merge" | "overwrite"` for `client.watchQuery`, `useQuery`, and other functions that accept `WatchQueryOptions`. You can use these options to make sure any `merge` functions involved in cache writes for `refetch` operations get invoked with `undefined` as their first argument, which simulates the absence of any existing data, while still giving the `merge` function a chance to determine the internal representation of the incoming data. - - The default behaviors are `overwrite: false` and `refetchWritePolicy: "merge"`, but you can change the default `refetchWritePolicy` value using `defaultOptions.watchQuery`: - ```ts - new ApolloClient({ - defaultOptions: { - watchQuery: { - refetchWritePolicy: "overwrite", - }, - }, - }) - ``` - [@benjamn](https://github.com/benjamn) in [#7810](https://github.com/apollographql/apollo-client/pull/7810) - - Mutations now accept an optional callback function called `reobserveQuery`, which will be passed the `ObservableQuery` and `Cache.DiffResult` objects for any queries invalidated by cache writes performed by the mutation's final `update` function. Using `reobserveQuery`, you can override the default `FetchPolicy` of the query, by (for example) calling `ObservableQuery` methods like `refetch` to force a network request. This automatic detection of invalidated queries provides an alternative to manually enumerating queries using the `refetchQueries` mutation option. Also, if you return a `Promise` from `reobserveQuery`, the mutation will automatically await that `Promise`, rendering the `awaitRefetchQueries` option unnecessary.
[@benjamn](https://github.com/benjamn) in [#7827](https://github.com/apollographql/apollo-client/pull/7827) diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index a389286adfa..6ca7534558e 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -1107,7 +1107,7 @@ export class QueryManager { ( // Watched queries must opt into overwriting existing data on refetch, // by passing refetchWritePolicy: "overwrite" in their WatchQueryOptions. networkStatus === NetworkStatus.refetch && - refetchWritePolicy === "overwrite" + refetchWritePolicy !== "merge" ) ? CacheWriteBehavior.OVERWRITE : CacheWriteBehavior.MERGE; diff --git a/src/react/hooks/__tests__/useQuery.test.tsx b/src/react/hooks/__tests__/useQuery.test.tsx index 276a4f1a711..10d2f4f2dd4 100644 --- a/src/react/hooks/__tests__/useQuery.test.tsx +++ b/src/react/hooks/__tests__/useQuery.test.tsx @@ -1830,9 +1830,7 @@ describe('useQuery Hook', () => { }).then(resolve, reject); }); - // TODO The default refetchWritePolicy probably should change to "overwrite" - // when we release the next major version of Apollo Client (v4). - itAsync('should assume default refetchWritePolicy value is "merge"', (resolve, reject) => { + itAsync('should assume default refetchWritePolicy value is "overwrite"', (resolve, reject) => { const mergeParams: [any, any][] = []; const cache = new InMemoryCache({ typePolicies: { @@ -1893,7 +1891,7 @@ describe('useQuery Hook', () => { loading: false, networkStatus: NetworkStatus.ready, data: { - primes: [2, 3, 5, 7, 11, 13, 17, 19, 23, 29], + primes: [13, 17, 19, 23, 29], }, }); }); @@ -1914,13 +1912,13 @@ describe('useQuery Hook', () => { expect(loading).toBe(false); expect(error).toBeUndefined(); expect(data).toEqual({ - // Thanks to refetchWritePolicy: "merge". - primes: [2, 3, 5, 7, 11, 13, 17, 19, 23, 29], + primes: [13, 17, 19, 23, 29], }); expect(mergeParams).toEqual([ [void 0, [2, 3, 5, 7, 11]], - // This indicates concatenation happened. - [[2, 3, 5, 7, 11], [13, 17, 19, 23, 29]], + // Without refetchWritePolicy: "overwrite", this array will be + // all 10 primes (2 through 29) together. + [void 0, [13, 17, 19, 23, 29]], ]); break; default: From ca643ccea567011eda2cf0c8232c19e6f28b51c2 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Sat, 10 Apr 2021 10:45:36 -0400 Subject: [PATCH 110/380] Bump @apollo/client npm version to 3.4.0-beta.22. --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2304c2d9f7e..33ac42f9b22 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-beta.21", + "version": "3.4.0-beta.22", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 4cbf5fbd2f4..938b931d10b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-beta.21", + "version": "3.4.0-beta.22", "description": "A fully-featured caching GraphQL client.", "private": true, "keywords": [ From 3f2eee7e8d6a4874120b6c84707b565ebcdaa349 Mon Sep 17 00:00:00 2001 From: Jenn Creighton Date: Mon, 12 Apr 2021 17:50:49 -0400 Subject: [PATCH 111/380] Provide variables and context to update function (#7902) --- CHANGELOG.md | 2 + src/core/ApolloClient.ts | 22 +- src/core/QueryManager.ts | 63 +++--- src/core/__tests__/QueryManager/index.ts | 4 +- src/core/index.ts | 1 - src/core/types.ts | 16 ++ src/core/watchQueryOptions.ts | 48 +++-- src/react/components/types.ts | 10 +- src/react/data/MutationData.ts | 24 ++- .../hoc/__tests__/mutations/queries.test.tsx | 9 +- .../mutations/recycled-queries.test.tsx | 9 +- src/react/hoc/mutation-hoc.tsx | 16 +- src/react/hoc/types.ts | 11 +- .../hooks/__tests__/useMutation.test.tsx | 188 ++++++++++++++++++ src/react/hooks/useMutation.ts | 17 +- src/react/types/types.ts | 57 ++++-- 16 files changed, 382 insertions(+), 115 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 850c86f9cc8..43c6fcf318f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,8 @@ - Allow `merge: true` field policy to merge `Reference` objects with non-normalized objects, and vice-versa.
[@benjamn](https://github.com/benjamn) in [#7778](https://github.com/apollographql/apollo-client/pull/7778) +- Pass `variables` and `context` to a mutation's `update` function
+ [@jcreighton](https://github.com/jcreighton) in [#7902](https://github.com/apollographql/apollo-client/pull/7902) ### Documentation TBD diff --git a/src/core/ApolloClient.ts b/src/core/ApolloClient.ts index 82d77d31257..a87b4e30395 100644 --- a/src/core/ApolloClient.ts +++ b/src/core/ApolloClient.ts @@ -12,6 +12,7 @@ import { ObservableQuery } from './ObservableQuery'; import { ApolloQueryResult, + DefaultContext, OperationVariables, Resolvers, } from './types'; @@ -33,7 +34,7 @@ import { export interface DefaultOptions { watchQuery?: Partial>; query?: Partial>; - mutate?: Partial>; + mutate?: Partial>; } let hasSuggestedDevtools = false; @@ -57,13 +58,13 @@ export type ApolloClientOptions = { version?: string; }; -type OptionsUnion = +type OptionsUnion = | WatchQueryOptions | QueryOptions - | MutationOptions; + | MutationOptions; export function mergeOptions< - TOptions extends OptionsUnion + TOptions extends OptionsUnion >( defaults: Partial, options: TOptions, @@ -348,13 +349,18 @@ export class ApolloClient implements DataProxy { * * It takes options as an object with the following keys and values: */ - public mutate( - options: MutationOptions, - ): Promise> { + public mutate< + TData = any, + TVariables = OperationVariables, + TContext = DefaultContext, + TCache extends ApolloCache = ApolloCache + >( + options: MutationOptions, + ): Promise> { if (this.defaultOptions.mutate) { options = mergeOptions(this.defaultOptions.mutate, options); } - return this.queryManager.mutate(options); + return this.queryManager.mutate(options); } /** diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index 6ca7534558e..aabc1afd508 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -35,6 +35,7 @@ import { NetworkStatus, isNetworkRequestInFlight } from './networkStatus'; import { ApolloQueryResult, OperationVariables, + MutationUpdaterFunction, ReobserveQueryCallback, } from './types'; import { LocalState } from './LocalState'; @@ -55,6 +56,8 @@ interface MutationStoreValue { error: Error | null; } +type UpdateQueries = MutationOptions["updateQueries"]; + export class QueryManager { public cache: ApolloCache; public link: ApolloLink; @@ -128,7 +131,7 @@ export class QueryManager { this.fetchCancelFns.clear(); } - public async mutate({ + public async mutate>({ mutation, variables, optimisticResponse, @@ -139,8 +142,8 @@ export class QueryManager { reobserveQuery, errorPolicy = 'none', fetchPolicy, - context = {}, - }: MutationOptions): Promise> { + context, + }: MutationOptions): Promise> { invariant( mutation, 'mutation option is required. You must specify your GraphQL document in the mutation option.', @@ -154,10 +157,10 @@ export class QueryManager { const mutationId = this.generateMutationId(); mutation = this.transform(mutation).document; - variables = this.getVariables(mutation, variables); + variables = this.getVariables(mutation, variables) as TVariables; if (this.transform(mutation).hasClientExports) { - variables = await this.localState.addExportedVariables(mutation, variables, context); + variables = await this.localState.addExportedVariables(mutation, variables, context) as TVariables; } const mutationStoreValue = @@ -170,11 +173,17 @@ export class QueryManager { } as MutationStoreValue); if (optimisticResponse) { - this.markMutationOptimistic(optimisticResponse, { + this.markMutationOptimistic< + TData, + TVariables, + TContext, + TCache + >(optimisticResponse, { mutationId, document: mutation, variables, errorPolicy, + context, updateQueries, update: updateWithProxyFn, }); @@ -185,7 +194,7 @@ export class QueryManager { const self = this; return new Promise((resolve, reject) => { - let storeResult: FetchResult | null; + let storeResult: FetchResult | null; return asyncMap( self.getObservableFromLink( @@ -198,7 +207,7 @@ export class QueryManager { false, ), - (result: FetchResult) => { + (result: FetchResult) => { if (graphQLResultHasError(result) && errorPolicy === 'none') { throw new ApolloError({ graphQLErrors: result.errors, @@ -218,12 +227,13 @@ export class QueryManager { // mutation await any Promise that markMutationResult returns, // since we are returning this Promise from the asyncMap mapping // function. - return self.markMutationResult({ + return self.markMutationResult({ mutationId, result, document: mutation, variables, errorPolicy, + context, updateQueries, update: updateWithProxyFn, reobserveQuery, @@ -291,18 +301,16 @@ export class QueryManager { }); } - public markMutationResult( + public markMutationResult>( mutation: { mutationId: string; result: FetchResult; document: DocumentNode; - variables?: OperationVariables; + variables?: TVariables; errorPolicy: ErrorPolicy; - updateQueries: MutationOptions["updateQueries"], - update?: ( - cache: ApolloCache, - result: FetchResult, - ) => void; + context?: TContext; + updateQueries: UpdateQueries; + update?: MutationUpdaterFunction; reobserveQuery?: ReobserveQueryCallback; }, cache = this.cache, @@ -364,7 +372,10 @@ export class QueryManager { // a write action. const { update } = mutation; if (update) { - update(c, mutation.result); + update(c as any, mutation.result, { + context: mutation.context, + variables: mutation.variables, + }); } }, @@ -389,18 +400,16 @@ export class QueryManager { return Promise.resolve(); } - public markMutationOptimistic( + public markMutationOptimistic>( optimisticResponse: any, mutation: { mutationId: string; document: DocumentNode; - variables?: OperationVariables; + variables?: TVariables; errorPolicy: ErrorPolicy; - updateQueries: MutationOptions["updateQueries"], - update?: ( - cache: ApolloCache, - result: FetchResult, - ) => void; + context?: TContext; + updateQueries: UpdateQueries, + update?: MutationUpdaterFunction; }, ) { const data = typeof optimisticResponse === "function" @@ -409,7 +418,7 @@ export class QueryManager { return this.cache.recordOptimisticTransaction(cache => { try { - this.markMutationResult({ + this.markMutationResult({ ...mutation, result: { data }, }, cache); @@ -505,9 +514,9 @@ export class QueryManager { return transformCache.get(document)!; } - private getVariables( + private getVariables( document: DocumentNode, - variables?: OperationVariables, + variables?: TVariables, ): OperationVariables { return { ...this.transform(document).defaultVars, diff --git a/src/core/__tests__/QueryManager/index.ts b/src/core/__tests__/QueryManager/index.ts index 9d406aef35f..d869425b033 100644 --- a/src/core/__tests__/QueryManager/index.ts +++ b/src/core/__tests__/QueryManager/index.ts @@ -5440,7 +5440,7 @@ describe('QueryManager', () => { describe('awaitRefetchQueries', () => { const awaitRefetchTest = - ({ awaitRefetchQueries, testQueryError = false }: MutationBaseOptions & { testQueryError?: boolean }) => + ({ awaitRefetchQueries, testQueryError = false }: MutationBaseOptions & { testQueryError?: boolean }) => new Promise((resolve, reject) => { const query = gql` query getAuthors($id: ID!) { @@ -5514,7 +5514,7 @@ describe('QueryManager', () => { { observable }, result => { expect(stripSymbols(result.data)).toEqual(queryData); - const mutateOptions: MutationOptions = { + const mutateOptions: MutationOptions = { mutation, refetchQueries: ['getAuthors'], }; diff --git a/src/core/index.ts b/src/core/index.ts index 6c005835443..a5fa0bb1c2d 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -21,7 +21,6 @@ export { ErrorPolicy, FetchMoreQueryOptions, SubscribeToMoreOptions, - MutationUpdaterFn, } from './watchQueryOptions'; export { NetworkStatus } from './networkStatus'; export * from './types'; diff --git a/src/core/types.ts b/src/core/types.ts index b90b84e35e0..c6e40a3eb31 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -1,5 +1,6 @@ import { DocumentNode, GraphQLError } from 'graphql'; +import { ApolloCache } from '../cache'; import { FetchResult } from '../link/core'; import { ApolloError } from '../errors'; import { QueryInfo } from './QueryInfo'; @@ -10,6 +11,8 @@ import { Cache } from '../cache'; export { TypedDocumentNode } from '@graphql-typed-document-node/core'; +export type DefaultContext = Record; + export type QueryListener = (queryInfo: QueryInfo) => void; export type ReobserveQueryCallback = ( @@ -51,6 +54,19 @@ export type MutationQueryReducersMap = { [queryName: string]: MutationQueryReducer; }; +export type MutationUpdaterFunction< + TData, + TVariables, + TContext, + TCache extends ApolloCache +> = ( + cache: TCache, + result: Omit, 'context'>, + options: { + context?: TContext, + variables?: TVariables, + }, +) => void; export interface Resolvers { [key: string]: { [ field: string ]: Resolver; diff --git a/src/core/watchQueryOptions.ts b/src/core/watchQueryOptions.ts index e6e2fd0efe9..e819295eaa1 100644 --- a/src/core/watchQueryOptions.ts +++ b/src/core/watchQueryOptions.ts @@ -1,10 +1,16 @@ import { DocumentNode } from 'graphql'; import { TypedDocumentNode } from '@graphql-typed-document-node/core'; -import { ApolloCache } from '../cache'; import { FetchResult } from '../link/core'; -import { MutationQueryReducersMap, ReobserveQueryCallback } from './types'; -import { PureQueryOptions, OperationVariables } from './types'; +import { + DefaultContext, + MutationQueryReducersMap, + PureQueryOptions, + OperationVariables, + MutationUpdaterFunction, + ReobserveQueryCallback, +} from './types'; +import { ApolloCache } from '../cache'; /** * fetchPolicy determines where the client may return a result from. The options are: @@ -144,7 +150,7 @@ export type SubscribeToMoreOptions< variables?: TSubscriptionVariables; updateQuery?: UpdateQueryFn; onError?: (error: Error) => void; - context?: Record; + context?: DefaultContext; }; export interface SubscriptionOptions { @@ -173,14 +179,16 @@ export interface SubscriptionOptions; + context?: DefaultContext; } export type RefetchQueryDescription = Array; export interface MutationBaseOptions< - T = { [key: string]: any }, - TVariables = OperationVariables + TData = any, + TVariables = OperationVariables, + TContext = DefaultContext, + TCache extends ApolloCache = ApolloCache, > { /** * An object that represents the result of this mutation that will be @@ -189,7 +197,7 @@ export interface MutationBaseOptions< * the result of a mutation immediately, and update the UI later if any errors * appear. */ - optimisticResponse?: T | ((vars: TVariables) => T); + optimisticResponse?: TData | ((vars: TVariables) => TData); /** * A {@link MutationQueryReducersMap}, which is map from query names to @@ -197,7 +205,7 @@ export interface MutationBaseOptions< * results of the mutation into the results of queries that are currently * being watched by your application. */ - updateQueries?: MutationQueryReducersMap; + updateQueries?: MutationQueryReducersMap; /** * A list of query names which will be refetched once this mutation has @@ -208,7 +216,7 @@ export interface MutationBaseOptions< * once these queries return. */ refetchQueries?: - | ((result: FetchResult) => RefetchQueryDescription) + | ((result: FetchResult) => RefetchQueryDescription) | RefetchQueryDescription; /** @@ -239,7 +247,7 @@ export interface MutationBaseOptions< * and you don't need to update the store, use the Promise returned from * `client.mutate` instead. */ - update?: MutationUpdaterFn; + update?: MutationUpdaterFunction; /** * A function that will be called for each ObservableQuery affected by @@ -260,14 +268,16 @@ export interface MutationBaseOptions< } export interface MutationOptions< - T = { [key: string]: any }, - TVariables = OperationVariables -> extends MutationBaseOptions { + TData = any, + TVariables = OperationVariables, + TContext = DefaultContext, + TCache extends ApolloCache = ApolloCache, +> extends MutationBaseOptions { /** * A GraphQL document, often created with `gql` from the `graphql-tag` * package, that contains a single mutation inside of it. */ - mutation: DocumentNode | TypedDocumentNode; + mutation: DocumentNode | TypedDocumentNode; /** * The context to be passed to the link execution chain. This context will @@ -279,7 +289,7 @@ export interface MutationOptions< * [`query` `context` option](https://www.apollographql.com/docs/react/api/apollo-client#ApolloClient.query)) * when the query is first initialized/run. */ - context?: any; + context?: TContext; /** * Specifies the {@link FetchPolicy} to be used for this query. Mutations only @@ -289,9 +299,3 @@ export interface MutationOptions< */ fetchPolicy?: Extract; } - -// Add a level of indirection for `typedoc`. -export type MutationUpdaterFn = ( - cache: ApolloCache, - mutationResult: FetchResult, -) => void; diff --git a/src/react/components/types.ts b/src/react/components/types.ts index 83dea55043f..50fa1588174 100644 --- a/src/react/components/types.ts +++ b/src/react/components/types.ts @@ -1,7 +1,7 @@ import { DocumentNode } from 'graphql'; import { TypedDocumentNode } from '@graphql-typed-document-node/core'; -import { OperationVariables } from '../../core'; +import { OperationVariables, DefaultContext, ApolloCache } from '../../core'; import { QueryFunctionOptions, QueryResult, @@ -22,11 +22,13 @@ export interface QueryComponentOptions< export interface MutationComponentOptions< TData = any, - TVariables = OperationVariables -> extends BaseMutationOptions { + TVariables = OperationVariables, + TContext = DefaultContext, + TCache extends ApolloCache = ApolloCache +> extends BaseMutationOptions { mutation: DocumentNode | TypedDocumentNode; children: ( - mutateFunction: MutationFunction, + mutateFunction: MutationFunction, result: MutationResult ) => JSX.Element | null; } diff --git a/src/react/data/MutationData.ts b/src/react/data/MutationData.ts index 209cd8be6ac..0d4d8495522 100644 --- a/src/react/data/MutationData.ts +++ b/src/react/data/MutationData.ts @@ -9,15 +9,17 @@ import { MutationResult, } from '../types/types'; import { OperationData } from './OperationData'; -import { OperationVariables, MutationOptions, mergeOptions } from '../../core'; +import { MutationOptions, mergeOptions, ApolloCache, OperationVariables, DefaultContext } from '../../core'; import { FetchResult } from '../../link/core'; type MutationResultWithoutClient = Omit, 'client'>; export class MutationData< TData = any, - TVariables = OperationVariables -> extends OperationData> { + TVariables = OperationVariables, + TContext = DefaultContext, + TCache extends ApolloCache = ApolloCache, +> extends OperationData> { private mostRecentMutationId: number; private result: MutationResultWithoutClient; private previousResult?: MutationResultWithoutClient; @@ -29,7 +31,7 @@ export class MutationData< result, setResult }: { - options: MutationDataOptions; + options: MutationDataOptions; context: any; result: MutationResultWithoutClient; setResult: (result: MutationResultWithoutClient) => any; @@ -41,13 +43,13 @@ export class MutationData< this.mostRecentMutationId = 0; } - public execute(result: MutationResultWithoutClient): MutationTuple { + public execute(result: MutationResultWithoutClient): MutationTuple { this.isMounted = true; this.verifyDocumentType(this.getOptions().mutation, DocumentType.Mutation); return [ this.runMutation, { ...result, client: this.refreshClient().client } - ] as MutationTuple; + ] as MutationTuple; } public afterExecute() { @@ -62,8 +64,10 @@ export class MutationData< private runMutation = ( mutationFunctionOptions: MutationFunctionOptions< TData, - TVariables - > = {} as MutationFunctionOptions + TVariables, + TContext, + TCache + > = {} as MutationFunctionOptions ) => { this.onMutationStart(); const mutationId = this.generateNewMutationId(); @@ -80,12 +84,12 @@ export class MutationData< }; private mutate( - options: MutationFunctionOptions + options: MutationFunctionOptions ) { return this.refreshClient().client.mutate( mergeOptions( this.getOptions(), - options as MutationOptions, + options as MutationOptions, ), ); } diff --git a/src/react/hoc/__tests__/mutations/queries.test.tsx b/src/react/hoc/__tests__/mutations/queries.test.tsx index 1c32f5e89f6..d31cca489f3 100644 --- a/src/react/hoc/__tests__/mutations/queries.test.tsx +++ b/src/react/hoc/__tests__/mutations/queries.test.tsx @@ -3,7 +3,7 @@ import { render, wait } from '@testing-library/react'; import gql from 'graphql-tag'; import { DocumentNode } from 'graphql'; -import { ApolloClient, MutationUpdaterFn } from '../../../../core'; +import { ApolloClient, MutationUpdaterFunction, ApolloCache } from '../../../../core'; import { ApolloProvider } from '../../../context'; import { InMemoryCache as Cache } from '../../../../cache'; import { @@ -131,7 +131,12 @@ describe('graphql(mutation) query integration', () => { }; } - const update: MutationUpdaterFn = (proxy, result) => { + const update: MutationUpdaterFunction< + MutationData, + Record, + Record, + ApolloCache + > = (proxy, result) => { const data = JSON.parse( JSON.stringify(proxy.readQuery({ query })) ); diff --git a/src/react/hoc/__tests__/mutations/recycled-queries.test.tsx b/src/react/hoc/__tests__/mutations/recycled-queries.test.tsx index 53147256128..aca021f1fc7 100644 --- a/src/react/hoc/__tests__/mutations/recycled-queries.test.tsx +++ b/src/react/hoc/__tests__/mutations/recycled-queries.test.tsx @@ -3,7 +3,7 @@ import { render, wait } from '@testing-library/react'; import gql from 'graphql-tag'; import { DocumentNode } from 'graphql'; -import { ApolloClient, MutationUpdaterFn } from '../../../../core'; +import { ApolloCache, ApolloClient, MutationUpdaterFunction } from '../../../../core'; import { ApolloProvider } from '../../../context'; import { InMemoryCache as Cache } from '../../../../cache'; import { MutationFunction } from '../../../types/types'; @@ -74,7 +74,12 @@ describe('graphql(mutation) update queries', () => { type MutationData = typeof mutationData; let todoUpdateQueryCount = 0; - const update: MutationUpdaterFn = (proxy, result) => { + const update: MutationUpdaterFunction< + MutationData, + Record, + Record, + ApolloCache + > = (proxy, result) => { todoUpdateQueryCount++; const data = JSON.parse( JSON.stringify( diff --git a/src/react/hoc/mutation-hoc.tsx b/src/react/hoc/mutation-hoc.tsx index 6f81396d664..dddab604ef5 100644 --- a/src/react/hoc/mutation-hoc.tsx +++ b/src/react/hoc/mutation-hoc.tsx @@ -3,6 +3,7 @@ import { DocumentNode } from 'graphql'; import hoistNonReactStatics from 'hoist-non-react-statics'; import { parser } from '../parser'; +import { DefaultContext } from '../../core/types'; import { BaseMutationOptions, MutationFunction, @@ -17,12 +18,15 @@ import { GraphQLBase } from './hoc-utils'; import { OperationOption, OptionProps, MutateProps } from './types'; +import { ApolloCache } from '../../core'; export function withMutation< TProps extends TGraphQLVariables | {} = {}, - TData = {}, + TData extends Record = {}, TGraphQLVariables = {}, - TChildProps = MutateProps + TChildProps = MutateProps, + TContext = DefaultContext, + TCache extends ApolloCache = ApolloCache, >( document: DocumentNode, operationOptions: OperationOption< @@ -41,9 +45,9 @@ export function withMutation< alias = 'Apollo' } = operationOptions; - let mapPropsToOptions = options as (props: any) => BaseMutationOptions; + let mapPropsToOptions = options as (props: any) => BaseMutationOptions; if (typeof mapPropsToOptions !== 'function') - mapPropsToOptions = () => options as BaseMutationOptions; + mapPropsToOptions = () => options as BaseMutationOptions; return ( WrappedComponent: React.ComponentType @@ -54,7 +58,7 @@ export function withMutation< static WrappedComponent = WrappedComponent; render() { let props = this.props as TProps; - const opts = mapPropsToOptions(props); + const opts = mapPropsToOptions(props) as BaseMutationOptions; if (operationOptions.withRef) { this.withRef = true; @@ -63,7 +67,7 @@ export function withMutation< }); } if (!opts.variables && operation.variables.length > 0) { - opts.variables = calculateVariablesFromProps(operation, props); + opts.variables = calculateVariablesFromProps(operation, props) as TGraphQLVariables; } return ( diff --git a/src/react/hoc/types.ts b/src/react/hoc/types.ts index dddfe8d6f1f..96b1cac7c4c 100644 --- a/src/react/hoc/types.ts +++ b/src/react/hoc/types.ts @@ -1,4 +1,4 @@ -import { ApolloClient } from '../../core'; +import { ApolloCache, ApolloClient } from '../../core'; import { ApolloError } from '../../errors'; import { ApolloQueryResult, @@ -7,6 +7,7 @@ import { UpdateQueryOptions, FetchMoreQueryOptions, SubscribeToMoreOptions, + DefaultContext, } from '../../core'; import { MutationFunction, @@ -89,16 +90,18 @@ export interface OperationOption< TProps, TData, TGraphQLVariables = OperationVariables, - TChildProps = ChildProps + TChildProps = ChildProps, + TContext = DefaultContext, + TCache extends ApolloCache = ApolloCache, > { options?: | BaseQueryOptions - | BaseMutationOptions + | BaseMutationOptions | (( props: TProps ) => | BaseQueryOptions - | BaseMutationOptions + | BaseMutationOptions ); props?: ( props: OptionProps, diff --git a/src/react/hooks/__tests__/useMutation.test.tsx b/src/react/hooks/__tests__/useMutation.test.tsx index ed0d4893baa..8e703a39ad9 100644 --- a/src/react/hooks/__tests__/useMutation.test.tsx +++ b/src/react/hooks/__tests__/useMutation.test.tsx @@ -428,6 +428,134 @@ describe('useMutation Hook', () => { }); }); + describe('Update function', () => { + + itAsync('should be called with the provided variables', async (resolve, reject) => { + const variables = { + description: 'Get milk!' + }; + + const mocks = [ + { + request: { + query: CREATE_TODO_MUTATION, + variables + }, + result: { data: CREATE_TODO_RESULT } + } + ]; + + const Component = () => { + const [createTodo] = useMutation( + CREATE_TODO_MUTATION, + { + update(_, __, options) { + expect(options.variables).toEqual(variables); + resolve(); + } + } + ); + + useEffect(() => { + createTodo({ variables }); + }, []); + + return null; + }; + + render( + + + + ); + }); + + itAsync('should be called with the provided context', async (resolve, reject) => { + const context = { id: 3 }; + + const variables = { + description: 'Get milk!' + }; + + const mocks = [ + { + request: { + query: CREATE_TODO_MUTATION, + variables + }, + result: { data: CREATE_TODO_RESULT } + } + ]; + + const Component = () => { + const [createTodo] = useMutation( + CREATE_TODO_MUTATION, + { + context, + update(_, __, options) { + expect(options.context).toEqual(context); + resolve(); + } + } + ); + + useEffect(() => { + createTodo({ variables }); + }, []); + + return null; + }; + + render( + + + + ); + }); + + describe('If context is not provided', () => { + itAsync('should be undefined', async (resolve, reject) => { + const variables = { + description: 'Get milk!' + }; + + const mocks = [ + { + request: { + query: CREATE_TODO_MUTATION, + variables + }, + result: { data: CREATE_TODO_RESULT } + } + ]; + + const Component = () => { + const [createTodo] = useMutation( + CREATE_TODO_MUTATION, + { + update(_, __, options) { + expect(options.context).toBeUndefined(); + resolve(); + } + } + ); + + useEffect(() => { + createTodo({ variables }); + }, []); + + return null; + }; + + render( + + + + ); + }); + }); + }); + describe('Optimistic response', () => { itAsync('should support optimistic response handling', async (resolve, reject) => { const optimisticResponse = { @@ -504,6 +632,66 @@ describe('useMutation Hook', () => { expect(renderCount).toBe(3); }).then(resolve, reject); }); + + itAsync('should be called with the provided context', async (resolve, reject) => { + const optimisticResponse = { + __typename: 'Mutation', + createTodo: { + id: 1, + description: 'TEMPORARY', + priority: 'High', + __typename: 'Todo' + } + }; + + const context = { id: 3 }; + + const variables = { + description: 'Get milk!' + }; + + const mocks = [ + { + request: { + query: CREATE_TODO_MUTATION, + variables + }, + result: { data: CREATE_TODO_RESULT } + } + ]; + + const contextFn = jest.fn(); + + const Component = () => { + const [createTodo] = useMutation( + CREATE_TODO_MUTATION, + { + optimisticResponse, + context, + update(_, __, options) { + contextFn(options.context); + } + } + ); + + useEffect(() => { + createTodo({ variables }); + }, []); + + return null; + }; + + render( + + + + ); + + return wait(() => { + expect(contextFn).toHaveBeenCalledTimes(2); + expect(contextFn).toHaveBeenCalledWith(context); + }).then(resolve, reject); + }); }); describe('refetching queries', () => { diff --git a/src/react/hooks/useMutation.ts b/src/react/hooks/useMutation.ts index 28f96618c0f..8119a01c4a8 100644 --- a/src/react/hooks/useMutation.ts +++ b/src/react/hooks/useMutation.ts @@ -4,21 +4,26 @@ import { TypedDocumentNode } from '@graphql-typed-document-node/core'; import { MutationHookOptions, MutationTuple } from '../types/types'; import { MutationData } from '../data'; -import { OperationVariables } from '../../core'; +import { ApolloCache, DefaultContext, OperationVariables } from '../../core'; import { getApolloContext } from '../context'; -export function useMutation( +export function useMutation< + TData = any, + TVariables = OperationVariables, + TContext = DefaultContext, + TCache extends ApolloCache = ApolloCache, +>( mutation: DocumentNode | TypedDocumentNode, - options?: MutationHookOptions -): MutationTuple { + options?: MutationHookOptions +): MutationTuple { const context = useContext(getApolloContext()); const [result, setResult] = useState({ called: false, loading: false }); const updatedOptions = options ? { ...options, mutation } : { mutation }; - const mutationDataRef = useRef>(); + const mutationDataRef = useRef>(); function getMutationDataRef() { if (!mutationDataRef.current) { - mutationDataRef.current = new MutationData({ + mutationDataRef.current = new MutationData({ options: updatedOptions, context, result, diff --git a/src/react/types/types.ts b/src/react/types/types.ts index c91f837f342..a7c692ba6f2 100644 --- a/src/react/types/types.ts +++ b/src/react/types/types.ts @@ -6,13 +6,15 @@ import { Observable } from '../../utilities'; import { FetchResult } from '../../link/core'; import { ApolloError } from '../../errors'; import { + ApolloCache, ApolloClient, ApolloQueryResult, + DefaultContext, ErrorPolicy, FetchMoreOptions, FetchMoreQueryOptions, FetchPolicy, - MutationUpdaterFn, + MutationUpdaterFunction, NetworkStatus, ObservableQuery, OperationVariables, @@ -24,7 +26,7 @@ import { /* Common types */ -export type Context = Record; +export type { DefaultContext as Context } from "../../core"; export type CommonOptions = TOptions & { client?: ApolloClient; @@ -36,7 +38,7 @@ export interface BaseQueryOptions extends Omit, "query"> { ssr?: boolean; client?: ApolloClient; - context?: Context; + context?: DefaultContext; } export interface QueryFunctionOptions< @@ -102,7 +104,7 @@ export interface LazyQueryHookOptions< export interface QueryLazyOptions { variables?: TVariables; - context?: Context; + context?: DefaultContext; } type UnexecutedLazyFields = { @@ -141,19 +143,21 @@ export type RefetchQueriesFunction = ( ) => Array; export interface BaseMutationOptions< - TData = any, - TVariables = OperationVariables + TData, + TVariables extends OperationVariables, + TContext extends DefaultContext, + TCache extends ApolloCache, > { variables?: TVariables; optimisticResponse?: TData | ((vars: TVariables) => TData); refetchQueries?: Array | RefetchQueriesFunction; awaitRefetchQueries?: boolean; errorPolicy?: ErrorPolicy; - update?: MutationUpdaterFn; + update?: MutationUpdaterFunction; reobserveQuery?: ReobserveQueryCallback; client?: ApolloClient; notifyOnNetworkStatusChange?: boolean; - context?: Context; + context?: TContext; onCompleted?: (data: TData) => void; onError?: (error: ApolloError) => void; fetchPolicy?: Extract; @@ -161,16 +165,18 @@ export interface BaseMutationOptions< } export interface MutationFunctionOptions< - TData = any, - TVariables = OperationVariables + TData, + TVariables, + TContext, + TCache extends ApolloCache, > { variables?: TVariables; optimisticResponse?: TData | ((vars: TVariables) => TData); refetchQueries?: Array | RefetchQueriesFunction; awaitRefetchQueries?: boolean; - update?: MutationUpdaterFn; + update?: MutationUpdaterFunction; reobserveQuery?: ReobserveQueryCallback; - context?: Context; + context?: TContext; fetchPolicy?: WatchQueryFetchPolicy; } @@ -184,26 +190,35 @@ export interface MutationResult { export declare type MutationFunction< TData = any, - TVariables = OperationVariables + TVariables = OperationVariables, + TContext = DefaultContext, + TCache extends ApolloCache = ApolloCache, > = ( - options?: MutationFunctionOptions + options?: MutationFunctionOptions ) => Promise>; export interface MutationHookOptions< TData = any, - TVariables = OperationVariables -> extends BaseMutationOptions { + TVariables = OperationVariables, + TContext = DefaultContext, + TCache extends ApolloCache = ApolloCache, +> extends BaseMutationOptions { mutation?: DocumentNode | TypedDocumentNode; } -export interface MutationDataOptions - extends BaseMutationOptions { +export interface MutationDataOptions< + TData, + TVariables extends OperationVariables, + TContext extends DefaultContext, + TCache extends ApolloCache, +> + extends BaseMutationOptions { mutation: DocumentNode | TypedDocumentNode; } -export type MutationTuple = [ +export type MutationTuple> = [ ( - options?: MutationFunctionOptions + options?: MutationFunctionOptions ) => Promise>, MutationResult ]; @@ -226,7 +241,7 @@ export interface BaseSubscriptionOptions< | ((options: BaseSubscriptionOptions) => boolean); client?: ApolloClient; skip?: boolean; - context?: Context; + context?: DefaultContext; onSubscriptionData?: (options: OnSubscriptionDataOptions) => any; onSubscriptionComplete?: () => void; } From 337e2f9cc53d0a74ae3cc5188f78690d20efdf65 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 13 Apr 2021 17:39:49 -0400 Subject: [PATCH 112/380] Bump @apollo/client npm version to 3.4.0-beta.23. --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8f6f5006832..9ef4c798b37 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-beta.22", + "version": "3.4.0-beta.23", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 3c078f9356a..97fad6d6155 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-beta.22", + "version": "3.4.0-beta.23", "description": "A fully-featured caching GraphQL client.", "private": true, "keywords": [ From 4420c4926b364f79ede19c15939996b752d95fe0 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Thu, 29 Apr 2021 14:49:37 -0400 Subject: [PATCH 113/380] Measure bundlesize using dist/core/index.js rather than dist/index.js. --- config/rollup.config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/rollup.config.js b/config/rollup.config.js index f7e94bf3031..8eccbcf95f0 100644 --- a/config/rollup.config.js +++ b/config/rollup.config.js @@ -102,7 +102,7 @@ export default [ ...entryPoints.map(prepareBundle), // Convert the ESM entry point to a single CJS bundle. prepareCJS( - './dist/index.js', + './dist/core/index.js', './dist/apollo-client.cjs.js', ), // Minify that single CJS bundle. From 482f4d822d9538e4fcd3839a4cbeb2a67e415b6c Mon Sep 17 00:00:00 2001 From: Sofian Hnaide Date: Mon, 3 May 2021 17:42:51 -0700 Subject: [PATCH 114/380] add resultCacheMaxSize configuration --- src/cache/inmemory/__mocks__/optimism.ts | 5 + src/cache/inmemory/__tests__/cache.ts | 35 +++++++ src/cache/inmemory/__tests__/readFromStore.ts | 31 +++++++ src/cache/inmemory/inMemoryCache.ts | 64 +++++++------ src/cache/inmemory/readFromStore.ts | 93 ++++++++++--------- 5 files changed, 158 insertions(+), 70 deletions(-) create mode 100644 src/cache/inmemory/__mocks__/optimism.ts diff --git a/src/cache/inmemory/__mocks__/optimism.ts b/src/cache/inmemory/__mocks__/optimism.ts new file mode 100644 index 00000000000..a81787c6703 --- /dev/null +++ b/src/cache/inmemory/__mocks__/optimism.ts @@ -0,0 +1,5 @@ +const optimism = jest.requireActual('optimism'); +module.exports = { + ...optimism, + wrap: jest.fn(optimism.wrap), +}; diff --git a/src/cache/inmemory/__tests__/cache.ts b/src/cache/inmemory/__tests__/cache.ts index a0e00d24b6f..57d3a442a01 100644 --- a/src/cache/inmemory/__tests__/cache.ts +++ b/src/cache/inmemory/__tests__/cache.ts @@ -6,6 +6,9 @@ import { makeReference, Reference, makeVar, TypedDocumentNode, isReference, Docu import { Cache } from '../../../cache'; import { InMemoryCache, InMemoryCacheConfig } from '../inMemoryCache'; +jest.mock('optimism'); +import { wrap } from 'optimism'; + disableFragmentWarnings(); describe('Cache', () => { @@ -1733,6 +1736,38 @@ describe('Cache', () => { }); }); +describe('resultCacheMaxSize', () => { + let wrapSpy: jest.Mock = wrap as jest.Mock; + beforeEach(() => { + wrapSpy.mockClear(); + }); + + it("does not set max size on caches if resultCacheMaxSize is not configured", () => { + new InMemoryCache(); + expect(wrapSpy).toHaveBeenCalled(); + /* + * The first wrap call is for getFragmentQueryDocument which intentionally + * does not have a max set since it's not expected to grow. + */ + wrapSpy.mock.calls.splice(1).forEach(([, { max }]) => { + expect(max).toBeUndefined(); + }) + }); + + it("configures max size on caches when resultCacheMaxSize is set", () => { + const resultCacheMaxSize = 12345; + new InMemoryCache({ resultCacheMaxSize }); + expect(wrapSpy).toHaveBeenCalled(); + /* + * The first wrap call is for getFragmentQueryDocument which intentionally + * does not have a max set since it's not expected to grow. + */ + wrapSpy.mock.calls.splice(1).forEach(([, { max }]) => { + expect(max).toBe(resultCacheMaxSize); + }) + }); +}); + describe("InMemoryCache#broadcastWatches", function () { it("should keep distinct consumers distinct (issue #5733)", function () { const cache = new InMemoryCache(); diff --git a/src/cache/inmemory/__tests__/readFromStore.ts b/src/cache/inmemory/__tests__/readFromStore.ts index 2a25c064df9..234fcabb6a5 100644 --- a/src/cache/inmemory/__tests__/readFromStore.ts +++ b/src/cache/inmemory/__tests__/readFromStore.ts @@ -19,6 +19,37 @@ import { TypedDocumentNode, } from '../../../core'; +jest.mock('optimism'); +import { wrap } from 'optimism'; + +describe('resultCacheMaxSize', () => { + const cache = new InMemoryCache(); + let wrapSpy: jest.Mock = wrap as jest.Mock; + beforeEach(() => { + wrapSpy.mockClear(); + }); + + it("does not set max size on caches if resultCacheMaxSize is not configured", () => { + new StoreReader({ cache }); + expect(wrapSpy).toHaveBeenCalled(); + + wrapSpy.mock.calls.forEach(([, { max }]) => { + expect(max).toBeUndefined(); + }) + }); + + it("configures max size on caches when resultCacheMaxSize is set", () => { + const resultCacheMaxSize = 12345; + new StoreReader({ cache, resultCacheMaxSize }); + expect(wrapSpy).toHaveBeenCalled(); + + wrapSpy.mock.calls.forEach(([, { max }]) => { + expect(max).toBe(resultCacheMaxSize); + }) + }); +}); + + describe('reading from the store', () => { const reader = new StoreReader({ cache: new InMemoryCache(), diff --git a/src/cache/inmemory/inMemoryCache.ts b/src/cache/inmemory/inMemoryCache.ts index 5c2b2a9e2dd..60e93fab167 100644 --- a/src/cache/inmemory/inMemoryCache.ts +++ b/src/cache/inmemory/inMemoryCache.ts @@ -2,7 +2,7 @@ import './fixPolyfills'; import { DocumentNode } from 'graphql'; -import { wrap } from 'optimism'; +import { OptimisticWrapperFunction, wrap } from 'optimism'; import { ApolloCache, BatchOptions } from '../core/cache'; import { Cache } from '../core/types/Cache'; @@ -33,6 +33,7 @@ export interface InMemoryCacheConfig extends ApolloReducerConfig { resultCaching?: boolean; possibleTypes?: PossibleTypesMap; typePolicies?: TypePolicies; + resultCacheMaxSize?: number; } type BroadcastOptions = Pick< @@ -60,6 +61,11 @@ export class InMemoryCache extends ApolloCache { private storeReader: StoreReader; private storeWriter: StoreWriter; + private maybeBroadcastWatch: OptimisticWrapperFunction< + [Cache.WatchOptions, BroadcastOptions?], + any, + [Cache.WatchOptions]>; + // Dynamically imported code can augment existing typePolicies or // possibleTypes by calling cache.policies.addTypePolicies or // cache.policies.addPossibletypes. @@ -99,8 +105,37 @@ export class InMemoryCache extends ApolloCache { this.storeReader = new StoreReader({ cache: this, addTypename: this.addTypename, + resultCacheMaxSize: this.config.resultCacheMaxSize, }), ); + + this.maybeBroadcastWatch = wrap(( + c: Cache.WatchOptions, + options?: BroadcastOptions, + ) => { + return this.broadcastWatch(c, options); + }, { + max: this.config.resultCacheMaxSize, + makeCacheKey: (c: Cache.WatchOptions) => { + // Return a cache key (thus enabling result caching) only if we're + // currently using a data store that can track cache dependencies. + const store = c.optimistic ? this.optimisticData : this.data; + if (supportsResultCaching(store)) { + const { optimistic, rootId, variables } = c; + return store.makeCacheKey( + c.query, + // Different watches can have the same query, optimistic + // status, rootId, and variables, but if their callbacks are + // different, the (identical) result needs to be delivered to + // each distinct callback. The easiest way to achieve that + // separation is to include c.callback in the cache key for + // maybeBroadcastWatch calls. See issue #5733. + c.callback, + JSON.stringify({ optimistic, rootId, variables }), + ); + } + } + }); } public restore(data: NormalizedCacheObject): this { @@ -423,33 +458,6 @@ export class InMemoryCache extends ApolloCache { } } - private maybeBroadcastWatch = wrap(( - c: Cache.WatchOptions, - options?: BroadcastOptions, - ) => { - return this.broadcastWatch(c, options); - }, { - makeCacheKey: (c: Cache.WatchOptions) => { - // Return a cache key (thus enabling result caching) only if we're - // currently using a data store that can track cache dependencies. - const store = c.optimistic ? this.optimisticData : this.data; - if (supportsResultCaching(store)) { - const { optimistic, rootId, variables } = c; - return store.makeCacheKey( - c.query, - // Different watches can have the same query, optimistic - // status, rootId, and variables, but if their callbacks are - // different, the (identical) result needs to be delivered to - // each distinct callback. The easiest way to achieve that - // separation is to include c.callback in the cache key for - // maybeBroadcastWatch calls. See issue #5733. - c.callback, - JSON.stringify({ optimistic, rootId, variables }), - ); - } - } - }); - // This method is wrapped by maybeBroadcastWatch, which is called by // broadcastWatches, so that we compute and broadcast results only when // the data that would be broadcast might have changed. It would be diff --git a/src/cache/inmemory/readFromStore.ts b/src/cache/inmemory/readFromStore.ts index a463ce8af34..f9ed078573b 100644 --- a/src/cache/inmemory/readFromStore.ts +++ b/src/cache/inmemory/readFromStore.ts @@ -80,11 +80,62 @@ type ExecSubSelectedArrayOptions = { export interface StoreReaderConfig { cache: InMemoryCache, addTypename?: boolean; + resultCacheMaxSize?: number; } export class StoreReader { + // cached version of executeSelectionset + private executeSelectionSet: OptimisticWrapperFunction< + [ExecSelectionSetOptions], // Actual arguments tuple type. + ExecResult, // Actual return type. + // Arguments type after keyArgs translation. + [SelectionSetNode, StoreObject | Reference, ReadMergeModifyContext]>; + + // cached version of executeSubSelectedArray + private executeSubSelectedArray: OptimisticWrapperFunction< + [ExecSubSelectedArrayOptions], + ExecResult, + [ExecSubSelectedArrayOptions]>; + constructor(private config: StoreReaderConfig) { this.config = { addTypename: true, ...config }; + + this.executeSelectionSet = wrap(options => this.execSelectionSetImpl(options), { + keyArgs(options) { + return [ + options.selectionSet, + options.objectOrReference, + options.context, + ]; + }, + max: this.config.resultCacheMaxSize, + // Note that the parameters of makeCacheKey are determined by the + // array returned by keyArgs. + makeCacheKey(selectionSet, parent, context) { + if (supportsResultCaching(context.store)) { + return context.store.makeCacheKey( + selectionSet, + isReference(parent) ? parent.__ref : parent, + context.varString, + ); + } + } + }); + + this.executeSubSelectedArray = wrap((options: ExecSubSelectedArrayOptions) => { + return this.execSubSelectedArrayImpl(options); + }, { + max: this.config.resultCacheMaxSize, + makeCacheKey({ field, array, context }) { + if (supportsResultCaching(context.store)) { + return context.store.makeCacheKey( + field, + array, + context.varString, + ); + } + } + }); } /** @@ -152,33 +203,6 @@ export class StoreReader { return false; } - // Cached version of execSelectionSetImpl. - private executeSelectionSet: OptimisticWrapperFunction< - [ExecSelectionSetOptions], // Actual arguments tuple type. - ExecResult, // Actual return type. - // Arguments type after keyArgs translation. - [SelectionSetNode, StoreObject | Reference, ReadMergeModifyContext] - > = wrap(options => this.execSelectionSetImpl(options), { - keyArgs(options) { - return [ - options.selectionSet, - options.objectOrReference, - options.context, - ]; - }, - // Note that the parameters of makeCacheKey are determined by the - // array returned by keyArgs. - makeCacheKey(selectionSet, parent, context) { - if (supportsResultCaching(context.store)) { - return context.store.makeCacheKey( - selectionSet, - isReference(parent) ? parent.__ref : parent, - context.varString, - ); - } - } - }); - // Uncached version of executeSelectionSet. private execSelectionSetImpl({ selectionSet, @@ -330,21 +354,6 @@ export class StoreReader { private knownResults = new WeakMap, SelectionSetNode>(); - // Cached version of execSubSelectedArrayImpl. - private executeSubSelectedArray = wrap((options: ExecSubSelectedArrayOptions) => { - return this.execSubSelectedArrayImpl(options); - }, { - makeCacheKey({ field, array, context }) { - if (supportsResultCaching(context.store)) { - return context.store.makeCacheKey( - field, - array, - context.varString, - ); - } - } - }); - // Uncached version of executeSubSelectedArray. private execSubSelectedArrayImpl({ field, From 1a6781a76b43917052873725ba004ebd5196ce89 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Wed, 5 May 2021 10:04:05 -0400 Subject: [PATCH 115/380] Stylistic tweaks. --- src/cache/inmemory/__tests__/cache.ts | 54 +++++++++++++-------------- 1 file changed, 26 insertions(+), 28 deletions(-) diff --git a/src/cache/inmemory/__tests__/cache.ts b/src/cache/inmemory/__tests__/cache.ts index 57d3a442a01..08fc4766c44 100644 --- a/src/cache/inmemory/__tests__/cache.ts +++ b/src/cache/inmemory/__tests__/cache.ts @@ -1737,35 +1737,33 @@ describe('Cache', () => { }); describe('resultCacheMaxSize', () => { - let wrapSpy: jest.Mock = wrap as jest.Mock; - beforeEach(() => { - wrapSpy.mockClear(); - }); - - it("does not set max size on caches if resultCacheMaxSize is not configured", () => { - new InMemoryCache(); - expect(wrapSpy).toHaveBeenCalled(); - /* - * The first wrap call is for getFragmentQueryDocument which intentionally - * does not have a max set since it's not expected to grow. - */ - wrapSpy.mock.calls.splice(1).forEach(([, { max }]) => { - expect(max).toBeUndefined(); - }) - }); + let wrapSpy: jest.Mock = wrap as jest.Mock; + beforeEach(() => { + wrapSpy.mockClear(); + }); - it("configures max size on caches when resultCacheMaxSize is set", () => { - const resultCacheMaxSize = 12345; - new InMemoryCache({ resultCacheMaxSize }); - expect(wrapSpy).toHaveBeenCalled(); - /* - * The first wrap call is for getFragmentQueryDocument which intentionally - * does not have a max set since it's not expected to grow. - */ - wrapSpy.mock.calls.splice(1).forEach(([, { max }]) => { - expect(max).toBe(resultCacheMaxSize); - }) - }); + it("does not set max size on caches if resultCacheMaxSize is not configured", () => { + new InMemoryCache(); + expect(wrapSpy).toHaveBeenCalled(); + + // The first wrap call is for getFragmentQueryDocument which intentionally + // does not have a max set since it's not expected to grow. + wrapSpy.mock.calls.splice(1).forEach(([, { max }]) => { + expect(max).toBeUndefined(); + }) + }); + + it("configures max size on caches when resultCacheMaxSize is set", () => { + const resultCacheMaxSize = 12345; + new InMemoryCache({ resultCacheMaxSize }); + expect(wrapSpy).toHaveBeenCalled(); + + // The first wrap call is for getFragmentQueryDocument which intentionally + // does not have a max set since it's not expected to grow. + wrapSpy.mock.calls.splice(1).forEach(([, { max }]) => { + expect(max).toBe(resultCacheMaxSize); + }) + }); }); describe("InMemoryCache#broadcastWatches", function () { From 526c940f3c1298bf0a9a3c3764f9dbe01d4996ee Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Wed, 5 May 2021 10:07:47 -0400 Subject: [PATCH 116/380] Make cache.reset() also dump all StoreReader/EntityStore data. --- src/cache/inmemory/entityStore.ts | 11 ----------- src/cache/inmemory/inMemoryCache.ts | 7 +++++-- 2 files changed, 5 insertions(+), 13 deletions(-) diff --git a/src/cache/inmemory/entityStore.ts b/src/cache/inmemory/entityStore.ts index 894069fe75a..6dc3124df19 100644 --- a/src/cache/inmemory/entityStore.ts +++ b/src/cache/inmemory/entityStore.ts @@ -341,17 +341,6 @@ export abstract class EntityStore implements NormalizedCache { } } - // Remove every Layer, leaving behind only the Root and the Stump. - public prune(): EntityStore { - if (this instanceof Layer) { - const parent = this.removeLayer(this.id); - if (parent !== this) { - return parent.prune(); - } - } - return this; - } - public abstract getStorage( idOrObj: string | StoreObject, ...storeFieldNames: (string | number)[] diff --git a/src/cache/inmemory/inMemoryCache.ts b/src/cache/inmemory/inMemoryCache.ts index 60e93fab167..dc3cc2c762d 100644 --- a/src/cache/inmemory/inMemoryCache.ts +++ b/src/cache/inmemory/inMemoryCache.ts @@ -85,6 +85,10 @@ export class InMemoryCache extends ApolloCache { typePolicies: this.config.typePolicies, }); + this.init(); + } + + private init() { // Passing { resultCaching: false } in the InMemoryCache constructor options // will completely disable dependency tracking, which will improve memory // usage but worsen the performance of repeated reads. @@ -320,8 +324,7 @@ export class InMemoryCache extends ApolloCache { } public reset(): Promise { - this.optimisticData = this.optimisticData.prune(); - this.data.clear(); + this.init(); this.broadcastWatches(); return Promise.resolve(); } From dacbbe0b912796497c8a3b0d20c3bd6777978ed2 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Wed, 5 May 2021 12:44:03 -0400 Subject: [PATCH 117/380] Mention PR #8107 in CHANGELOG.md. --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d74dff42da..69ffa821a71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,9 @@ - Pass `variables` and `context` to a mutation's `update` function
[@jcreighton](https://github.com/jcreighton) in [#7902](https://github.com/apollographql/apollo-client/pull/7902) +- A `resultCacheMaxSize` option may be passed to the `InMemoryCache` constructor to limit the number of result objects that will be retained in memory (to speed up repeated reads), and calling `cache.reset()` now releases all such memory.
+ [@SofianHn](https://github.com/SofianHn) in [#8701](https://github.com/apollographql/apollo-client/pull/8701) + ### Documentation TBD From 09d94cb8f902614da6cbb7302caf2b1a520827cc Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Wed, 5 May 2021 12:52:53 -0400 Subject: [PATCH 118/380] Bump @apollo/client npm version to 3.4.0-beta.24. --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index deb4fd67d35..4bdc18d4b14 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-beta.23", + "version": "3.4.0-beta.24", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 4ff2a99fe93..2e7072dbfb3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-beta.23", + "version": "3.4.0-beta.24", "description": "A fully-featured caching GraphQL client.", "private": true, "keywords": [ From b4428284ef68bca62980678445c5ec56088dedd6 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Fri, 23 Apr 2021 18:26:36 -0400 Subject: [PATCH 119/380] Make canon.pass return shallow copy of given object. That is, rather than a Pass wrapper object, which would be very confusing if it was ever accidentally exposed to user code. --- src/cache/inmemory/object-canon.ts | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/src/cache/inmemory/object-canon.ts b/src/cache/inmemory/object-canon.ts index 63b4ad2f2de..b64990937d5 100644 --- a/src/cache/inmemory/object-canon.ts +++ b/src/cache/inmemory/object-canon.ts @@ -2,14 +2,19 @@ import { Trie } from "@wry/trie"; import { canUseWeakMap } from "../../utilities"; import { objToStr } from "./helpers"; -class Pass { - constructor(public readonly value: T) {} -} - function isObjectOrArray(value: any): boolean { return !!value && typeof value === "object"; } +function shallowCopy(value: T): T { + if (isObjectOrArray(value)) { + return Array.isArray(value) + ? value.slice(0) as any as T + : { __proto__: Object.getPrototypeOf(value), ...value }; + } + return value; +} + // When programmers talk about the "canonical form" of an object, they // usually have the following meaning in mind, which I've copied from // https://en.wiktionary.org/wiki/canonical_form: @@ -79,20 +84,23 @@ export class ObjectCanon { // Make the ObjectCanon assume this value has already been // canonicalized. - public pass(value: T): T extends object ? Pass : T; + private passes = new WeakMap(); + public pass(value: T): T; public pass(value: any) { - return isObjectOrArray(value) ? new Pass(value) : value; + if (isObjectOrArray(value)) { + const copy = shallowCopy(value); + this.passes.set(copy, value); + return copy; + } + return value; } // Returns the canonical version of value. public admit(value: T): T; public admit(value: any) { if (isObjectOrArray(value)) { - // If value is a Pass object returned by canon.pass, unwrap it - // as-is, without canonicalizing its value. - if (value instanceof Pass) { - return value.value; - } + const original = this.passes.get(value); + if (original) return original; switch (objToStr.call(value)) { case "[object Array]": { From 67d2ce99a9e572d157d8f466afe0bb4ba3e53d81 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Fri, 7 May 2021 16:00:51 -0400 Subject: [PATCH 120/380] Allow opting out of cache result canonization. Although cache result canonization (#7439) is algorithmically efficient (linear time for tree-shaped results), it does have a computational cost for large result trees, so you might want to disable canonization for exceptionally big queries, if you decide the future performance benefits of result canonization are not worth the initial cost. Fortunately, this implementation allows non-canonical results to be exchaged later for canonical results without recomputing the underlying results, but merely by canonizing the previous results. Of course, this reuse happens only when the cache has not been modified in the meantime (the usual result caching/invalidation story, nothing new), in which case the StoreReader does its best to reuse as many subtrees as it can, if it can't reuse the entire result tree. --- package-lock.json | 16 ++- package.json | 2 +- src/cache/core/cache.ts | 7 +- src/cache/core/types/Cache.ts | 1 + src/cache/core/types/DataProxy.ts | 12 ++ src/cache/inmemory/__tests__/readFromStore.ts | 114 +++++++++++++++++ src/cache/inmemory/inMemoryCache.ts | 8 +- src/cache/inmemory/object-canon.ts | 4 + src/cache/inmemory/readFromStore.ts | 118 ++++++++++++++---- src/cache/inmemory/types.ts | 1 + src/core/QueryInfo.ts | 10 +- src/core/watchQueryOptions.ts | 7 ++ 12 files changed, 263 insertions(+), 37 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4bdc18d4b14..a0f7b360477 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,16 @@ "zen-observable": "^0.8.14" }, "dependencies": { + "optimism": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/optimism/-/optimism-0.15.0.tgz", + "integrity": "sha512-KLKl3Kb7hH++s9ewRcBhmfpXgXF0xQ+JZ3xQFuPjnoT6ib2TDmYyVkKENmGxivsN2G3VRxpXuauCkB4GYOhtPw==", + "dev": true, + "requires": { + "@wry/context": "^0.6.0", + "@wry/trie": "^0.3.0" + } + }, "tslib": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", @@ -10904,9 +10914,9 @@ } }, "optimism": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/optimism/-/optimism-0.15.0.tgz", - "integrity": "sha512-KLKl3Kb7hH++s9ewRcBhmfpXgXF0xQ+JZ3xQFuPjnoT6ib2TDmYyVkKENmGxivsN2G3VRxpXuauCkB4GYOhtPw==", + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/optimism/-/optimism-0.16.0.tgz", + "integrity": "sha512-p+JNvSj7tsCCCiwb5jFvfNZZL8YMy1G7S8hymB5dwupV7rpu7ftRkMw2yvNY9zfMk0oH/kIGmkPSXaeBNjtWYQ==", "requires": { "@wry/context": "^0.6.0", "@wry/trie": "^0.3.0" diff --git a/package.json b/package.json index 2e7072dbfb3..a388a5e00c1 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,7 @@ "fast-json-stable-stringify": "^2.0.0", "graphql-tag": "^2.12.3", "hoist-non-react-statics": "^3.3.2", - "optimism": "^0.15.0", + "optimism": "^0.16.0", "prop-types": "^15.7.2", "symbol-observable": "^2.0.0", "ts-invariant": "^0.7.3", diff --git a/src/cache/core/cache.ts b/src/cache/core/cache.ts index 7b9c55e8886..4c188fe83d9 100644 --- a/src/cache/core/cache.ts +++ b/src/cache/core/cache.ts @@ -142,10 +142,8 @@ export abstract class ApolloCache implements DataProxy { optimistic = !!options.optimistic, ): QueryType | null { return this.read({ + ...options, rootId: options.id || 'ROOT_QUERY', - query: options.query, - variables: options.variables, - returnPartialData: options.returnPartialData, optimistic, }); } @@ -159,10 +157,9 @@ export abstract class ApolloCache implements DataProxy { optimistic = !!options.optimistic, ): FragmentType | null { return this.read({ + ...options, query: this.getFragmentDoc(options.fragment, options.fragmentName), - variables: options.variables, rootId: options.id, - returnPartialData: options.returnPartialData, optimistic, }); } diff --git a/src/cache/core/types/Cache.ts b/src/cache/core/types/Cache.ts index 4eacf9e22f3..98b0fb00f48 100644 --- a/src/cache/core/types/Cache.ts +++ b/src/cache/core/types/Cache.ts @@ -10,6 +10,7 @@ export namespace Cache { previousResult?: any; optimistic: boolean; returnPartialData?: boolean; + canonizeResults?: boolean; } export interface WriteOptions diff --git a/src/cache/core/types/DataProxy.ts b/src/cache/core/types/DataProxy.ts index a6e008416cb..8e04d01a5df 100644 --- a/src/cache/core/types/DataProxy.ts +++ b/src/cache/core/types/DataProxy.ts @@ -67,6 +67,12 @@ export namespace DataProxy { * readQuery method can be omitted. Defaults to false. */ optimistic?: boolean; + /** + * Whether to canonize cache results before returning them. Canonization + * takes some extra time, but it speeds up future deep equality comparisons. + * Defaults to true. + */ + canonizeResults?: boolean; } export interface ReadFragmentOptions @@ -82,6 +88,12 @@ export namespace DataProxy { * readQuery method can be omitted. Defaults to false. */ optimistic?: boolean; + /** + * Whether to canonize cache results before returning them. Canonization + * takes some extra time, but it speeds up future deep equality comparisons. + * Defaults to true. + */ + canonizeResults?: boolean; } export interface WriteOptions { diff --git a/src/cache/inmemory/__tests__/readFromStore.ts b/src/cache/inmemory/__tests__/readFromStore.ts index 234fcabb6a5..3df74615432 100644 --- a/src/cache/inmemory/__tests__/readFromStore.ts +++ b/src/cache/inmemory/__tests__/readFromStore.ts @@ -2010,4 +2010,118 @@ describe('reading from the store', () => { expect(result1.abc).toBe(abc); expect(result2.abc).toBe(abc); }); + + it("readQuery can opt out of canonization", function () { + let count = 0; + + const cache = new InMemoryCache({ + typePolicies: { + Query: { + fields: { + count() { + return count++; + }, + }, + }, + }, + }); + + const canon = cache["storeReader"]["canon"]; + + const query = gql` + query { + count + } + `; + + function readQuery(canonizeResults: boolean) { + return cache.readQuery<{ + count: number; + }>({ + query, + canonizeResults, + }); + } + + const nonCanonicalQueryResult0 = readQuery(false); + expect(canon.isCanonical(nonCanonicalQueryResult0)).toBe(false); + expect(nonCanonicalQueryResult0).toEqual({ count: 0 }); + + const canonicalQueryResult0 = readQuery(true); + expect(canon.isCanonical(canonicalQueryResult0)).toBe(true); + // The preservation of { count: 0 } proves the result didn't have to be + // recomputed, but merely canonized. + expect(canonicalQueryResult0).toEqual({ count: 0 }); + + cache.evict({ + fieldName: "count", + }); + + const canonicalQueryResult1 = readQuery(true); + expect(canon.isCanonical(canonicalQueryResult1)).toBe(true); + expect(canonicalQueryResult1).toEqual({ count: 1 }); + + const nonCanonicalQueryResult1 = readQuery(false); + // Since we already read a canonical result, we were able to reuse it when + // reading the non-canonical result. + expect(nonCanonicalQueryResult1).toBe(canonicalQueryResult1); + }); + + it("readFragment can opt out of canonization", function () { + let count = 0; + + const cache = new InMemoryCache({ + typePolicies: { + Query: { + fields: { + count() { + return count++; + }, + }, + }, + }, + }); + + const canon = cache["storeReader"]["canon"]; + + const fragment = gql` + fragment CountFragment on Query { + count + } + `; + + function readFragment(canonizeResults: boolean) { + return cache.readFragment<{ + count: number; + }>({ + id: "ROOT_QUERY", + fragment, + canonizeResults, + }); + } + + const canonicalFragmentResult1 = readFragment(true); + expect(canon.isCanonical(canonicalFragmentResult1)).toBe(true); + expect(canonicalFragmentResult1).toEqual({ count: 0 }); + + const nonCanonicalFragmentResult1 = readFragment(false); + // Since we already read a canonical result, we were able to reuse it when + // reading the non-canonical result. + expect(nonCanonicalFragmentResult1).toBe(canonicalFragmentResult1); + + cache.evict({ + fieldName: "count", + }); + + const nonCanonicalFragmentResult2 = readFragment(false); + expect(readFragment(false)).toBe(nonCanonicalFragmentResult2); + expect(canon.isCanonical(nonCanonicalFragmentResult2)).toBe(false); + expect(nonCanonicalFragmentResult2).toEqual({ count: 1 }); + expect(readFragment(false)).toBe(nonCanonicalFragmentResult2); + + const canonicalFragmentResult2 = readFragment(true); + expect(readFragment(true)).toBe(canonicalFragmentResult2); + expect(canon.isCanonical(canonicalFragmentResult2)).toBe(true); + expect(canonicalFragmentResult2).toEqual({ count: 1 }); + }); }); diff --git a/src/cache/inmemory/inMemoryCache.ts b/src/cache/inmemory/inMemoryCache.ts index dc3cc2c762d..fe7eb284bdd 100644 --- a/src/cache/inmemory/inMemoryCache.ts +++ b/src/cache/inmemory/inMemoryCache.ts @@ -164,10 +164,8 @@ export class InMemoryCache extends ApolloCache { } = options; try { return this.storeReader.diffQueryAgainstStore({ + ...options, store: options.optimistic ? this.optimisticData : this.data, - query: options.query, - variables: options.variables, - rootId: options.rootId, config: this.config, returnPartialData, }).result || null; @@ -223,11 +221,9 @@ export class InMemoryCache extends ApolloCache { public diff(options: Cache.DiffOptions): Cache.DiffResult { return this.storeReader.diffQueryAgainstStore({ + ...options, store: options.optimistic ? this.optimisticData : this.data, rootId: options.id || "ROOT_QUERY", - query: options.query, - variables: options.variables, - returnPartialData: options.returnPartialData, config: this.config, }); } diff --git a/src/cache/inmemory/object-canon.ts b/src/cache/inmemory/object-canon.ts index b64990937d5..0be454bc0d7 100644 --- a/src/cache/inmemory/object-canon.ts +++ b/src/cache/inmemory/object-canon.ts @@ -82,6 +82,10 @@ export class ObjectCanon { keys?: SortedKeysInfo; }>(canUseWeakMap); + public isCanonical(value: any): boolean { + return isObjectOrArray(value) && this.known.has(value); + } + // Make the ObjectCanon assume this value has already been // canonicalized. private passes = new WeakMap(); diff --git a/src/cache/inmemory/readFromStore.ts b/src/cache/inmemory/readFromStore.ts index f9ed078573b..0ed395dddcb 100644 --- a/src/cache/inmemory/readFromStore.ts +++ b/src/cache/inmemory/readFromStore.ts @@ -23,6 +23,7 @@ import { getQueryDefinition, mergeDeepArray, getFragmentFromSelection, + maybeDeepFreeze, } from '../../utilities'; import { Cache } from '../core/types/Cache'; import { @@ -42,6 +43,7 @@ export type VariableMap = { [name: string]: any }; interface ReadContext extends ReadMergeModifyContext { query: DocumentNode; policies: Policies; + canonizeResults: boolean; fragmentMap: FragmentMap; path: (string | number)[]; clientOnly: boolean; @@ -83,13 +85,34 @@ export interface StoreReaderConfig { resultCacheMaxSize?: number; } +// Arguments type after keyArgs translation. +type ExecSelectionSetKeyArgs = [ + SelectionSetNode, + StoreObject | Reference, + ReadMergeModifyContext, + boolean, +]; + +function execSelectionSetKeyArgs( + options: ExecSelectionSetOptions, +): ExecSelectionSetKeyArgs { + return [ + options.selectionSet, + options.objectOrReference, + options.context, + // We split out this property so we can pass different values + // independently without modifying options.context itself. + options.context.canonizeResults, + ]; +} + export class StoreReader { // cached version of executeSelectionset private executeSelectionSet: OptimisticWrapperFunction< [ExecSelectionSetOptions], // Actual arguments tuple type. ExecResult, // Actual return type. - // Arguments type after keyArgs translation. - [SelectionSetNode, StoreObject | Reference, ReadMergeModifyContext]>; + ExecSelectionSetKeyArgs + >; // cached version of executeSubSelectedArray private executeSubSelectedArray: OptimisticWrapperFunction< @@ -97,32 +120,65 @@ export class StoreReader { ExecResult, [ExecSubSelectedArrayOptions]>; - constructor(private config: StoreReaderConfig) { - this.config = { addTypename: true, ...config }; + private config: { + cache: InMemoryCache, + addTypename: boolean; + resultCacheMaxSize?: number; + }; - this.executeSelectionSet = wrap(options => this.execSelectionSetImpl(options), { - keyArgs(options) { - return [ - options.selectionSet, - options.objectOrReference, - options.context, - ]; - }, + constructor(config: StoreReaderConfig) { + this.config = { + ...config, + addTypename: config.addTypename !== false, + }; + + this.executeSelectionSet = wrap(options => { + const { canonizeResults } = options.context; + + const peekArgs = execSelectionSetKeyArgs(options); + + // Negate this boolean option so we can find out if we've already read + // this result using the other boolean value. + peekArgs[3] = !canonizeResults; + + const other = this.executeSelectionSet.peek(...peekArgs); + + if (other) { + if (canonizeResults) { + return { + ...other, + // If we previously read this result without canonizing it, we can + // reuse that result simply by canonizing it now. + result: this.canon.admit(other.result), + }; + } + // If we previously read this result with canonization enabled, we can + // return that canonized result as-is. + return other; + } + + // Finally, if we didn't find any useful previous results, run the real + // execSelectionSetImpl method with the given options. + return this.execSelectionSetImpl(options); + + }, { max: this.config.resultCacheMaxSize, + keyArgs: execSelectionSetKeyArgs, // Note that the parameters of makeCacheKey are determined by the // array returned by keyArgs. - makeCacheKey(selectionSet, parent, context) { + makeCacheKey(selectionSet, parent, context, canonizeResults) { if (supportsResultCaching(context.store)) { return context.store.makeCacheKey( selectionSet, isReference(parent) ? parent.__ref : parent, context.varString, + canonizeResults, ); } } }); - this.executeSubSelectedArray = wrap((options: ExecSubSelectedArrayOptions) => { + this.executeSubSelectedArray = wrap((options: ExecSubSelectedArrayOptions) => { return this.execSubSelectedArrayImpl(options); }, { max: this.config.resultCacheMaxSize, @@ -151,12 +207,13 @@ export class StoreReader { rootId = 'ROOT_QUERY', variables, returnPartialData = true, + canonizeResults = true, }: DiffQueryAgainstStoreOptions): Cache.DiffResult { const policies = this.config.cache.policies; variables = { ...getDefaultValues(getQueryDefinition(query)), - ...variables, + ...variables!, }; const execResult = this.executeSelectionSet({ @@ -168,6 +225,7 @@ export class StoreReader { policies, variables, varString: JSON.stringify(variables), + canonizeResults, fragmentMap: createFragmentMap(getFragmentDefinitions(query)), path: [], clientOnly: false, @@ -195,7 +253,15 @@ export class StoreReader { ): boolean { if (supportsResultCaching(context.store) && this.knownResults.get(result) === selectionSet) { - const latest = this.executeSelectionSet.peek(selectionSet, parent, context); + const latest = this.executeSelectionSet.peek( + selectionSet, + parent, + context, + // If result is canonical, then it could only have been previously + // cached by the canonizing version of executeSelectionSet, so we can + // avoid checking both possibilities here. + this.canon.isCanonical(result), + ); if (latest && result === latest.result) { return true; } @@ -306,7 +372,9 @@ export class StoreReader { // as a scalar value. To keep this.canon from canonicalizing // this value, we use this.canon.pass to wrap fieldValue in a // Pass object that this.canon.admit will later unwrap as-is. - fieldValue = this.canon.pass(fieldValue); + if (context.canonizeResults) { + fieldValue = this.canon.pass(fieldValue); + } } else if (fieldValue != null) { // In this case, because we know the field has a selection set, @@ -341,7 +409,12 @@ export class StoreReader { // Perform a single merge at the end so that we can avoid making more // defensive shallow copies than necessary. - finalResult.result = this.canon.admit(mergeDeepArray(objectsToMerge)); + const merged = mergeDeepArray(objectsToMerge); + finalResult.result = context.canonizeResults + ? this.canon.admit(merged) + // Since this.canon is normally responsible for freezing results (only in + // development), freeze them manually if canonization is disabled. + : maybeDeepFreeze(merged); // Store this result with its selection set so that we can quickly // recognize it again in the StoreReader#isFresh method. @@ -377,7 +450,7 @@ export class StoreReader { array = array.filter(context.store.canRead); } - array = this.canon.admit(array.map((item, i) => { + array = array.map((item, i) => { // null value in array if (item === null) { return null; @@ -410,9 +483,12 @@ export class StoreReader { invariant(context.path.pop() === i); return item; - })); + }); - return { result: array, missing }; + return { + result: context.canonizeResults ? this.canon.admit(array) : array, + missing, + }; } } diff --git a/src/cache/inmemory/types.ts b/src/cache/inmemory/types.ts index 13959a0c3c2..ba269bd876a 100644 --- a/src/cache/inmemory/types.ts +++ b/src/cache/inmemory/types.ts @@ -103,6 +103,7 @@ export type ReadQueryOptions = { query: DocumentNode; variables?: Object; previousResult?: any; + canonizeResults?: boolean; rootId?: string; config?: ApolloReducerConfig; }; diff --git a/src/core/QueryInfo.ts b/src/core/QueryInfo.ts index 020a51a9fd4..299cba34414 100644 --- a/src/core/QueryInfo.ts +++ b/src/core/QueryInfo.ts @@ -168,6 +168,7 @@ export class QueryInfo { variables, returnPartialData: true, optimistic: true, + canonizeResults: this.canonize(), }); } @@ -247,7 +248,7 @@ export class QueryInfo { // Cancel the pending notify timeout this.reset(); - + this.cancel(); // Revert back to the no-op version of cancel inherited from // QueryInfo.prototype. @@ -281,10 +282,16 @@ export class QueryInfo { optimistic: true, watcher: this, callback: diff => this.setDiff(diff), + canonizeResults: this.canonize(), }); } } + private canonize() { + const oq = this.observableQuery; + return !oq || oq.options.canonizeResults !== false; + } + private lastWrite?: { result: FetchResult; variables: WatchQueryOptions["variables"]; @@ -396,6 +403,7 @@ export class QueryInfo { variables: options.variables, returnPartialData: true, optimistic: true, + canonizeResults: this.canonize(), }); // In case the QueryManager stops this QueryInfo before its diff --git a/src/core/watchQueryOptions.ts b/src/core/watchQueryOptions.ts index e819295eaa1..beca5cce0cc 100644 --- a/src/core/watchQueryOptions.ts +++ b/src/core/watchQueryOptions.ts @@ -96,6 +96,13 @@ export interface QueryOptions { * Apollo Client `QueryManager` (due to a cache miss). */ partialRefetch?: boolean; + + /** + * Whether to canonize cache results before returning them. Canonization + * takes some extra time, but it speeds up future deep equality comparisons. + * Defaults to true. + */ + canonizeResults?: boolean; } /** From 1b54bce3acf5e8cd7e45fbb985876f95bbf9906c Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Mon, 10 May 2021 14:08:28 -0400 Subject: [PATCH 121/380] Invalidate QueryInfo.getDiff cache if any options change. Previously, queryInfo.diff would be reused as long as the variables hadn't changed, but changes in other options (like canonizeResults) should also trigger recomputation of cache.diff. --- src/core/QueryInfo.ts | 86 +++++++++++++++++++++++++++---------------- 1 file changed, 54 insertions(+), 32 deletions(-) diff --git a/src/core/QueryInfo.ts b/src/core/QueryInfo.ts index 299cba34414..c3438768127 100644 --- a/src/core/QueryInfo.ts +++ b/src/core/QueryInfo.ts @@ -117,7 +117,7 @@ export class QueryInfo { } if (!equal(query.variables, this.variables)) { - this.diff = null; + this.lastDiff = void 0; } Object.assign(this, { @@ -143,17 +143,17 @@ export class QueryInfo { private notifyTimeout?: ReturnType; - private diff: Cache.DiffResult | null = null; - reset() { cancelNotifyTimeout(this); - this.diff = null; + this.lastDiff = void 0; this.dirty = false; } getDiff(variables = this.variables): Cache.DiffResult { - if (this.diff && equal(variables, this.variables)) { - return this.diff; + const options = this.getDiffOptions(variables); + + if (this.lastDiff && equal(options, this.lastDiff.options)) { + return this.lastDiff.diff; } this.updateWatch(this.variables = variables); @@ -163,18 +163,39 @@ export class QueryInfo { return { complete: false }; } - return this.diff = this.cache.diff({ + const diff = this.cache.diff(options); + this.updateLastDiff(diff, options); + return diff; + } + + private lastDiff?: { + diff: Cache.DiffResult, + options: Cache.DiffOptions, + }; + + private updateLastDiff( + diff: Cache.DiffResult | null, + options?: Cache.DiffOptions, + ) { + this.lastDiff = diff ? { + diff, + options: options || this.getDiffOptions(), + } : void 0; + } + + private getDiffOptions(variables = this.variables): Cache.DiffOptions { + return { query: this.document!, variables, returnPartialData: true, optimistic: true, canonizeResults: this.canonize(), - }); + }; } setDiff(diff: Cache.DiffResult | null) { - const oldDiff = this.diff; - this.diff = diff; + const oldDiff = this.lastDiff && this.lastDiff.diff; + this.updateLastDiff(diff); if (!this.dirty && (diff && diff.result) !== (oldDiff && oldDiff.result)) { this.dirty = true; @@ -252,7 +273,7 @@ export class QueryInfo { this.cancel(); // Revert back to the no-op version of cancel inherited from // QueryInfo.prototype. - delete this.cancel; + this.cancel = QueryInfo.prototype.cancel; this.subscriptions.forEach(sub => sub.unsubscribe()); @@ -272,18 +293,20 @@ export class QueryInfo { if (oq && oq.options.fetchPolicy === "no-cache") { return; } + + const watchOptions: Cache.WatchOptions = { + // Although this.getDiffOptions returns Cache.DiffOptions instead of + // Cache.WatchOptions, all the overlapping options should be the same, so + // we can reuse getDiffOptions here, for consistency. + ...this.getDiffOptions(variables), + watcher: this, + callback: diff => this.setDiff(diff), + }; + if (!this.lastWatch || - this.lastWatch.query !== this.document || - !equal(variables, this.lastWatch.variables)) { + !equal(watchOptions, this.lastWatch)) { this.cancel(); - this.cancel = this.cache.watch(this.lastWatch = { - query: this.document!, - variables, - optimistic: true, - watcher: this, - callback: diff => this.setDiff(diff), - canonizeResults: this.canonize(), - }); + this.cancel = this.cache.watch(this.lastWatch = watchOptions); } } @@ -333,7 +356,10 @@ export class QueryInfo { this.reset(); if (options.fetchPolicy === 'no-cache') { - this.diff = { result: result.data, complete: true }; + this.updateLastDiff( + { result: result.data, complete: true }, + this.getDiffOptions(options.variables), + ); } else if (!this.stopped && cacheWriteBehavior !== CacheWriteBehavior.FORBID) { if (shouldWriteResult(result, options.errorPolicy)) { @@ -388,23 +414,19 @@ export class QueryInfo { // mitigate the clobbering somehow, but that would make this // particular cache write even less important, and thus // skipping it would be even safer than it is today. - if (this.diff && this.diff.complete) { + if (this.lastDiff && + this.lastDiff.diff.complete) { // Reuse data from the last good (complete) diff that we // received, when possible. - result.data = this.diff.result; + result.data = this.lastDiff.diff.result; return; } // If the previous this.diff was incomplete, fall through to // re-reading the latest data with cache.diff, below. } - const diff = cache.diff({ - query: this.document!, - variables: options.variables, - returnPartialData: true, - optimistic: true, - canonizeResults: this.canonize(), - }); + const diffOptions = this.getDiffOptions(options.variables); + const diff = cache.diff(diffOptions); // In case the QueryManager stops this QueryInfo before its // results are delivered, it's important to avoid restarting the @@ -420,7 +442,7 @@ export class QueryInfo { // result from the cache, rather than the raw network result. // Set without setDiff to avoid triggering a notify call, since // we have other ways of notifying for this result. - this.diff = diff; + this.updateLastDiff(diff, diffOptions); if (diff.complete) { result.data = diff.result; } From b63cc0257d959f1ef6e9ff2d64a8abeeccfb2303 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Mon, 10 May 2021 14:14:51 -0400 Subject: [PATCH 122/380] Test that canonizeResults works with useQuery. --- src/react/hooks/__tests__/useQuery.test.tsx | 114 ++++++++++++++++++++ 1 file changed, 114 insertions(+) diff --git a/src/react/hooks/__tests__/useQuery.test.tsx b/src/react/hooks/__tests__/useQuery.test.tsx index 10d2f4f2dd4..5969458ac7e 100644 --- a/src/react/hooks/__tests__/useQuery.test.tsx +++ b/src/react/hooks/__tests__/useQuery.test.tsx @@ -2832,6 +2832,120 @@ describe('useQuery Hook', () => { }); }); + describe("canonical cache results", () => { + itAsync("can be disabled via useQuery options", (resolve, reject) => { + const cache = new InMemoryCache({ + typePolicies: { + Result: { + keyFields: false, + }, + }, + }); + + const query = gql` + query { + results { + value + } + } + `; + + const results = [ + { __typename: "Result", value: 0 }, + { __typename: "Result", value: 1 }, + { __typename: "Result", value: 1 }, + { __typename: "Result", value: 2 }, + { __typename: "Result", value: 3 }, + { __typename: "Result", value: 5 }, + ]; + + cache.writeQuery({ + query, + data: { results }, + }) + + let renderCount = 0; + function App() { + const [canonizeResults, setCanonize] = useState(false); + const { loading, data } = useQuery(query, { + fetchPolicy: "cache-only", + canonizeResults, + }); + + switch (++renderCount) { + case 1: { + expect(loading).toBe(false); + expect(data).toEqual({ results }); + expect(data.results.length).toBe(6); + const resultSet = new Set(data.results as typeof results); + // Since canonization is not happening, the duplicate 1 results are + // returned as distinct objects. + expect(resultSet.size).toBe(6); + act(() => setCanonize(true)); + break; + } + + case 2: { + expect(loading).toBe(false); + expect(data).toEqual({ results }); + expect(data.results.length).toBe(6); + const resultSet = new Set(data.results as typeof results); + // Since canonization is happening now, the duplicate 1 results are + // returned as identical (===) objects. + expect(resultSet.size).toBe(5); + const values: number[] = []; + resultSet.forEach(result => values.push(result.value)); + expect(values).toEqual([0, 1, 2, 3, 5]); + act(() => { + results.push({ + __typename: "Result", + value: 8, + }); + // Append another element to the results array, invalidating the + // array itself, triggering another render (below). + cache.writeQuery({ + query, + overwrite: true, + data: { results }, + }); + }); + break; + } + + case 3: { + expect(loading).toBe(false); + expect(data).toEqual({ results }); + expect(data.results.length).toBe(7); + const resultSet = new Set(data.results as typeof results); + // Since canonization is happening now, the duplicate 1 results are + // returned as identical (===) objects. + expect(resultSet.size).toBe(6); + const values: number[] = []; + resultSet.forEach(result => values.push(result.value)); + expect(values).toEqual([0, 1, 2, 3, 5, 8]); + break; + } + + default: { + reject("too many renders"); + } + } + + return null; + } + + render( + + + + ); + + return wait(() => { + expect(renderCount).toBe(3); + }).then(resolve, reject); + }); + }); + describe("multiple useQuery calls per component", () => { type ABFields = { id: number; From 74b6e66b4d6d1605765da4c060378730cf546dd8 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 11 May 2021 12:08:48 -0400 Subject: [PATCH 123/380] Document options.overwrite for cache.write{Query,Fragment}. --- docs/source/api/cache/InMemoryCache.mdx | 32 ++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/docs/source/api/cache/InMemoryCache.mdx b/docs/source/api/cache/InMemoryCache.mdx index 5558cfd8173..de3fe4e06e9 100644 --- a/docs/source/api/cache/InMemoryCache.mdx +++ b/docs/source/api/cache/InMemoryCache.mdx @@ -235,6 +235,21 @@ The default value is `true`. + + + +###### `overwrite` + +`Boolean` + + + +If `true`, ignore existing cache data when calling `merge` functions, allowing incoming data to replace existing data, without warnings about data loss. + +The default value is `false`. + + + @@ -505,6 +520,21 @@ The default value is `true`. + + + +###### `overwrite` + +`Boolean` + + + +If `true`, ignore existing cache data when calling `merge` functions, allowing incoming data to replace existing data, without warnings about data loss. + +The default value is `false`. + + + @@ -624,7 +654,7 @@ See [Modifier function API](#modifier-function-api) below. If `true`, also modifies the optimistically cached values for included fields. -The default value is `false`. +The default value is `false`. From 44bdaca89e13c95c1c0e6ceefa7397b3331598ce Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 11 May 2021 12:12:55 -0400 Subject: [PATCH 124/380] Document options.canonizeResults for cache.read{Query,Fragment}. --- docs/source/api/cache/InMemoryCache.mdx | 31 +++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/docs/source/api/cache/InMemoryCache.mdx b/docs/source/api/cache/InMemoryCache.mdx index de3fe4e06e9..b9bbc402599 100644 --- a/docs/source/api/cache/InMemoryCache.mdx +++ b/docs/source/api/cache/InMemoryCache.mdx @@ -129,6 +129,21 @@ By specifying the ID of another cached object, you can query arbitrary cached da + + + +###### `canonizeResults` + +`Boolean` + + + +If `true`, result objects read from the cache will be _canonized_, which means deeply-equal objects will also be `===` (literally the same object), allowing much more efficient comparison of past/present results. + +The default value is `true`. + + + @@ -397,6 +412,22 @@ A map of any GraphQL variable names and values required by `fragment`. + + + + +###### `canonizeResults` + +`Boolean` + + + +If `true`, result objects read from the cache will be _canonized_, which means deeply-equal objects will also be `===` (literally the same object), allowing much more efficient comparison of past/present results. + +The default value is `true`. + + + From 94b74728b24935b84223b2774de1869e7ebd163d Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 11 May 2021 12:36:41 -0400 Subject: [PATCH 125/380] Depend on existence of enclosing entity object when reading from cache. (#8147) Co-authored-by: Sofian Hnaide --- CHANGELOG.md | 3 + package-lock.json | 6 +- package.json | 2 +- src/__tests__/resultCacheCleaning.ts | 186 +++++++++++++++++++++++++++ src/cache/inmemory/entityStore.ts | 29 ++++- src/cache/inmemory/readFromStore.ts | 23 +++- 6 files changed, 242 insertions(+), 7 deletions(-) create mode 100644 src/__tests__/resultCacheCleaning.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b75a80a79ca..015ee3981ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,9 @@ - A `resultCacheMaxSize` option may be passed to the `InMemoryCache` constructor to limit the number of result objects that will be retained in memory (to speed up repeated reads), and calling `cache.reset()` now releases all such memory.
[@SofianHn](https://github.com/SofianHn) in [#8701](https://github.com/apollographql/apollo-client/pull/8701) +- Fully remove result cache entries from LRU dependency system when the corresponding entities are removed from `InMemoryCache` by eviction, or by any other means.
+ [@sofianhn](https://github.com/sofianhn) and [@benjamn](https://github.com/benjamn) in [#8147](https://github.com/apollographql/apollo-client/pull/8147) + ### Documentation TBD diff --git a/package-lock.json b/package-lock.json index be4ed34167a..c19cfc2a754 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10914,9 +10914,9 @@ } }, "optimism": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/optimism/-/optimism-0.16.0.tgz", - "integrity": "sha512-p+JNvSj7tsCCCiwb5jFvfNZZL8YMy1G7S8hymB5dwupV7rpu7ftRkMw2yvNY9zfMk0oH/kIGmkPSXaeBNjtWYQ==", + "version": "0.16.1", + "resolved": "https://registry.npmjs.org/optimism/-/optimism-0.16.1.tgz", + "integrity": "sha512-64i+Uw3otrndfq5kaoGNoY7pvOhSsjFEN4bdEFh80MWVk/dbgJfMv7VFDeCT8LxNAlEVhQmdVEbfE7X2nWNIIg==", "requires": { "@wry/context": "^0.6.0", "@wry/trie": "^0.3.0" diff --git a/package.json b/package.json index 9c3c875cf1b..b8190890fb5 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,7 @@ "fast-json-stable-stringify": "^2.0.0", "graphql-tag": "^2.12.3", "hoist-non-react-statics": "^3.3.2", - "optimism": "^0.16.0", + "optimism": "^0.16.1", "prop-types": "^15.7.2", "symbol-observable": "^2.0.0", "ts-invariant": "^0.7.3", diff --git a/src/__tests__/resultCacheCleaning.ts b/src/__tests__/resultCacheCleaning.ts new file mode 100644 index 00000000000..ee60b976e77 --- /dev/null +++ b/src/__tests__/resultCacheCleaning.ts @@ -0,0 +1,186 @@ +import { makeExecutableSchema } from "graphql-tools"; + +import { ApolloClient, Resolvers, gql } from "../core"; +import { InMemoryCache, NormalizedCacheObject } from "../cache"; +import { SchemaLink } from "../link/schema"; + +describe("resultCache cleaning", () => { + const fragments = gql` + fragment user on User { + id + name + } + + fragment reaction on Reaction { + id + type + author { + ...user + } + } + + fragment message on Message { + id + author { + ...user + } + reactions { + ...reaction + } + viewedBy { + ...user + } + } + `; + + const query = gql` + query getChat($id: ID!) { + chat(id: $id) { + id + name + members { + ...user + } + messages { + ...message + } + } + } + ${{ ...fragments }} + `; + + function uuid(label: string) { + return () => + `${label}-${Math.random() + .toString(16) + .substr(2)}`; + } + + function emptyList(len: number) { + return new Array(len).fill(true); + } + + const typeDefs = gql` + type Query { + chat(id: ID!): Chat! + } + + type Chat { + id: ID! + name: String! + messages: [Message!]! + members: [User!]! + } + + type Message { + id: ID! + author: User! + reactions: [Reaction!]! + viewedBy: [User!]! + content: String! + } + + type User { + id: ID! + name: String! + } + + type Reaction { + id: ID! + type: String! + author: User! + } + `; + + const resolvers: Resolvers = { + Query: { + chat(_, { id }) { + return id; + }, + }, + Chat: { + id(id) { + return id; + }, + name(id) { + return id; + }, + messages() { + return emptyList(10); + }, + members() { + return emptyList(10); + }, + }, + Message: { + id: uuid("Message"), + author() { + return { foo: true }; + }, + reactions() { + return emptyList(10); + }, + viewedBy() { + return emptyList(10); + }, + content: uuid("Message-Content"), + }, + User: { + id: uuid("User"), + name: uuid("User.name"), + }, + Reaction: { + id: uuid("Reaction"), + type: uuid("Reaction.type"), + author() { + return { foo: true }; + }, + }, + }; + + let client: ApolloClient; + + beforeEach(() => { + client = new ApolloClient({ + cache: new InMemoryCache, + link: new SchemaLink({ + schema: makeExecutableSchema({ + typeDefs, + resolvers, + }), + }), + }); + }); + + afterEach(() => { + const storeReader = (client.cache as InMemoryCache)["storeReader"]; + expect(storeReader["executeSubSelectedArray"].size).toBeGreaterThan(0); + expect(storeReader["executeSelectionSet"].size).toBeGreaterThan(0); + client.cache.evict({ + id: "ROOT_QUERY", + }); + client.cache.gc(); + expect(storeReader["executeSubSelectedArray"].size).toEqual(0); + expect(storeReader["executeSelectionSet"].size).toEqual(0); + }); + + it(`empties all result caches after eviction - query`, async () => { + await client.query({ + query, + variables: { id: 1 }, + }); + }); + + it(`empties all result caches after eviction - watchQuery`, async () => { + return new Promise((r) => { + const observable = client.watchQuery({ + query, + variables: { id: 1 }, + }); + const unsubscribe = observable.subscribe(() => { + unsubscribe.unsubscribe(); + r(); + }); + }); + }); +}); diff --git a/src/cache/inmemory/entityStore.ts b/src/cache/inmemory/entityStore.ts index 6dc3124df19..5bf4792e026 100644 --- a/src/cache/inmemory/entityStore.ts +++ b/src/cache/inmemory/entityStore.ts @@ -534,7 +534,17 @@ class CacheGroup { public dirty(dataId: string, storeFieldName: string) { if (this.d) { - this.d.dirty(makeDepKey(dataId, storeFieldName)); + this.d.dirty( + makeDepKey(dataId, storeFieldName), + // When storeFieldName === "__exists", that means the entity identified + // by dataId has either disappeared from the cache or was newly added, + // so the result caching system would do well to "forget everything it + // knows" about that object. To achieve that kind of invalidation, we + // not only dirty the associated result cache entry, but also remove it + // completely from the dependency graph. For the optimism implmentation + // details, see https://github.com/benjamn/optimism/pull/195. + storeFieldName === "__exists" ? "forget" : "setDirty", + ); } } @@ -550,6 +560,23 @@ function makeDepKey(dataId: string, storeFieldName: string) { return storeFieldName + '#' + dataId; } +export function maybeDependOnExistenceOfEntity( + store: NormalizedCache, + entityId: string, +) { + if (supportsResultCaching(store)) { + // We use this pseudo-field __exists elsewhere in the EntityStore code to + // represent changes in the existence of the entity object identified by + // entityId. This dependency gets reliably dirtied whenever an object with + // this ID is deleted (or newly created) within this group, so any result + // cache entries (for example, StoreReader#executeSelectionSet results) that + // depend on __exists for this entityId will get dirtied as well, leading to + // the eventual recomputation (instead of reuse) of those result objects the + // next time someone reads them from the cache. + store.group.depend(entityId, "__exists"); + } +} + export namespace EntityStore { // Refer to this class as EntityStore.Root outside this namespace. export class Root extends EntityStore { diff --git a/src/cache/inmemory/readFromStore.ts b/src/cache/inmemory/readFromStore.ts index 0ed395dddcb..2572c6c0607 100644 --- a/src/cache/inmemory/readFromStore.ts +++ b/src/cache/inmemory/readFromStore.ts @@ -31,7 +31,7 @@ import { NormalizedCache, ReadMergeModifyContext, } from './types'; -import { supportsResultCaching } from './entityStore'; +import { maybeDependOnExistenceOfEntity, supportsResultCaching } from './entityStore'; import { getTypenameFromStoreObject } from './helpers'; import { Policies } from './policies'; import { InMemoryCache } from './inMemoryCache'; @@ -70,12 +70,14 @@ function missingFromInvariant( type ExecSelectionSetOptions = { selectionSet: SelectionSetNode; objectOrReference: StoreObject | Reference; + enclosingRef: Reference; context: ReadContext; }; type ExecSubSelectedArrayOptions = { field: FieldNode; array: any[]; + enclosingRef: Reference; context: ReadContext; }; @@ -157,6 +159,11 @@ export class StoreReader { return other; } + maybeDependOnExistenceOfEntity( + options.context.store, + options.enclosingRef.__ref, + ); + // Finally, if we didn't find any useful previous results, run the real // execSelectionSetImpl method with the given options. return this.execSelectionSetImpl(options); @@ -179,6 +186,10 @@ export class StoreReader { }); this.executeSubSelectedArray = wrap((options: ExecSubSelectedArrayOptions) => { + maybeDependOnExistenceOfEntity( + options.context.store, + options.enclosingRef.__ref, + ); return this.execSubSelectedArrayImpl(options); }, { max: this.config.resultCacheMaxSize, @@ -216,9 +227,11 @@ export class StoreReader { ...variables!, }; + const rootRef = makeReference(rootId); const execResult = this.executeSelectionSet({ selectionSet: getMainDefinition(query).selectionSet, - objectOrReference: makeReference(rootId), + objectOrReference: rootRef, + enclosingRef: rootRef, context: { store, query, @@ -273,6 +286,7 @@ export class StoreReader { private execSelectionSetImpl({ selectionSet, objectOrReference, + enclosingRef, context, }: ExecSelectionSetOptions): ExecResult { if (isReference(objectOrReference) && @@ -364,6 +378,7 @@ export class StoreReader { fieldValue = handleMissing(this.executeSubSelectedArray({ field: selection, array: fieldValue, + enclosingRef, context, })); @@ -383,6 +398,7 @@ export class StoreReader { fieldValue = handleMissing(this.executeSelectionSet({ selectionSet: selection.selectionSet, objectOrReference: fieldValue as StoreObject | Reference, + enclosingRef: isReference(fieldValue) ? fieldValue : enclosingRef, context, })); } @@ -431,6 +447,7 @@ export class StoreReader { private execSubSelectedArrayImpl({ field, array, + enclosingRef, context, }: ExecSubSelectedArrayOptions): ExecResult { let missing: MissingFieldError[] | undefined; @@ -463,6 +480,7 @@ export class StoreReader { return handleMissing(this.executeSubSelectedArray({ field, array: item, + enclosingRef, context, }), i); } @@ -472,6 +490,7 @@ export class StoreReader { return handleMissing(this.executeSelectionSet({ selectionSet: field.selectionSet, objectOrReference: item, + enclosingRef: isReference(item) ? item : enclosingRef, context, }), i); } From 1d5726330b71a43f0d4ef96702dbc24a53f87f5b Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 11 May 2021 12:49:33 -0400 Subject: [PATCH 126/380] Bump @apollo/client npm version to 3.4.0-beta.25. --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index c19cfc2a754..84dd6e5538a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-beta.24", + "version": "3.4.0-beta.25", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index b8190890fb5..32df77e5415 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-beta.24", + "version": "3.4.0-beta.25", "description": "A fully-featured caching GraphQL client.", "private": true, "keywords": [ From 54082b566ed167554fa957ba4804163b682eceff Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Mon, 24 Aug 2020 19:46:54 -0400 Subject: [PATCH 127/380] Check structural equality in QueryInfo#setDiff, again. Revisiting PR #6891, which was reverted by commit c2ef68f9561b807df808a08a384032f574818c11. With the introduction of canonical cache results (#7439), the strict equality check in setDiff is about to become mostly synonymous with deep equality checking (but much faster to check), so we need to deal with the consequences of #6891 one way or another. As evidence that this change now (after #7439) has no observable impact, notice that we were able to switch back to using !equal(a, b) without needing to fix/update any failing tests, compared to the few tests that needed updating in #6891. However, by switching to deep equality checking, we allow ourselves the option to experiment with disabling (or periodically clearing) the ObjectCanon, which would presumably lead to broadcasting more !== but deeply equal results to cache.watch watchers. With deep equality checking in setDiff, those !== results will get past the filter in the same cases canonical objects would, so there's less of a discrepancy in client behavior with/without canonization enabled. --- src/core/QueryInfo.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/core/QueryInfo.ts b/src/core/QueryInfo.ts index c3438768127..138fae80e68 100644 --- a/src/core/QueryInfo.ts +++ b/src/core/QueryInfo.ts @@ -197,7 +197,8 @@ export class QueryInfo { const oldDiff = this.lastDiff && this.lastDiff.diff; this.updateLastDiff(diff); if (!this.dirty && - (diff && diff.result) !== (oldDiff && oldDiff.result)) { + !equal(oldDiff && oldDiff.result, + diff && diff.result)) { this.dirty = true; if (!this.notifyTimeout) { this.notifyTimeout = setTimeout(() => this.notify(), 0); From 3dce4a8f8092cf4fa7376ab17b822f5a75f07c0c Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Wed, 12 May 2021 15:55:13 -0400 Subject: [PATCH 128/380] Improve subscribeAndCount type inference before writing more tests. --- src/core/__tests__/ObservableQuery.ts | 7 ++++++- src/utilities/testing/subscribeAndCount.ts | 13 ++++++++----- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/core/__tests__/ObservableQuery.ts b/src/core/__tests__/ObservableQuery.ts index d7bf0c9aeeb..bfb6f787827 100644 --- a/src/core/__tests__/ObservableQuery.ts +++ b/src/core/__tests__/ObservableQuery.ts @@ -1,5 +1,6 @@ import gql from 'graphql-tag'; import { GraphQLError } from 'graphql'; +import { TypedDocumentNode } from '@graphql-typed-document-node/core'; import { ApolloClient, NetworkStatus } from '../../core'; import { ObservableQuery } from '../ObservableQuery'; @@ -39,7 +40,11 @@ export const mockFetchQuery = (queryManager: QueryManager) => { describe('ObservableQuery', () => { // Standard data for all these tests - const query = gql` + const query: TypedDocumentNode<{ + people_one: { + name: string; + }; + }> = gql` query query($id: ID!) { people_one(id: $id) { name diff --git a/src/utilities/testing/subscribeAndCount.ts b/src/utilities/testing/subscribeAndCount.ts index 22067167b50..61589ede615 100644 --- a/src/utilities/testing/subscribeAndCount.ts +++ b/src/utilities/testing/subscribeAndCount.ts @@ -1,12 +1,15 @@ import { ObservableQuery } from '../../core/ObservableQuery'; -import { ApolloQueryResult } from '../../core/types'; +import { ApolloQueryResult, OperationVariables } from '../../core/types'; import { ObservableSubscription } from '../../utilities/observables/Observable'; import { asyncMap } from '../../utilities/observables/asyncMap'; -export default function subscribeAndCount( +export default function subscribeAndCount< + TData, + TVariables = OperationVariables, +>( reject: (reason: any) => any, - observable: ObservableQuery, - cb: (handleCount: number, result: ApolloQueryResult) => any, + observable: ObservableQuery, + cb: (handleCount: number, result: ApolloQueryResult) => any, ): ObservableSubscription { // Use a Promise queue to prevent callbacks from being run out of order. let queue = Promise.resolve(); @@ -14,7 +17,7 @@ export default function subscribeAndCount( const subscription = asyncMap( observable, - (result: ApolloQueryResult) => { + (result: ApolloQueryResult) => { // All previous asynchronous callbacks must complete before cb can // be invoked with this result. return queue = queue.then(() => { From e3ec93d2112cda52ca67aed45bbaad1c54657ca3 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Wed, 12 May 2021 16:54:18 -0400 Subject: [PATCH 129/380] Test that queryInfo.setDiff prevents duplicate results. https://github.com/apollographql/apollo-client/pull/7997#issuecomment-822559880 --- src/core/__tests__/ObservableQuery.ts | 73 +++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/src/core/__tests__/ObservableQuery.ts b/src/core/__tests__/ObservableQuery.ts index bfb6f787827..423f13cf63d 100644 --- a/src/core/__tests__/ObservableQuery.ts +++ b/src/core/__tests__/ObservableQuery.ts @@ -1923,6 +1923,79 @@ describe('ObservableQuery', () => { }); }); + itAsync("QueryInfo does not notify for !== but deep-equal results", (resolve, reject) => { + const queryManager = mockQueryManager(reject, { + request: { query, variables }, + result: { data: dataOne }, + }); + + const observable = queryManager.watchQuery({ + query, + variables, + // If we let the cache return canonical results, it will be harder to + // write this test, because any two results that are deeply equal will + // also be !==, making the choice of equality test in queryInfo.setDiff + // less visible/important. + canonizeResults: false, + }); + + const queryInfo = observable["queryInfo"]; + const cache = queryInfo["cache"]; + const setDiffSpy = jest.spyOn(queryInfo, "setDiff"); + const notifySpy = jest.spyOn(queryInfo, "notify"); + + subscribeAndCount(reject, observable, (count, result) => { + if (count === 1) { + expect(result).toEqual({ + loading: false, + networkStatus: NetworkStatus.ready, + data: dataOne, + }); + + let invalidateCount = 0; + let onDirtyCount = 0; + + cache.batch({ + optimistic: true, + transaction(cache) { + cache.modify({ + fields: { + people_one(value, { INVALIDATE }) { + expect(value).toEqual(dataOne.people_one); + ++invalidateCount; + return INVALIDATE; + }, + }, + }); + }, + // Verify that the cache.modify operation did trigger a cache broadcast. + onDirty(watch, diff) { + expect(watch.watcher).toBe(queryInfo); + expect(diff).toEqual({ + complete: true, + result: { + people_one: { + name: "Luke Skywalker", + }, + }, + }); + ++onDirtyCount; + }, + }); + + new Promise(resolve => setTimeout(resolve, 100)).then(() => { + expect(setDiffSpy).toHaveBeenCalledTimes(1); + expect(notifySpy).not.toHaveBeenCalled(); + expect(invalidateCount).toBe(1); + expect(onDirtyCount).toBe(1); + queryManager.stop(); + }).then(resolve, reject); + } else { + reject("too many results"); + } + }); + }); + itAsync("ObservableQuery#map respects Symbol.species", (resolve, reject) => { const observable = mockWatchQuery(reject, { request: { query, variables }, From 8ef9e64f6a3b069ae241b97bc25902e47dd944b1 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Wed, 12 May 2021 19:47:23 -0400 Subject: [PATCH 130/380] Bump @apollo/client npm version to 3.4.0-beta.26. --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 84dd6e5538a..d1f9f3a2a62 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-beta.25", + "version": "3.4.0-beta.26", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index b49a9984ddd..29da62edc3e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-beta.25", + "version": "3.4.0-beta.26", "description": "A fully-featured caching GraphQL client.", "private": true, "keywords": [ From 897e36eeb225ef530fd514f000b35be770e08427 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Fri, 14 May 2021 13:32:39 -0400 Subject: [PATCH 131/380] Remove dependency on fast-json-stable-stringify. > Note: I am expecting tests to fail for this commit, demonstrating the importance of using a stable serialization strategy for field arguments. Although fast-json-stable-stringify has done its job well, it only provides a "main" field in its package.json file, pointing to a CommonJS entry point, and does not appear to export any ECMAScript modules. Thanks to our conversion/abandonment of fast-json-stable-stringify and other CommonJS-only npm dependencies (zen-observable in #5961 and graphql-tag in #6074), it should now (after this PR is merged) be possible to load @apollo/client/core from an ESM-aware CDN like jsdelivr.net or jspm.io: If you put that script tag in an HTML file, or inject it into the DOM of any webpage, you will currently see this error: Uncaught SyntaxError: The requested module '/npm/fast-json-stable-stringify@2.1.0/+esm' does not provide an export named 'default' This list of errors used to be longer, but now the only package left is fast-json-stable-stringify. Note that we're loading @apollo/client/core@beta here, not @apollo/client@beta. The reason @apollo/client itself is not yet ESM-ready is that react and react-dom are the two remaining CommonJS-only dependencies, and @apollo/client currently/regrettably re-exports utilities from @apollo/client/react. If importing from @apollo/client/core is a burden or feels weird to you, please know that we are planning to make @apollo/client synonymous with @apollo/client/core in Apollo Client 4.0, along with making @apollo/client/react synonymous with the v3 API of @apollo/client, though of course those will be major breaking changes: https://github.com/apollographql/apollo-client/issues/8190 --- package-lock.json | 3 ++- package.json | 1 - src/utilities/graphql/storeUtils.ts | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index d1f9f3a2a62..11b7c112b95 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6654,7 +6654,8 @@ "fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true }, "fast-levenshtein": { "version": "2.0.6", diff --git a/package.json b/package.json index 29da62edc3e..8b1fc04951d 100644 --- a/package.json +++ b/package.json @@ -76,7 +76,6 @@ "@wry/context": "^0.6.0", "@wry/equality": "^0.4.0", "@wry/trie": "^0.3.0", - "fast-json-stable-stringify": "^2.0.0", "graphql-tag": "^2.12.3", "hoist-non-react-statics": "^3.3.2", "optimism": "^0.16.1", diff --git a/src/utilities/graphql/storeUtils.ts b/src/utilities/graphql/storeUtils.ts index 31c9513fb8d..6ef388baf03 100644 --- a/src/utilities/graphql/storeUtils.ts +++ b/src/utilities/graphql/storeUtils.ts @@ -17,7 +17,6 @@ import { SelectionSetNode, } from 'graphql'; -import stringify from 'fast-json-stable-stringify'; import { InvariantError } from 'ts-invariant'; import { FragmentMap, getFragmentFromSelection } from './fragments'; @@ -181,6 +180,7 @@ export function getStoreKeyName( fieldName: string, args?: Record | null, directives?: Directives, + stringify: (value: any) => string = JSON.stringify, ): string { if ( args && @@ -202,7 +202,7 @@ export function getStoreKeyName( filteredArgs[key] = args[key]; }); - return `${directives['connection']['key']}(${JSON.stringify( + return `${directives['connection']['key']}(${stringify( filteredArgs, )})`; } else { @@ -224,7 +224,7 @@ export function getStoreKeyName( Object.keys(directives).forEach(key => { if (KNOWN_DIRECTIVES.indexOf(key) !== -1) return; if (directives[key] && Object.keys(directives[key]).length) { - completeFieldName += `@${key}(${JSON.stringify(directives[key])})`; + completeFieldName += `@${key}(${stringify(directives[key])})`; } else { completeFieldName += `@${key}`; } From c82e9395d597d67f68d5a101a81ef67702f8e340 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Fri, 14 May 2021 17:05:39 -0400 Subject: [PATCH 132/380] Simple but slow(er) replacement for fast-json-stable-stringify. This should fix the failing tests, though I'm planning to replace this default implementation with a more clever one in the next commit. --- src/utilities/graphql/storeUtils.ts | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src/utilities/graphql/storeUtils.ts b/src/utilities/graphql/storeUtils.ts index 6ef388baf03..26937a34139 100644 --- a/src/utilities/graphql/storeUtils.ts +++ b/src/utilities/graphql/storeUtils.ts @@ -180,7 +180,6 @@ export function getStoreKeyName( fieldName: string, args?: Record | null, directives?: Directives, - stringify: (value: any) => string = JSON.stringify, ): string { if ( args && @@ -234,6 +233,28 @@ export function getStoreKeyName( return completeFieldName; } +export namespace getStoreKeyName { + export function setStringify(s: typeof stringify) { + const previous = stringify; + stringify = s; + return previous; + } +} + +let stringify = function defaultStringify(value: any): string { + return JSON.stringify(value, stringifyReplacer); +}; + +function stringifyReplacer(_key: string, value: any): any { + if (value && typeof value === "object" && !Array.isArray(value)) { + value = Object.keys(value).sort().reduce((copy, key) => { + copy[key] = value[key]; + return copy; + }, Object.create(Object.getPrototypeOf(value))); + } + return value; +} + export function argumentsObjectFromField( field: FieldNode | DirectiveNode, variables?: Record, From 842eb5e2b820f5d0aec4746c229acaeb69306d1b Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Fri, 14 May 2021 17:21:52 -0400 Subject: [PATCH 133/380] Use ObjectCanon to speed up stable stringification. This reimplementation of the stable `stringify` function used by `getStoreKeyName` builds on the `ObjectCanon` introduced by my PR #7439, which guarantees canonical objects keys are always in sorted order. --- src/cache/inmemory/object-canon.ts | 27 ++++++++++++++++++++++++--- src/cache/inmemory/policies.ts | 6 ++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/src/cache/inmemory/object-canon.ts b/src/cache/inmemory/object-canon.ts index 0be454bc0d7..84c7c008cd6 100644 --- a/src/cache/inmemory/object-canon.ts +++ b/src/cache/inmemory/object-canon.ts @@ -2,7 +2,7 @@ import { Trie } from "@wry/trie"; import { canUseWeakMap } from "../../utilities"; import { objToStr } from "./helpers"; -function isObjectOrArray(value: any): boolean { +function isObjectOrArray(value: any): value is object { return !!value && typeof value === "object"; } @@ -109,7 +109,7 @@ export class ObjectCanon { switch (objToStr.call(value)) { case "[object Array]": { if (this.known.has(value)) return value; - const array: any[] = value.map(this.admit, this); + const array: any[] = (value as any[]).map(this.admit, this); // Arrays are looked up in the Trie using their recursively // canonicalized elements, and the known version of the array is // preserved as node.array. @@ -134,7 +134,7 @@ export class ObjectCanon { array.push(keys.json); const firstValueIndex = array.length; keys.sorted.forEach(key => { - array.push(this.admit(value[key])); + array.push(this.admit((value as any)[key])); }); // Objects are looked up in the Trie by their prototype (which // is *not* recursively canonicalized), followed by a JSON @@ -193,3 +193,24 @@ type SortedKeysInfo = { sorted: string[]; json: string; }; + +const stringifyCanon = new ObjectCanon; +const stringifyCache = new WeakMap(); + +// Since the keys of canonical objects are always created in lexicographically +// sorted order, we can use the ObjectCanon to implement a fast and stable +// version of JSON.stringify, which automatically sorts object keys. +export function canonicalStringify(value: any): string { + if (isObjectOrArray(value)) { + const canonical = stringifyCanon.admit(value); + let json = stringifyCache.get(canonical); + if (json === void 0) { + stringifyCache.set( + canonical, + json = JSON.stringify(canonical), + ); + } + return json; + } + return JSON.stringify(value); +} diff --git a/src/cache/inmemory/policies.ts b/src/cache/inmemory/policies.ts index f0a92829522..749b8af893f 100644 --- a/src/cache/inmemory/policies.ts +++ b/src/cache/inmemory/policies.ts @@ -48,6 +48,12 @@ import { } from '../core/types/common'; import { WriteContext } from './writeToStore'; +// Upgrade to a faster version of the default stable JSON.stringify function +// used by getStoreKeyName. This function is used when computing storeFieldName +// strings (when no keyArgs has been configured for a field). +import { canonicalStringify } from './object-canon'; +getStoreKeyName.setStringify(canonicalStringify); + export type TypePolicies = { [__typename: string]: TypePolicy; } From ac74d8e7729b6532ca7922b86e8c5eececf38ec4 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Mon, 17 May 2021 15:05:00 -0400 Subject: [PATCH 134/380] Allow resetting stringifyCanon used by canonicalStringify. This reset will happen whenever cache.gc() is called, which makes sense because it frees all memory associated with the replaced stringifyCanon, at the cost of temporarily slowing down canonicalStringify (but without any logical changes in the behavior/output of canonicalStringify). --- src/cache/inmemory/inMemoryCache.ts | 3 +++ src/cache/inmemory/object-canon.ts | 5 ++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/cache/inmemory/inMemoryCache.ts b/src/cache/inmemory/inMemoryCache.ts index fe7eb284bdd..896847bb6d6 100644 --- a/src/cache/inmemory/inMemoryCache.ts +++ b/src/cache/inmemory/inMemoryCache.ts @@ -28,6 +28,7 @@ import { TypePolicies, } from './policies'; import { hasOwn } from './helpers'; +import { resetStringifyCanon } from './object-canon'; export interface InMemoryCacheConfig extends ApolloReducerConfig { resultCaching?: boolean; @@ -262,6 +263,7 @@ export class InMemoryCache extends ApolloCache { // Request garbage collection of unreachable normalized entities. public gc() { + resetStringifyCanon(); return this.optimisticData.gc(); } @@ -322,6 +324,7 @@ export class InMemoryCache extends ApolloCache { public reset(): Promise { this.init(); this.broadcastWatches(); + resetStringifyCanon(); return Promise.resolve(); } diff --git a/src/cache/inmemory/object-canon.ts b/src/cache/inmemory/object-canon.ts index 84c7c008cd6..a933b36a280 100644 --- a/src/cache/inmemory/object-canon.ts +++ b/src/cache/inmemory/object-canon.ts @@ -194,7 +194,10 @@ type SortedKeysInfo = { json: string; }; -const stringifyCanon = new ObjectCanon; +let stringifyCanon = new ObjectCanon; +export function resetStringifyCanon() { + stringifyCanon = new ObjectCanon; +} const stringifyCache = new WeakMap(); // Since the keys of canonical objects are always created in lexicographically From 5a17ec1f1724ec436b5107ed9112e830f4c3ff80 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Fri, 14 May 2021 18:34:17 -0400 Subject: [PATCH 135/380] Don't bother preserving prototypes of objects to be JSON-serialized. https://github.com/apollographql/apollo-client/pull/8222#discussion_r632827175 --- src/utilities/graphql/storeUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utilities/graphql/storeUtils.ts b/src/utilities/graphql/storeUtils.ts index 26937a34139..6d6729d2d23 100644 --- a/src/utilities/graphql/storeUtils.ts +++ b/src/utilities/graphql/storeUtils.ts @@ -250,7 +250,7 @@ function stringifyReplacer(_key: string, value: any): any { value = Object.keys(value).sort().reduce((copy, key) => { copy[key] = value[key]; return copy; - }, Object.create(Object.getPrototypeOf(value))); + }, {} as Record); } return value; } From ef2d3ad4cf4ea2e64e57dd26f5c803f1013eba02 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Mon, 17 May 2021 17:42:54 -0400 Subject: [PATCH 136/380] Support canonicalStringify.reset() instead of a separate function. --- src/cache/inmemory/inMemoryCache.ts | 6 +++--- src/cache/inmemory/object-canon.ts | 20 ++++++++++++-------- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/src/cache/inmemory/inMemoryCache.ts b/src/cache/inmemory/inMemoryCache.ts index 896847bb6d6..0f059478c0d 100644 --- a/src/cache/inmemory/inMemoryCache.ts +++ b/src/cache/inmemory/inMemoryCache.ts @@ -28,7 +28,7 @@ import { TypePolicies, } from './policies'; import { hasOwn } from './helpers'; -import { resetStringifyCanon } from './object-canon'; +import { canonicalStringify } from './object-canon'; export interface InMemoryCacheConfig extends ApolloReducerConfig { resultCaching?: boolean; @@ -263,7 +263,7 @@ export class InMemoryCache extends ApolloCache { // Request garbage collection of unreachable normalized entities. public gc() { - resetStringifyCanon(); + canonicalStringify.reset(); return this.optimisticData.gc(); } @@ -324,7 +324,7 @@ export class InMemoryCache extends ApolloCache { public reset(): Promise { this.init(); this.broadcastWatches(); - resetStringifyCanon(); + canonicalStringify.reset(); return Promise.resolve(); } diff --git a/src/cache/inmemory/object-canon.ts b/src/cache/inmemory/object-canon.ts index a933b36a280..d9a2747e2a7 100644 --- a/src/cache/inmemory/object-canon.ts +++ b/src/cache/inmemory/object-canon.ts @@ -194,16 +194,10 @@ type SortedKeysInfo = { json: string; }; -let stringifyCanon = new ObjectCanon; -export function resetStringifyCanon() { - stringifyCanon = new ObjectCanon; -} -const stringifyCache = new WeakMap(); - // Since the keys of canonical objects are always created in lexicographically // sorted order, we can use the ObjectCanon to implement a fast and stable // version of JSON.stringify, which automatically sorts object keys. -export function canonicalStringify(value: any): string { +export const canonicalStringify = Object.assign(function (value: any): string { if (isObjectOrArray(value)) { const canonical = stringifyCanon.admit(value); let json = stringifyCache.get(canonical); @@ -216,4 +210,14 @@ export function canonicalStringify(value: any): string { return json; } return JSON.stringify(value); -} +}, { + reset() { + stringifyCanon = new ObjectCanon; + }, +}); + +// Can be reset by calling canonicalStringify.reset(). +let stringifyCanon = new ObjectCanon; + +// Needs no resetting, thanks to weakness. +const stringifyCache = new WeakMap(); From a2d679ed058289a560c5e0901cb22fc25f00c954 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Mon, 17 May 2021 17:43:56 -0400 Subject: [PATCH 137/380] Use Object.assign instead of namespace for getStoreKeyName.setStringify. https://github.com/apollographql/apollo-client/pull/8222#discussion_r633847490 --- src/utilities/graphql/storeUtils.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/utilities/graphql/storeUtils.ts b/src/utilities/graphql/storeUtils.ts index 6d6729d2d23..70e0bc0eb5b 100644 --- a/src/utilities/graphql/storeUtils.ts +++ b/src/utilities/graphql/storeUtils.ts @@ -176,7 +176,7 @@ const KNOWN_DIRECTIVES: string[] = [ 'export', ]; -export function getStoreKeyName( +export const getStoreKeyName = Object.assign(function ( fieldName: string, args?: Record | null, directives?: Directives, @@ -231,16 +231,16 @@ export function getStoreKeyName( } return completeFieldName; -} - -export namespace getStoreKeyName { - export function setStringify(s: typeof stringify) { +}, { + setStringify(s: typeof stringify) { const previous = stringify; stringify = s; return previous; - } -} + }, +}); +// Default stable JSON.stringify implementation. Can be updated/replaced with +// something better by calling getStoreKeyName.setStringify. let stringify = function defaultStringify(value: any): string { return JSON.stringify(value, stringifyReplacer); }; From cd202fc307de804d721bc2e8a09df4bcf2c16c7f Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Mon, 17 May 2021 17:45:50 -0400 Subject: [PATCH 138/380] Export canonicalStringify from @apollo/client/cache. https://github.com/apollographql/apollo-client/pull/8222#discussion_r633851693 --- src/__tests__/__snapshots__/exports.ts.snap | 1 + src/cache/index.ts | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/src/__tests__/__snapshots__/exports.ts.snap b/src/__tests__/__snapshots__/exports.ts.snap index d41a41b752a..cf064e3d32a 100644 --- a/src/__tests__/__snapshots__/exports.ts.snap +++ b/src/__tests__/__snapshots__/exports.ts.snap @@ -68,6 +68,7 @@ Array [ "MissingFieldError", "Policies", "cacheSlot", + "canonicalStringify", "defaultDataIdFromObject", "fieldNameFromStoreName", "isReference", diff --git a/src/cache/index.ts b/src/cache/index.ts index d57d526ef8b..5e3f221a44f 100644 --- a/src/cache/index.ts +++ b/src/cache/index.ts @@ -38,4 +38,8 @@ export { Policies, } from './inmemory/policies'; +export { + canonicalStringify, +} from './inmemory/object-canon'; + export * from './inmemory/types'; From 127b2728e558febfedf72ca6ccc74d88cf65756f Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 18 May 2021 12:25:26 -0400 Subject: [PATCH 139/380] Bump @apollo/client npm version to 3.4.0-beta.27. --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 11b7c112b95..df62cbf1421 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-beta.26", + "version": "3.4.0-beta.27", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 8b1fc04951d..d57fb51e4eb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-beta.26", + "version": "3.4.0-beta.27", "description": "A fully-featured caching GraphQL client.", "private": true, "keywords": [ From eddad5a2ada64c32057f72a61ab4a151d9d85042 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 6 Apr 2021 15:04:05 -0400 Subject: [PATCH 140/380] Rename cache.batch onDirty option to onWatchUpdated. Dirtiness is an overloaded metaphor that isn't widely used in our documentation or implementation (outside of the optimism npm package, which uses other terminology unrelated to Apollo Client). The cache.batch options.onWatchUpdated callback fires for any cache.watch watches whose queries were dirtied/affected/updated/invalidated/etc. by the options.transaction function. Choosing among these possible terms, I think "updated" is the most neutral and least likely to mislead. --- src/cache/core/cache.ts | 2 +- src/cache/inmemory/__tests__/cache.ts | 26 ++++++------- src/cache/inmemory/inMemoryCache.ts | 56 ++++++++++++++------------- src/core/QueryManager.ts | 2 +- src/core/__tests__/ObservableQuery.ts | 8 ++-- 5 files changed, 48 insertions(+), 46 deletions(-) diff --git a/src/cache/core/cache.ts b/src/cache/core/cache.ts index 4c188fe83d9..84118a70006 100644 --- a/src/cache/core/cache.ts +++ b/src/cache/core/cache.ts @@ -26,7 +26,7 @@ export type BatchOptions> = { // 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?: ( + onWatchUpdated?: ( this: C, watch: Cache.WatchOptions, diff: Cache.DiffResult, diff --git a/src/cache/inmemory/__tests__/cache.ts b/src/cache/inmemory/__tests__/cache.ts index 08fc4766c44..6751b46fd8f 100644 --- a/src/cache/inmemory/__tests__/cache.ts +++ b/src/cache/inmemory/__tests__/cache.ts @@ -1348,7 +1348,7 @@ describe('Cache', () => { return { diffs, watch: options, cancel }; } - it('calls onDirty for each invalidated watch', () => { + it('calls onWatchUpdated for each invalidated watch', () => { const cache = new InMemoryCache; const aQuery = gql`query { a }`; @@ -1371,7 +1371,7 @@ describe('Cache', () => { }); }, optimistic: true, - onDirty(w, diff) { + onWatchUpdated(w, diff) { dirtied.set(w, diff); }, }); @@ -1412,7 +1412,7 @@ describe('Cache', () => { }); }, optimistic: true, - onDirty(w, diff) { + onWatchUpdated(w, diff) { dirtied.set(w, diff); }, }); @@ -1485,7 +1485,7 @@ describe('Cache', () => { }); }, optimistic: true, - onDirty(w, diff) { + onWatchUpdated(w, diff) { dirtied.set(w, diff); }, }); @@ -1506,7 +1506,7 @@ describe('Cache', () => { bInfo.cancel(); }); - it('does not pass previously invalidated queries to onDirty', () => { + it('does not pass previously invalidated queries to onWatchUpdated', () => { const cache = new InMemoryCache; const aQuery = gql`query { a }`; @@ -1527,13 +1527,13 @@ describe('Cache', () => { cache.writeQuery({ query: bQuery, - // Writing this data with broadcast:false queues this update for the - // next broadcast, whenever it happens. If that next broadcast is the - // one triggered by cache.batch, the bQuery broadcast could be - // accidentally intercepted by onDirty, even though the transaction - // does not touch the Query.b field. To solve this problem, the batch - // method calls cache.broadcastWatches() before the transaction, when - // options.onDirty is provided. + // Writing this data with broadcast:false queues this update for + // the next broadcast, whenever it happens. If that next broadcast + // is the one triggered by cache.batch, the bQuery broadcast could + // be accidentally intercepted by onWatchUpdated, even though the + // transaction does not touch the Query.b field. To solve this + // problem, the batch method calls cache.broadcastWatches() before + // the transaction, when options.onWatchUpdated is provided. broadcast: false, data: { b: "beeeee", @@ -1559,7 +1559,7 @@ describe('Cache', () => { }); }, optimistic: true, - onDirty(watch, diff) { + onWatchUpdated(watch, diff) { dirtied.set(watch, diff); }, }); diff --git a/src/cache/inmemory/inMemoryCache.ts b/src/cache/inmemory/inMemoryCache.ts index 0f059478c0d..668850c9ac7 100644 --- a/src/cache/inmemory/inMemoryCache.ts +++ b/src/cache/inmemory/inMemoryCache.ts @@ -39,8 +39,8 @@ export interface InMemoryCacheConfig extends ApolloReducerConfig { type BroadcastOptions = Pick< BatchOptions, - | "onDirty" | "optimistic" + | "onWatchUpdated" > const defaultConfig: InMemoryCacheConfig = { @@ -359,22 +359,23 @@ export class InMemoryCache extends ApolloCache { } }; - const { onDirty } = options; + const { onWatchUpdated } = options; const alreadyDirty = new Set(); - if (onDirty && !this.txCount) { - // If an options.onDirty callback is provided, we want to call it with - // only the Cache.WatchOptions objects affected by options.transaction, - // but there might be dirty watchers already waiting to be broadcast that - // have nothing to do with the transaction. To prevent including those - // watchers in the post-transaction broadcast, we perform this initial - // broadcast to collect the dirty watchers, so we can re-dirty them later, - // after the post-transaction broadcast, allowing them to receive their - // pending broadcasts the next time broadcastWatches is called, just as - // they would if we never called cache.batch. + if (onWatchUpdated && !this.txCount) { + // If an options.onWatchUpdated callback is provided, we want to + // call it with only the Cache.WatchOptions objects affected by + // options.transaction, but there might be dirty watchers already + // waiting to be broadcast that have nothing to do with the + // transaction. To prevent including those watchers in the + // post-transaction broadcast, we perform this initial broadcast to + // collect the dirty watchers, so we can re-dirty them later, after + // the post-transaction broadcast, allowing them to receive their + // pending broadcasts the next time broadcastWatches is called, just + // as they would if we never called cache.batch. this.broadcastWatches({ ...options, - onDirty(watch) { + onWatchUpdated(watch) { alreadyDirty.add(watch); return false; }, @@ -402,18 +403,18 @@ export class InMemoryCache extends ApolloCache { // Note: if this.txCount > 0, then alreadyDirty.size === 0, so this code // takes the else branch and calls this.broadcastWatches(options), which // does nothing when this.txCount > 0. - if (onDirty && alreadyDirty.size) { + if (onWatchUpdated && alreadyDirty.size) { this.broadcastWatches({ ...options, - onDirty(watch, diff) { - const onDirtyResult = onDirty.call(this, watch, diff); - if (onDirtyResult !== false) { - // Since onDirty did not return false, this diff is about to be - // broadcast to watch.callback, so we don't need to re-dirty it - // with the other alreadyDirty watches below. + onWatchUpdated(watch, diff) { + const result = onWatchUpdated.call(this, watch, diff); + if (result !== false) { + // Since onWatchUpdated did not return false, this diff is + // about to be broadcast to watch.callback, so we don't need + // to re-dirty it with the other alreadyDirty watches below. alreadyDirty.delete(watch); } - return onDirtyResult; + return result; } }); // Silently re-dirty any watches that were already dirty before the @@ -422,8 +423,9 @@ export class InMemoryCache extends ApolloCache { alreadyDirty.forEach(watch => this.maybeBroadcastWatch.dirty(watch)); } } else { - // If alreadyDirty is empty or we don't have an options.onDirty function, - // we don't need to go to the trouble of wrapping options.onDirty. + // If alreadyDirty is empty or we don't have an onWatchUpdated + // function, we don't need to go to the trouble of wrapping + // options.onWatchUpdated. this.broadcastWatches(options); } } @@ -482,10 +484,10 @@ export class InMemoryCache extends ApolloCache { diff.fromOptimisticTransaction = true; } - if (options.onDirty && - options.onDirty.call(this, c, diff) === false) { - // Returning false from the onDirty callback will prevent calling - // c.callback(diff) for this watcher. + if (options.onWatchUpdated && + options.onWatchUpdated.call(this, c, diff) === false) { + // Returning false from the onWatchUpdated callback will prevent + // calling c.callback(diff) for this watcher. return; } } diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index aabc1afd508..f8ab8c47cd4 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -382,7 +382,7 @@ export class QueryManager { // Write the final mutation.result to the root layer of the cache. optimistic: false, - onDirty: mutation.reobserveQuery && ((watch, diff) => { + onWatchUpdated: mutation.reobserveQuery && ((watch, diff) => { if (watch.watcher instanceof QueryInfo) { const oq = watch.watcher.observableQuery; if (oq) { diff --git a/src/core/__tests__/ObservableQuery.ts b/src/core/__tests__/ObservableQuery.ts index 423f13cf63d..5dc2903cc49 100644 --- a/src/core/__tests__/ObservableQuery.ts +++ b/src/core/__tests__/ObservableQuery.ts @@ -1953,7 +1953,7 @@ describe('ObservableQuery', () => { }); let invalidateCount = 0; - let onDirtyCount = 0; + let onWatchUpdatedCount = 0; cache.batch({ optimistic: true, @@ -1969,7 +1969,7 @@ describe('ObservableQuery', () => { }); }, // Verify that the cache.modify operation did trigger a cache broadcast. - onDirty(watch, diff) { + onWatchUpdated(watch, diff) { expect(watch.watcher).toBe(queryInfo); expect(diff).toEqual({ complete: true, @@ -1979,7 +1979,7 @@ describe('ObservableQuery', () => { }, }, }); - ++onDirtyCount; + ++onWatchUpdatedCount; }, }); @@ -1987,7 +1987,7 @@ describe('ObservableQuery', () => { expect(setDiffSpy).toHaveBeenCalledTimes(1); expect(notifySpy).not.toHaveBeenCalled(); expect(invalidateCount).toBe(1); - expect(onDirtyCount).toBe(1); + expect(onWatchUpdatedCount).toBe(1); queryManager.stop(); }).then(resolve, reject); } else { From 97857baf28843a0086e0c47482c9158e8d5ab8b2 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 6 Apr 2021 15:18:33 -0400 Subject: [PATCH 141/380] Rename mutation reobserveQuery callback to onQueryUpdated. Previously, options.onDirty (for cache.batch) and options.reobserveQuery (for mutations) had no obvious relationship, even though one is implemented in terms of the other. Now, cache.batch takes an onWatchUpdated callback and mutations take onQueryUpdated, which I think better represents the similarity (as well as the crucial difference in argument types) between the callbacks. Also, the options.onQueryUpdated callback is typically triggered by the mutation's options.update function, so the "onQueryUpdated" terminology more accurately reflects the source of the updates. --- src/core/QueryManager.ts | 12 +++++------ src/core/__tests__/QueryManager/index.ts | 8 ++++---- src/core/types.ts | 2 +- src/core/watchQueryOptions.ts | 4 ++-- .../hooks/__tests__/useMutation.test.tsx | 20 +++++++++---------- src/react/types/types.ts | 6 +++--- 6 files changed, 26 insertions(+), 26 deletions(-) diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index f8ab8c47cd4..1c37017c725 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -36,7 +36,7 @@ import { ApolloQueryResult, OperationVariables, MutationUpdaterFunction, - ReobserveQueryCallback, + OnQueryUpdated, } from './types'; import { LocalState } from './LocalState'; @@ -139,7 +139,7 @@ export class QueryManager { refetchQueries = [], awaitRefetchQueries = false, update: updateWithProxyFn, - reobserveQuery, + onQueryUpdated, errorPolicy = 'none', fetchPolicy, context, @@ -236,7 +236,7 @@ export class QueryManager { context, updateQueries, update: updateWithProxyFn, - reobserveQuery, + onQueryUpdated, }); } catch (e) { // Likewise, throwing an error from the asyncMap mapping function @@ -311,7 +311,7 @@ export class QueryManager { context?: TContext; updateQueries: UpdateQueries; update?: MutationUpdaterFunction; - reobserveQuery?: ReobserveQueryCallback; + onQueryUpdated?: OnQueryUpdated; }, cache = this.cache, ): Promise { @@ -382,11 +382,11 @@ export class QueryManager { // Write the final mutation.result to the root layer of the cache. optimistic: false, - onWatchUpdated: mutation.reobserveQuery && ((watch, diff) => { + onWatchUpdated: mutation.onQueryUpdated && ((watch, diff) => { if (watch.watcher instanceof QueryInfo) { const oq = watch.watcher.observableQuery; if (oq) { - reobserveResults.push(mutation.reobserveQuery!(oq, diff)); + reobserveResults.push(mutation.onQueryUpdated!(oq, diff)); // Prevent the normal cache broadcast of this result. return false; } diff --git a/src/core/__tests__/QueryManager/index.ts b/src/core/__tests__/QueryManager/index.ts index d869425b033..da43103d649 100644 --- a/src/core/__tests__/QueryManager/index.ts +++ b/src/core/__tests__/QueryManager/index.ts @@ -5227,7 +5227,7 @@ describe('QueryManager', () => { }); }); - describe('reobserveQuery', () => { + describe('onQueryUpdated', () => { const mutation = gql` mutation changeAuthorName { changeAuthorName(newName: "Jack Smith") { @@ -5316,7 +5316,7 @@ describe('QueryManager', () => { }); }, - reobserveQuery(obsQuery) { + onQueryUpdated(obsQuery) { expect(obsQuery.options.query).toBe(query); return obsQuery.refetch().then(async () => { // Wait a bit to make sure the mutation really awaited the @@ -5374,7 +5374,7 @@ describe('QueryManager', () => { }); }, - reobserveQuery(obsQuery) { + onQueryUpdated(obsQuery) { expect(obsQuery.options.query).toBe(query); return obsQuery.refetch(); }, @@ -5415,7 +5415,7 @@ describe('QueryManager', () => { cache.evict({ fieldName: "author" }); }, - reobserveQuery(obsQuery) { + onQueryUpdated(obsQuery) { expect(obsQuery.options.query).toBe(query); return obsQuery.reobserve({ fetchPolicy: "network-only", diff --git a/src/core/types.ts b/src/core/types.ts index c6e40a3eb31..fd08388656f 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -15,7 +15,7 @@ export type DefaultContext = Record; export type QueryListener = (queryInfo: QueryInfo) => void; -export type ReobserveQueryCallback = ( +export type OnQueryUpdated = ( observableQuery: ObservableQuery, diff: Cache.DiffResult, ) => void | Promise; diff --git a/src/core/watchQueryOptions.ts b/src/core/watchQueryOptions.ts index beca5cce0cc..f9f96975d80 100644 --- a/src/core/watchQueryOptions.ts +++ b/src/core/watchQueryOptions.ts @@ -8,7 +8,7 @@ import { PureQueryOptions, OperationVariables, MutationUpdaterFunction, - ReobserveQueryCallback, + OnQueryUpdated, } from './types'; import { ApolloCache } from '../cache'; @@ -260,7 +260,7 @@ export interface MutationBaseOptions< * A function that will be called for each ObservableQuery affected by * this mutation, after the mutation has completed. */ - reobserveQuery?: ReobserveQueryCallback; + onQueryUpdated?: OnQueryUpdated; /** * Specifies the {@link ErrorPolicy} to be used for this operation diff --git a/src/react/hooks/__tests__/useMutation.test.tsx b/src/react/hooks/__tests__/useMutation.test.tsx index f3f938e50c6..198f1fcdfa6 100644 --- a/src/react/hooks/__tests__/useMutation.test.tsx +++ b/src/react/hooks/__tests__/useMutation.test.tsx @@ -747,7 +747,7 @@ describe('useMutation Hook', () => { }); describe('refetching queries', () => { - itAsync('can pass reobserveQuery to useMutation', (resolve, reject) => { + itAsync('can pass onQueryUpdated to useMutation', (resolve, reject) => { interface TData { todoCount: number; } @@ -791,17 +791,17 @@ describe('useMutation Hook', () => { }).setOnError(reject), }); - // The goal of this test is to make sure reobserveQuery gets called as + // The goal of this test is to make sure onQueryUpdated gets called as // part of the createTodo mutation, so we use this reobservePromise to - // await the calling of reobserveQuery. - interface ReobserveResults { + // await the calling of onQueryUpdated. + interface OnQueryUpdatedResults { obsQuery: ObservableQuery; diff: Cache.DiffResult; result: ApolloQueryResult; } - let reobserveResolve: (results: ReobserveResults) => any; - const reobservePromise = new Promise(resolve => { - reobserveResolve = resolve; + let resolveOnUpdate: (results: OnQueryUpdatedResults) => any; + const onUpdatePromise = new Promise(resolve => { + resolveOnUpdate = resolve; }); let finishedReobserving = false; @@ -838,10 +838,10 @@ describe('useMutation Hook', () => { act(() => { createTodo({ variables, - reobserveQuery(obsQuery, diff) { + onQueryUpdated(obsQuery, diff) { return obsQuery.reobserve().then(result => { finishedReobserving = true; - reobserveResolve({ obsQuery, diff, result }); + resolveOnUpdate({ obsQuery, diff, result }); }); }, }); @@ -888,7 +888,7 @@ describe('useMutation Hook', () => { ); - return reobservePromise.then(results => { + return onUpdatePromise.then(results => { expect(finishedReobserving).toBe(true); expect(results.diff).toEqual({ diff --git a/src/react/types/types.ts b/src/react/types/types.ts index a7c692ba6f2..b3bef363669 100644 --- a/src/react/types/types.ts +++ b/src/react/types/types.ts @@ -19,7 +19,7 @@ import { ObservableQuery, OperationVariables, PureQueryOptions, - ReobserveQueryCallback, + OnQueryUpdated, WatchQueryFetchPolicy, WatchQueryOptions, } from '../../core'; @@ -154,7 +154,7 @@ export interface BaseMutationOptions< awaitRefetchQueries?: boolean; errorPolicy?: ErrorPolicy; update?: MutationUpdaterFunction; - reobserveQuery?: ReobserveQueryCallback; + onQueryUpdated?: OnQueryUpdated; client?: ApolloClient; notifyOnNetworkStatusChange?: boolean; context?: TContext; @@ -175,7 +175,7 @@ export interface MutationFunctionOptions< refetchQueries?: Array | RefetchQueriesFunction; awaitRefetchQueries?: boolean; update?: MutationUpdaterFunction; - reobserveQuery?: ReobserveQueryCallback; + onQueryUpdated?: OnQueryUpdated; context?: TContext; fetchPolicy?: WatchQueryFetchPolicy; } From 204ae7773468ad94ecdcc4109927706812c80ead Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 6 Apr 2021 16:40:45 -0400 Subject: [PATCH 142/380] Remove optimistic layer at same time as final mutation update. --- src/cache/core/cache.ts | 2 ++ src/cache/inmemory/inMemoryCache.ts | 5 +++++ src/core/QueryManager.ts | 7 +++++++ 3 files changed, 14 insertions(+) diff --git a/src/cache/core/cache.ts b/src/cache/core/cache.ts index 84118a70006..ac84eaf01b6 100644 --- a/src/cache/core/cache.ts +++ b/src/cache/core/cache.ts @@ -23,6 +23,8 @@ export type BatchOptions> = { // the same as passing null. optimistic: string | boolean; + removeOptimistic?: string; + // 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. diff --git a/src/cache/inmemory/inMemoryCache.ts b/src/cache/inmemory/inMemoryCache.ts index 668850c9ac7..77b6b1a29dd 100644 --- a/src/cache/inmemory/inMemoryCache.ts +++ b/src/cache/inmemory/inMemoryCache.ts @@ -342,6 +342,7 @@ export class InMemoryCache extends ApolloCache { const { transaction, optimistic = true, + removeOptimistic, } = options; const perform = (layer?: EntityStore) => { @@ -400,6 +401,10 @@ export class InMemoryCache extends ApolloCache { perform(); } + if (typeof removeOptimistic === "string") { + this.optimisticData = this.optimisticData.removeLayer(removeOptimistic); + } + // Note: if this.txCount > 0, then alreadyDirty.size === 0, so this code // takes the else branch and calls this.broadcastWatches(options), which // does nothing when this.txCount > 0. diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index 1c37017c725..48d122e8d1c 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -232,6 +232,7 @@ export class QueryManager { result, document: mutation, variables, + removeOptimistic: !!optimisticResponse, errorPolicy, context, updateQueries, @@ -311,6 +312,7 @@ export class QueryManager { context?: TContext; updateQueries: UpdateQueries; update?: MutationUpdaterFunction; + removeOptimistic: boolean; onQueryUpdated?: OnQueryUpdated; }, cache = this.cache, @@ -382,6 +384,10 @@ export class QueryManager { // Write the final mutation.result to the root layer of the cache. optimistic: false, + removeOptimistic: mutation.removeOptimistic + ? mutation.mutationId + : void 0, + onWatchUpdated: mutation.onQueryUpdated && ((watch, diff) => { if (watch.watcher instanceof QueryInfo) { const oq = watch.watcher.observableQuery; @@ -420,6 +426,7 @@ export class QueryManager { try { this.markMutationResult({ ...mutation, + removeOptimistic: false, result: { data }, }, cache); } catch (error) { From 82c68c838978c87e95bbab8ef9e3194a9d844a77 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 6 Apr 2021 16:27:59 -0400 Subject: [PATCH 143/380] Improve options API of client.refetchQueries method. This is obviously a breaking change for client.refetchQueries, but that method was introduced in PR #7431, targeting the release-3.4 branch. Since Apollo Client v3.4 is still in beta, we still have room to rework the signature of the client.refetchQueries method (introduced in #7431 and released in @apollo/client@3.4.0-beta.3), not only adding significant new functionality like options.updateCache and options.onQueryUpdated, but also leaving room to add functionality more easily in the future, without breaking backwards compatibility, since client.refetchQueries takes named options and returns an object of named results, so adding new options or returning new results never needs to be a breaking change. --- src/__tests__/client.ts | 4 +- src/core/ApolloClient.ts | 29 ++- src/core/QueryManager.ts | 316 +++++++++++++++-------- src/core/__tests__/QueryManager/index.ts | 4 +- src/core/watchQueryOptions.ts | 8 + 5 files changed, 250 insertions(+), 111 deletions(-) diff --git a/src/__tests__/client.ts b/src/__tests__/client.ts index af13183cef1..6f0f58ecb61 100644 --- a/src/__tests__/client.ts +++ b/src/__tests__/client.ts @@ -2436,7 +2436,9 @@ describe('client', () => { // @ts-ignore const spy = jest.spyOn(client.queryManager, 'refetchQueries'); - await client.refetchQueries(['Author1']); + await client.refetchQueries({ + include: ['Author1'], + }); expect(spy).toHaveBeenCalled(); }); diff --git a/src/core/ApolloClient.ts b/src/core/ApolloClient.ts index a87b4e30395..26bc4013a6e 100644 --- a/src/core/ApolloClient.ts +++ b/src/core/ApolloClient.ts @@ -23,7 +23,7 @@ import { MutationOptions, SubscriptionOptions, WatchQueryFetchPolicy, - RefetchQueryDescription, + RefetchQueriesOptions, } from './watchQueryOptions'; import { @@ -536,9 +536,30 @@ export class ApolloClient implements DataProxy { * Takes optional parameter `includeStandby` which will include queries in standby-mode when refetching. */ public refetchQueries( - queries: RefetchQueryDescription, - ): Promise[]> { - return Promise.all(this.queryManager.refetchQueries(queries)); + options: Pick< + RefetchQueriesOptions>, + | "updateCache" + | "include" + | "optimistic" + | "onQueryUpdated" + >, + ): Promise<{ + queries: ObservableQuery[]; + results: Map, ApolloQueryResult>; + }> { + const results = this.queryManager.refetchQueries(options); + const queries: ObservableQuery[] = []; + const values: any[] = []; + + results.forEach((value, obsQuery) => { + queries.push(obsQuery); + values.push(value); + }); + + return Promise.all(values).then(values => { + values.forEach((value, i) => results.set(queries[i], value)); + return { queries, results }; + }); } /** diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index 48d122e8d1c..59da8d3ec66 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -29,6 +29,7 @@ import { WatchQueryFetchPolicy, ErrorPolicy, RefetchQueryDescription, + RefetchQueriesOptions, } from './watchQueryOptions'; import { ObservableQuery } from './ObservableQuery'; import { NetworkStatus, isNetworkRequestInFlight } from './networkStatus'; @@ -194,8 +195,6 @@ export class QueryManager { const self = this; return new Promise((resolve, reject) => { - let storeResult: FetchResult | null; - return asyncMap( self.getObservableFromLink( mutation, @@ -219,37 +218,75 @@ export class QueryManager { mutationStoreValue.error = null; } - storeResult = result; - - if (fetchPolicy !== 'no-cache') { - try { - // Returning the result of markMutationResult here makes the - // mutation await any Promise that markMutationResult returns, - // since we are returning this Promise from the asyncMap mapping - // function. - return self.markMutationResult({ - mutationId, - result, - document: mutation, - variables, - removeOptimistic: !!optimisticResponse, - errorPolicy, - context, - updateQueries, - update: updateWithProxyFn, - onQueryUpdated, - }); - } catch (e) { - // Likewise, throwing an error from the asyncMap mapping function - // will result in calling the subscribed error handler function. - throw new ApolloError({ - networkError: e, - }); - } + const storeResult: typeof result = { ...result }; + + if (typeof refetchQueries === "function") { + refetchQueries = refetchQueries(storeResult); + } + + if (errorPolicy === 'ignore' && + graphQLResultHasError(storeResult)) { + delete storeResult.errors; + } + + if (fetchPolicy === 'no-cache') { + const refetchResults = this.refetchQueries({ + include: refetchQueries, + onQueryUpdated, + }); + + return Promise.all( + awaitRefetchQueries ? refetchResults.values() : [], + ).then(() => storeResult); } + + const markPromise = self.markMutationResult< + TData, + TVariables, + TContext, + TCache + >({ + mutationId, + result, + document: mutation, + variables, + errorPolicy, + context, + update: updateWithProxyFn, + updateQueries, + refetchQueries, + removeOptimistic: optimisticResponse ? mutationId : void 0, + onQueryUpdated, + }); + + if (awaitRefetchQueries || onQueryUpdated) { + // Returning the result of markMutationResult here makes the + // mutation await the Promise that markMutationResult returns, + // since we are returning markPromise from the map function + // we passed to asyncMap above. + return markPromise.then(() => storeResult); + } + + return storeResult; }, ).subscribe({ + next(storeResult) { + if (optimisticResponse) { + self.cache.removeOptimistic(mutationId); + } + + self.broadcastQueries(); + + // At the moment, a mutation can have only one result, so we can + // immediately resolve upon receiving the first result. In the future, + // mutations containing @defer or @stream directives might receive + // multiple FetchResult payloads from the ApolloLink chain, so we will + // probably need to collect those results in this next method and call + // resolve only later, in an observer.complete function. + resolve(storeResult); + }, + error(err: Error) { if (mutationStoreValue) { mutationStoreValue.loading = false; @@ -268,41 +305,16 @@ export class QueryManager { }), ); }, - - complete() { - if (optimisticResponse) { - self.cache.removeOptimistic(mutationId); - } - - self.broadcastQueries(); - - // allow for conditional refetches - // XXX do we want to make this the only API one day? - if (typeof refetchQueries === 'function') { - refetchQueries = refetchQueries(storeResult!); - } - - const refetchQueryPromises = self.refetchQueries(refetchQueries); - - Promise.all( - awaitRefetchQueries ? refetchQueryPromises : [], - ).then(() => { - if ( - errorPolicy === 'ignore' && - storeResult && - graphQLResultHasError(storeResult) - ) { - delete storeResult.errors; - } - - resolve(storeResult!); - }, reject); - }, }); }); } - public markMutationResult>( + public markMutationResult< + TData, + TVariables, + TContext, + TCache extends ApolloCache + >( mutation: { mutationId: string; result: FetchResult; @@ -312,7 +324,8 @@ export class QueryManager { context?: TContext; updateQueries: UpdateQueries; update?: MutationUpdaterFunction; - removeOptimistic: boolean; + refetchQueries?: RefetchQueryDescription; + removeOptimistic?: string; onQueryUpdated?: OnQueryUpdated; }, cache = this.cache, @@ -364,43 +377,37 @@ export class QueryManager { }); } - const reobserveResults: any[] = []; + const results = this.refetchQueries({ + updateCache(cache: TCache) { + cacheWrites.forEach(write => cache.write(write)); - cache.batch({ - transaction(c) { - cacheWrites.forEach(write => c.write(write)); // If the mutation has some writes associated with it then we need to // apply those writes to the store by running this reducer again with // a write action. const { update } = mutation; if (update) { - update(c as any, mutation.result, { + update(cache, mutation.result, { context: mutation.context, variables: mutation.variables, }); } }, + include: mutation.refetchQueries, + // Write the final mutation.result to the root layer of the cache. optimistic: false, - removeOptimistic: mutation.removeOptimistic - ? mutation.mutationId - : void 0, + // Remove the corresponding optimistic layer at the same time as we + // write the final non-optimistic result. + removeOptimistic: mutation.removeOptimistic, - onWatchUpdated: mutation.onQueryUpdated && ((watch, diff) => { - if (watch.watcher instanceof QueryInfo) { - const oq = watch.watcher.observableQuery; - if (oq) { - reobserveResults.push(mutation.onQueryUpdated!(oq, diff)); - // Prevent the normal cache broadcast of this result. - return false; - } - } - }), + // Let the caller of client.mutate optionally determine the refetching + // behavior for watched queries after the mutation.update function runs. + onQueryUpdated: mutation.onQueryUpdated, }); - return Promise.all(reobserveResults).then(() => void 0); + return Promise.all(results.values()).then(() => void 0); } return Promise.resolve(); @@ -426,7 +433,6 @@ export class QueryManager { try { this.markMutationResult({ ...mutation, - removeOptimistic: false, result: { data }, }, cache); } catch (error) { @@ -1023,38 +1029,138 @@ export class QueryManager { return concast; } - public refetchQueries( - queries: RefetchQueryDescription, - ): Promise>[] { - const refetchQueryPromises: Promise>[] = []; - - if (isNonEmptyArray(queries)) { - queries.forEach(refetchQuery => { - if (typeof refetchQuery === 'string') { - this.queries.forEach(({ observableQuery }) => { - if (observableQuery && - observableQuery.hasObservers() && - observableQuery.queryName === refetchQuery) { - refetchQueryPromises.push(observableQuery.refetch()); + public refetchQueries({ + updateCache, + include, + optimistic = false, + removeOptimistic = optimistic ? "TODO" : void 0, + onQueryUpdated, + }: RefetchQueriesOptions>) { + const includedQueriesById = new Map(); + const results = new Map, any>(); + + if (include) { + const queryIdsByQueryName: Record = Object.create(null); + this.queries.forEach((queryInfo, queryId) => { + const oq = queryInfo.observableQuery; + const queryName = oq && oq.queryName; + if (queryName) { + queryIdsByQueryName[queryName] = queryId; + } + }); + + include.forEach(queryNameOrOptions => { + if (typeof queryNameOrOptions === "string") { + const queryId = queryIdsByQueryName[queryNameOrOptions]; + if (queryId) { + includedQueriesById.set(queryId, queryNameOrOptions); + } else { + invariant.warn(`Unknown query name ${ + JSON.stringify(queryNameOrOptions) + } passed to refetchQueries method in options.include array`); + } + } else { + includedQueriesById.set( + // We will be issuing a fresh network request for this query, so we + // pre-allocate a new query ID here. + this.generateQueryId(), + queryNameOrOptions, + ); + } + }); + } + + if (updateCache) { + this.cache.batch({ + transaction: updateCache, + + // Since you can perform any combination of cache reads and/or writes in + // the cache.batch transaction function, its optimistic option can be + // either a boolean or a string, representing three distinct modes of + // operation: + // + // * false: read/write only the root layer + // * true: read/write the topmost layer + // * string: read/write a fresh optimistic layer with that ID string + // + // When typeof optimistic === "string", a new optimistic layer will be + // temporarily created within cache.batch with that string as its ID. If + // we then pass that same string as the removeOptimistic option, we can + // make cache.batch immediately remove the optimistic layer after + // running the transaction, triggering only one broadcast. + // + // However, the refetchQueries method accepts only true or false for its + // optimistic option (not string). We interpret true to mean a temporary + // optimistic layer should be created, to allow efficiently rolling back + // the effect of the updateCache function, which involves passing a + // string instead of true as the optimistic option to cache.batch, when + // refetchQueries receives optimistic: true. + // + // In other words, we are deliberately not supporting the use case of + // writing to an *existing* optimistic layer (using the refetchQueries + // updateCache function), since that would potentially interfere with + // other optimistic updates in progress. Instead, you can read/write + // only the root layer by passing optimistic: false to refetchQueries, + // or you can read/write a brand new optimistic layer that will be + // automatically removed by passing optimistic: true. + optimistic: optimistic && removeOptimistic || false, + + // The removeOptimistic option can also be provided by itself, even if + // optimistic === false, to remove some previously-added optimistic + // layer safely and efficiently, like we do in markMutationResult. + // + // If an explicit removeOptimistic string is provided with optimistic: + // true, the removeOptimistic string will determine the ID of the + // temporary optimistic layer, in case that ever matters. + removeOptimistic, + + onWatchUpdated: onQueryUpdated && function (watch, diff) { + if (watch.watcher instanceof QueryInfo) { + const oq = watch.watcher.observableQuery; + if (oq) { + includedQueriesById.delete(oq.queryId); + results.set(oq, onQueryUpdated!(oq, diff)); + // Prevent the normal cache broadcast of this result. + return false; } + } + }, + }); + } + + if (includedQueriesById.size) { + includedQueriesById.forEach((queryNameOrOptions, queryId) => { + const queryInfo = this.getQuery(queryId); + let oq = queryInfo.observableQuery; + if (oq) { + const result = onQueryUpdated + ? onQueryUpdated(oq, queryInfo.getDiff()) + : oq.refetch(); + + results.set(oq, result); + + } else if (typeof queryNameOrOptions === "object") { + const fetchPromise = this.fetchQuery(queryId, { + query: queryNameOrOptions.query, + variables: queryNameOrOptions.variables, + fetchPolicy: "network-only", + context: queryNameOrOptions.context, }); - } else { - const queryOptions: QueryOptions = { - query: refetchQuery.query, - variables: refetchQuery.variables, - fetchPolicy: 'network-only', - }; - - if (refetchQuery.context) { - queryOptions.context = refetchQuery.context; + + oq = queryInfo.observableQuery; + if (oq) { + results.set(oq, fetchPromise); + } else { + throw new InvariantError(JSON.stringify(queryInfo, null, 2)); } - refetchQueryPromises.push(this.query(queryOptions)); + const stop = () => this.stopQuery(queryId); + fetchPromise.then(stop, stop); } }); } - return refetchQueryPromises; + return results; } private fetchQueryByPolicy( diff --git a/src/core/__tests__/QueryManager/index.ts b/src/core/__tests__/QueryManager/index.ts index da43103d649..e14effcf004 100644 --- a/src/core/__tests__/QueryManager/index.ts +++ b/src/core/__tests__/QueryManager/index.ts @@ -4382,7 +4382,9 @@ describe('QueryManager', () => { observable.subscribe({ next: () => null }); observable2.subscribe({ next: () => null }); - return Promise.all(queryManager.refetchQueries(['GetAuthor', 'GetAuthor2'])).then(() => { + return Promise.all(queryManager.refetchQueries({ + include: ['GetAuthor', 'GetAuthor2'], + })).then(() => { const result = getCurrentQueryResult(observable); expect(result.partial).toBe(false); expect(stripSymbols(result.data)).toEqual(dataChanged); diff --git a/src/core/watchQueryOptions.ts b/src/core/watchQueryOptions.ts index f9f96975d80..c2fd7b74cc0 100644 --- a/src/core/watchQueryOptions.ts +++ b/src/core/watchQueryOptions.ts @@ -191,6 +191,14 @@ export interface SubscriptionOptions; +export type RefetchQueriesOptions> = { + updateCache?: (cache: Cache) => void; + include?: RefetchQueryDescription; + optimistic?: boolean; + removeOptimistic?: string; + onQueryUpdated?: OnQueryUpdated; +}; + export interface MutationBaseOptions< TData = any, TVariables = OperationVariables, From 55bc6bede21c57b1f9265f1287ac762fcfd8c7b8 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Thu, 8 Apr 2021 16:25:49 -0400 Subject: [PATCH 144/380] Ensure optimistic layers get removed in QueryManager#refetchQueries. --- src/core/QueryManager.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index 59da8d3ec66..f0392f050bd 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -272,10 +272,6 @@ export class QueryManager { ).subscribe({ next(storeResult) { - if (optimisticResponse) { - self.cache.removeOptimistic(mutationId); - } - self.broadcastQueries(); // At the moment, a mutation can have only one result, so we can @@ -1160,6 +1156,17 @@ export class QueryManager { }); } + if (removeOptimistic) { + // In case no updateCache callback was provided (so cache.batch was not + // called above, and thus did not already remove the optimistic layer), + // remove it here. Since this is a no-op when the layer has already been + // removed, we do it even if we called cache.batch above, since it's + // possible this.cache is an instance of some ApolloCache subclass other + // than InMemoryCache, and does not fully support the removeOptimistic + // option for cache.batch. + this.cache.removeOptimistic(removeOptimistic); + } + return results; } From b8c18d48d26c64ffc238e9dc2dc78a56f1db91c3 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Thu, 8 Apr 2021 18:36:02 -0400 Subject: [PATCH 145/380] Fix bug due to Promise.all not understanding iterators. --- src/core/QueryManager.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index f0392f050bd..9f9dd5d4978 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -230,13 +230,15 @@ export class QueryManager { } if (fetchPolicy === 'no-cache') { - const refetchResults = this.refetchQueries({ + const results: any[] = []; + + this.refetchQueries({ include: refetchQueries, onQueryUpdated, - }); + }).forEach(result => results.push(result)); return Promise.all( - awaitRefetchQueries ? refetchResults.values() : [], + awaitRefetchQueries ? results : [], ).then(() => storeResult); } @@ -373,7 +375,9 @@ export class QueryManager { }); } - const results = this.refetchQueries({ + const results: any[] = []; + + this.refetchQueries({ updateCache(cache: TCache) { cacheWrites.forEach(write => cache.write(write)); @@ -401,9 +405,10 @@ export class QueryManager { // Let the caller of client.mutate optionally determine the refetching // behavior for watched queries after the mutation.update function runs. onQueryUpdated: mutation.onQueryUpdated, - }); - return Promise.all(results.values()).then(() => void 0); + }).forEach(result => results.push(result)); + + return Promise.all(results).then(() => void 0); } return Promise.resolve(); From 3936f04842eb9b57fcda4cead477ecb336e19181 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Thu, 8 Apr 2021 18:46:26 -0400 Subject: [PATCH 146/380] Fix test failing due to incorrect usage of queryManager.refetchQueries. --- src/core/__tests__/QueryManager/index.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/core/__tests__/QueryManager/index.ts b/src/core/__tests__/QueryManager/index.ts index e14effcf004..3748c78a98a 100644 --- a/src/core/__tests__/QueryManager/index.ts +++ b/src/core/__tests__/QueryManager/index.ts @@ -4382,9 +4382,12 @@ describe('QueryManager', () => { observable.subscribe({ next: () => null }); observable2.subscribe({ next: () => null }); - return Promise.all(queryManager.refetchQueries({ + const results: any[] = []; + queryManager.refetchQueries({ include: ['GetAuthor', 'GetAuthor2'], - })).then(() => { + }).forEach(result => results.push(result)); + + return Promise.all(results).then(() => { const result = getCurrentQueryResult(observable); expect(result.partial).toBe(false); expect(stripSymbols(result.data)).toEqual(dataChanged); From d0962618b7029653c6d04beaab32bbaee0404cef Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Thu, 8 Apr 2021 19:08:56 -0400 Subject: [PATCH 147/380] Test that refetchQueries now warns when named query not found. --- src/core/__tests__/QueryManager/index.ts | 47 +++++++++++------------- 1 file changed, 22 insertions(+), 25 deletions(-) diff --git a/src/core/__tests__/QueryManager/index.ts b/src/core/__tests__/QueryManager/index.ts index 3748c78a98a..fdf0294bdc3 100644 --- a/src/core/__tests__/QueryManager/index.ts +++ b/src/core/__tests__/QueryManager/index.ts @@ -4629,16 +4629,12 @@ describe('QueryManager', () => { }); describe('refetchQueries', () => { - const oldWarn = console.warn; - let timesWarned = 0; - + let consoleWarnSpy: jest.SpyInstance; beforeEach(() => { - // clear warnings - timesWarned = 0; - // mock warn method - console.warn = (...args: any[]) => { - timesWarned++; - }; + consoleWarnSpy = jest.spyOn(console, "warn").mockImplementation(); + }); + afterEach(() => { + consoleWarnSpy.mockRestore(); }); itAsync('should refetch the right query when a result is successfully returned', (resolve, reject) => { @@ -4777,12 +4773,15 @@ describe('QueryManager', () => { }, result => { expect(stripSymbols(result.data)).toEqual(secondReqData); - expect(timesWarned).toBe(0); + expect(consoleWarnSpy).toHaveBeenLastCalledWith( + 'Unknown query name "fakeQuery" passed to refetchQueries method ' + + "in options.include array" + ); }, ).then(resolve, reject); }); - itAsync('should ignore without warning a query name that is asked to refetch with no active subscriptions', (resolve, reject) => { + itAsync('should ignore (with warning) a query named in refetchQueries that has no active subscriptions', (resolve, reject) => { const mutation = gql` mutation changeAuthorName { changeAuthorName(newName: "Jack Smith") { @@ -4836,16 +4835,18 @@ describe('QueryManager', () => { const observable = queryManager.watchQuery({ query }); return observableToPromise({ observable }, result => { expect(stripSymbols(result.data)).toEqual(data); - }) - .then(() => { - // The subscription has been stopped already - return queryManager.mutate({ - mutation, - refetchQueries: ['getAuthors'], - }); - }) - .then(() => expect(timesWarned).toBe(0)) - .then(resolve, reject); + }).then(() => { + // The subscription has been stopped already + return queryManager.mutate({ + mutation, + refetchQueries: ['getAuthors'], + }); + }).then(() => { + expect(consoleWarnSpy).toHaveBeenLastCalledWith( + 'Unknown query name "getAuthors" passed to refetchQueries method ' + + "in options.include array" + ); + }).then(resolve, reject); }); itAsync('also works with a query document and variables', (resolve, reject) => { @@ -5226,10 +5227,6 @@ describe('QueryManager', () => { }, ).then(resolve, reject); }); - - afterEach(() => { - console.warn = oldWarn; - }); }); describe('onQueryUpdated', () => { From c760500ed6bcf5eb3d42df4d5fe412cf74abe633 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Thu, 8 Apr 2021 19:52:27 -0400 Subject: [PATCH 148/380] Ensure ObservableQuery is created for PureQueryOptions refetches. --- src/core/QueryManager.ts | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index 9f9dd5d4978..e240df11a95 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -1141,20 +1141,21 @@ export class QueryManager { results.set(oq, result); } else if (typeof queryNameOrOptions === "object") { - const fetchPromise = this.fetchQuery(queryId, { + const options: WatchQueryOptions = { query: queryNameOrOptions.query, variables: queryNameOrOptions.variables, fetchPolicy: "network-only", context: queryNameOrOptions.context, - }); + }; - oq = queryInfo.observableQuery; - if (oq) { - results.set(oq, fetchPromise); - } else { - throw new InvariantError(JSON.stringify(queryInfo, null, 2)); - } + queryInfo.setObservableQuery(oq = new ObservableQuery({ + queryManager: this, + queryInfo, + options, + })); + const fetchPromise = this.fetchQuery(queryId, options); + results.set(oq, fetchPromise); const stop = () => this.stopQuery(queryId); fetchPromise.then(stop, stop); } From 21f09869945ef9a03800c514ef14591a5c83361f Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Fri, 9 Apr 2021 16:41:53 -0400 Subject: [PATCH 149/380] Separate {Public,Private}RefetchQueriesOptions. --- src/core/ApolloClient.ts | 30 ++++++++----------- src/core/QueryManager.ts | 15 ++++++---- src/core/__tests__/QueryManager/index.ts | 3 +- src/core/types.ts | 6 ++-- src/core/watchQueryOptions.ts | 30 +++++++++++++++---- .../hooks/__tests__/useMutation.test.tsx | 1 + src/react/types/types.ts | 12 ++++++-- 7 files changed, 62 insertions(+), 35 deletions(-) diff --git a/src/core/ApolloClient.ts b/src/core/ApolloClient.ts index 26bc4013a6e..8abcc95fd18 100644 --- a/src/core/ApolloClient.ts +++ b/src/core/ApolloClient.ts @@ -23,7 +23,7 @@ import { MutationOptions, SubscriptionOptions, WatchQueryFetchPolicy, - RefetchQueriesOptions, + PublicRefetchQueriesOptions, } from './watchQueryOptions'; import { @@ -535,31 +535,25 @@ export class ApolloClient implements DataProxy { * active queries. * Takes optional parameter `includeStandby` which will include queries in standby-mode when refetching. */ - public refetchQueries( - options: Pick< - RefetchQueriesOptions>, - | "updateCache" - | "include" - | "optimistic" - | "onQueryUpdated" - >, + public refetchQueries( + options: PublicRefetchQueriesOptions>, ): Promise<{ queries: ObservableQuery[]; - results: Map, ApolloQueryResult>; + results: ApolloQueryResult[]; }> { - const results = this.queryManager.refetchQueries(options); + const map = this.queryManager.refetchQueries(options); const queries: ObservableQuery[] = []; - const values: any[] = []; + const results: any[] = []; - results.forEach((value, obsQuery) => { + map.forEach((result, obsQuery) => { queries.push(obsQuery); - values.push(value); + results.push(result); }); - return Promise.all(values).then(values => { - values.forEach((value, i) => results.set(queries[i], value)); - return { queries, results }; - }); + return Promise.all(results).then(results => ({ + queries, + results, + })); } /** diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index e240df11a95..081a0023787 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -29,7 +29,7 @@ import { WatchQueryFetchPolicy, ErrorPolicy, RefetchQueryDescription, - RefetchQueriesOptions, + PrivateRefetchQueriesOptions, } from './watchQueryOptions'; import { ObservableQuery } from './ObservableQuery'; import { NetworkStatus, isNetworkRequestInFlight } from './networkStatus'; @@ -132,7 +132,12 @@ export class QueryManager { this.fetchCancelFns.clear(); } - public async mutate>({ + public async mutate< + TData, + TVariables, + TContext, + TCache extends ApolloCache + >({ mutation, variables, optimisticResponse, @@ -324,7 +329,7 @@ export class QueryManager { update?: MutationUpdaterFunction; refetchQueries?: RefetchQueryDescription; removeOptimistic?: string; - onQueryUpdated?: OnQueryUpdated; + onQueryUpdated?: OnQueryUpdated; }, cache = this.cache, ): Promise { @@ -1030,13 +1035,13 @@ export class QueryManager { return concast; } - public refetchQueries({ + public refetchQueries({ updateCache, include, optimistic = false, removeOptimistic = optimistic ? "TODO" : void 0, onQueryUpdated, - }: RefetchQueriesOptions>) { + }: PrivateRefetchQueriesOptions>) { const includedQueriesById = new Map(); const results = new Map, any>(); diff --git a/src/core/__tests__/QueryManager/index.ts b/src/core/__tests__/QueryManager/index.ts index fdf0294bdc3..56b7bc397c1 100644 --- a/src/core/__tests__/QueryManager/index.ts +++ b/src/core/__tests__/QueryManager/index.ts @@ -5320,11 +5320,12 @@ describe('QueryManager', () => { onQueryUpdated(obsQuery) { expect(obsQuery.options.query).toBe(query); - return obsQuery.refetch().then(async () => { + return obsQuery.refetch().then(async (result) => { // Wait a bit to make sure the mutation really awaited the // refetching of the query. await new Promise(resolve => setTimeout(resolve, 100)); finishedRefetch = true; + return result; }); }, }).then(() => { diff --git a/src/core/types.ts b/src/core/types.ts index fd08388656f..566d1c232c5 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -15,10 +15,10 @@ export type DefaultContext = Record; export type QueryListener = (queryInfo: QueryInfo) => void; -export type OnQueryUpdated = ( +export type OnQueryUpdated = ( observableQuery: ObservableQuery, - diff: Cache.DiffResult, -) => void | Promise; + diff: Cache.DiffResult, +) => void | Promise>; export type OperationVariables = Record; diff --git a/src/core/watchQueryOptions.ts b/src/core/watchQueryOptions.ts index c2fd7b74cc0..ac8c1cccfcc 100644 --- a/src/core/watchQueryOptions.ts +++ b/src/core/watchQueryOptions.ts @@ -191,13 +191,31 @@ export interface SubscriptionOptions; -export type RefetchQueriesOptions> = { - updateCache?: (cache: Cache) => void; - include?: RefetchQueryDescription; +export interface PublicRefetchQueriesOptions< + TData, + TCache extends ApolloCache, +> { + updateCache?: (cache: TCache) => void; + // Although you can pass PureQueryOptions objects in addition to strings in + // the refetchQueries array for a mutation, the client.refetchQueries method + // deliberately discourages passing PureQueryOptions, by restricting the + // public type of the options.include array to string[] (just query names). + include?: string[]; optimistic?: boolean; + onQueryUpdated?: OnQueryUpdated; +} + +export interface PrivateRefetchQueriesOptions< + TData, + TCache extends ApolloCache, +> extends Omit, "include"> { + // Just like the refetchQueries array for a mutation, allowing both strings + // and PureQueryOptions objects. + include?: RefetchQueryDescription; + // This part of the API is a (useful) implementation detail, but need not be + // exposed in the public client.refetchQueries API (above). removeOptimistic?: string; - onQueryUpdated?: OnQueryUpdated; -}; +} export interface MutationBaseOptions< TData = any, @@ -268,7 +286,7 @@ export interface MutationBaseOptions< * A function that will be called for each ObservableQuery affected by * this mutation, after the mutation has completed. */ - onQueryUpdated?: OnQueryUpdated; + onQueryUpdated?: OnQueryUpdated; /** * Specifies the {@link ErrorPolicy} to be used for this operation diff --git a/src/react/hooks/__tests__/useMutation.test.tsx b/src/react/hooks/__tests__/useMutation.test.tsx index 198f1fcdfa6..01f51941862 100644 --- a/src/react/hooks/__tests__/useMutation.test.tsx +++ b/src/react/hooks/__tests__/useMutation.test.tsx @@ -842,6 +842,7 @@ describe('useMutation Hook', () => { return obsQuery.reobserve().then(result => { finishedReobserving = true; resolveOnUpdate({ obsQuery, diff, result }); + return result; }); }, }); diff --git a/src/react/types/types.ts b/src/react/types/types.ts index b3bef363669..9d165d1e97f 100644 --- a/src/react/types/types.ts +++ b/src/react/types/types.ts @@ -154,7 +154,11 @@ export interface BaseMutationOptions< awaitRefetchQueries?: boolean; errorPolicy?: ErrorPolicy; update?: MutationUpdaterFunction; - onQueryUpdated?: OnQueryUpdated; + // Use OnQueryUpdated instead of OnQueryUpdated here because TData + // is the shape of the mutation result, but onQueryUpdated gets called with + // results from any queries affected by the mutation update function, which + // probably do not have the same shape as the mutation result. + onQueryUpdated?: OnQueryUpdated; client?: ApolloClient; notifyOnNetworkStatusChange?: boolean; context?: TContext; @@ -175,7 +179,11 @@ export interface MutationFunctionOptions< refetchQueries?: Array | RefetchQueriesFunction; awaitRefetchQueries?: boolean; update?: MutationUpdaterFunction; - onQueryUpdated?: OnQueryUpdated; + // Use OnQueryUpdated instead of OnQueryUpdated here because TData + // is the shape of the mutation result, but onQueryUpdated gets called with + // results from any queries affected by the mutation update function, which + // probably do not have the same shape as the mutation result. + onQueryUpdated?: OnQueryUpdated; context?: TContext; fetchPolicy?: WatchQueryFetchPolicy; } From b94956e4b6018dabe1138fd6b964a00ff8c71f7e Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Fri, 16 Apr 2021 16:12:39 -0400 Subject: [PATCH 150/380] Simplify client.refetchQueries return type. Some consumers may not care what client.refetchQueries returns (so we shouldn't do a lot of work to return something they won't use), while others may want to await Promise.all(client.refetchQueries(...).updates), while others may want direct access to client.refetchQueries(...).updates and/or .queries, without waiting on a promise. The { queries, updates } result contains adequate information for all of these use cases, and leaves room to introduce more properties in the future, if we need to support additional client.refetchQueries use cases. --- src/core/ApolloClient.ts | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/core/ApolloClient.ts b/src/core/ApolloClient.ts index 8abcc95fd18..ed1fe05e365 100644 --- a/src/core/ApolloClient.ts +++ b/src/core/ApolloClient.ts @@ -535,25 +535,25 @@ export class ApolloClient implements DataProxy { * active queries. * Takes optional parameter `includeStandby` which will include queries in standby-mode when refetching. */ - public refetchQueries( - options: PublicRefetchQueriesOptions>, - ): Promise<{ + public refetchQueries< + TData, + TCache extends ApolloCache = ApolloCache, + >( + options: PublicRefetchQueriesOptions, + ): { queries: ObservableQuery[]; - results: ApolloQueryResult[]; - }> { + updates: any[]; + } { const map = this.queryManager.refetchQueries(options); const queries: ObservableQuery[] = []; - const results: any[] = []; + const updates: any[] = []; map.forEach((result, obsQuery) => { queries.push(obsQuery); - results.push(result); + updates.push(result); }); - return Promise.all(results).then(results => ({ - queries, - results, - })); + return { queries, updates }; } /** From 87dddf224a3ad22b36c2429151533aac86a1d83a Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Fri, 16 Apr 2021 16:13:16 -0400 Subject: [PATCH 151/380] Allow onQueryUpdated callbacks to return a boolean. --- src/cache/core/cache.ts | 2 +- src/core/QueryManager.ts | 45 +++++++++++++++++++++++++++------------- src/core/types.ts | 2 +- 3 files changed, 33 insertions(+), 16 deletions(-) diff --git a/src/cache/core/cache.ts b/src/cache/core/cache.ts index ac84eaf01b6..2517c368306 100644 --- a/src/cache/core/cache.ts +++ b/src/cache/core/cache.ts @@ -32,7 +32,7 @@ export type BatchOptions> = { this: C, watch: Cache.WatchOptions, diff: Cache.DiffResult, - ) => void | false; + ) => any; }; export abstract class ApolloCache implements DataProxy { diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index 081a0023787..2cc854eb385 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -1045,6 +1045,23 @@ export class QueryManager { const includedQueriesById = new Map(); const results = new Map, any>(); + function maybeAddResult(oq: ObservableQuery, result: any): boolean { + // The onQueryUpdated function can return false to ignore this query and + // skip its normal broadcast, or true to allow the usual broadcast to + // happen (when diff.result has changed). + if (result === false || result === true) { + return result; + } + + // Returning anything other than true or false from onQueryUpdated will + // cause the result to be included in the results Map, while also + // canceling/overriding the normal broadcast. + results.set(oq, result); + + // Prevent the normal cache broadcast of this result. + return false; + } + if (include) { const queryIdsByQueryName: Record = Object.create(null); this.queries.forEach((queryInfo, queryId) => { @@ -1121,14 +1138,13 @@ export class QueryManager { removeOptimistic, onWatchUpdated: onQueryUpdated && function (watch, diff) { - if (watch.watcher instanceof QueryInfo) { - const oq = watch.watcher.observableQuery; - if (oq) { - includedQueriesById.delete(oq.queryId); - results.set(oq, onQueryUpdated!(oq, diff)); - // Prevent the normal cache broadcast of this result. - return false; - } + const oq = + watch.watcher instanceof QueryInfo && + watch.watcher.observableQuery; + + if (oq) { + includedQueriesById.delete(oq.queryId); + return maybeAddResult(oq, onQueryUpdated(oq, diff)); } }, }); @@ -1139,11 +1155,12 @@ export class QueryManager { const queryInfo = this.getQuery(queryId); let oq = queryInfo.observableQuery; if (oq) { - const result = onQueryUpdated - ? onQueryUpdated(oq, queryInfo.getDiff()) - : oq.refetch(); - - results.set(oq, result); + maybeAddResult( + oq, + onQueryUpdated + ? onQueryUpdated(oq, queryInfo.getDiff()) + : oq.refetch(), + ); } else if (typeof queryNameOrOptions === "object") { const options: WatchQueryOptions = { @@ -1160,7 +1177,7 @@ export class QueryManager { })); const fetchPromise = this.fetchQuery(queryId, options); - results.set(oq, fetchPromise); + maybeAddResult(oq, fetchPromise); const stop = () => this.stopQuery(queryId); fetchPromise.then(stop, stop); } diff --git a/src/core/types.ts b/src/core/types.ts index 566d1c232c5..1528e466c9d 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -18,7 +18,7 @@ export type QueryListener = (queryInfo: QueryInfo) => void; export type OnQueryUpdated = ( observableQuery: ObservableQuery, diff: Cache.DiffResult, -) => void | Promise>; +) => boolean | Promise>; export type OperationVariables = Record; From 8ef7d650c29b50319582bc56a2c5df64c828e640 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Fri, 16 Apr 2021 16:42:43 -0400 Subject: [PATCH 152/380] Pick unique optimistic layer ID for optimistic client.refetchQueries. TODO This functionality (the options.optimistic option for client.refetchQueries) needs more test coverage. --- src/core/QueryManager.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index 2cc854eb385..69ac833c751 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -1039,7 +1039,7 @@ export class QueryManager { updateCache, include, optimistic = false, - removeOptimistic = optimistic ? "TODO" : void 0, + removeOptimistic = optimistic ? makeUniqueId("refetchQueries") : void 0, onQueryUpdated, }: PrivateRefetchQueriesOptions>) { const includedQueriesById = new Map(); @@ -1367,3 +1367,10 @@ export class QueryManager { }; } } + +const prefixCounts: Record = Object.create(null); +function makeUniqueId(prefix: string) { + const count = prefixCounts[prefix] || 1; + prefixCounts[prefix] = count + 1; + return `${prefix}:${count}:${Math.random().toString(36).slice(2)}`; +} From 1282b91c96271fdd5c1101496221b5112fc3d216 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Mon, 19 Apr 2021 11:33:19 -0400 Subject: [PATCH 153/380] Pass previous diff to watch.callback, onWatchUpdated, and onQueryUpdated. --- src/cache/core/cache.ts | 3 ++- src/cache/core/types/Cache.ts | 5 ++++- src/cache/inmemory/inMemoryCache.ts | 7 ++++--- src/core/QueryManager.ts | 4 ++-- src/core/types.ts | 3 ++- 5 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/cache/core/cache.ts b/src/cache/core/cache.ts index 2517c368306..0dac825ef13 100644 --- a/src/cache/core/cache.ts +++ b/src/cache/core/cache.ts @@ -31,7 +31,8 @@ export type BatchOptions> = { onWatchUpdated?: ( this: C, watch: Cache.WatchOptions, - diff: Cache.DiffResult, + newDiff: Cache.DiffResult, + oldDiff?: Cache.DiffResult, ) => any; }; diff --git a/src/cache/core/types/Cache.ts b/src/cache/core/types/Cache.ts index 98b0fb00f48..35bd4e1aab5 100644 --- a/src/cache/core/types/Cache.ts +++ b/src/cache/core/types/Cache.ts @@ -2,7 +2,10 @@ import { DataProxy } from './DataProxy'; import { Modifier, Modifiers } from './common'; export namespace Cache { - export type WatchCallback = (diff: Cache.DiffResult) => void; + export type WatchCallback = ( + newDiff: Cache.DiffResult, + oldDiff?: Cache.DiffResult, + ) => void; export interface ReadOptions extends DataProxy.Query { diff --git a/src/cache/inmemory/inMemoryCache.ts b/src/cache/inmemory/inMemoryCache.ts index 77b6b1a29dd..d289b7ac213 100644 --- a/src/cache/inmemory/inMemoryCache.ts +++ b/src/cache/inmemory/inMemoryCache.ts @@ -477,6 +477,7 @@ export class InMemoryCache extends ApolloCache { c: Cache.WatchOptions, options?: BroadcastOptions, ) { + const { lastDiff } = c; const diff = this.diff({ query: c.query, variables: c.variables, @@ -490,15 +491,15 @@ export class InMemoryCache extends ApolloCache { } if (options.onWatchUpdated && - options.onWatchUpdated.call(this, c, diff) === false) { + options.onWatchUpdated.call(this, c, diff, lastDiff) === false) { // Returning false from the onWatchUpdated callback will prevent // calling c.callback(diff) for this watcher. return; } } - if (!c.lastDiff || c.lastDiff.result !== diff.result) { - c.callback(c.lastDiff = diff); + if (!lastDiff || lastDiff.result !== diff.result) { + c.callback(c.lastDiff = diff, lastDiff); } } } diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index 69ac833c751..92ddf6e21d1 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -1137,14 +1137,14 @@ export class QueryManager { // temporary optimistic layer, in case that ever matters. removeOptimistic, - onWatchUpdated: onQueryUpdated && function (watch, diff) { + onWatchUpdated: onQueryUpdated && function (watch, newDiff, oldDiff) { const oq = watch.watcher instanceof QueryInfo && watch.watcher.observableQuery; if (oq) { includedQueriesById.delete(oq.queryId); - return maybeAddResult(oq, onQueryUpdated(oq, diff)); + return maybeAddResult(oq, onQueryUpdated(oq, newDiff, oldDiff)); } }, }); diff --git a/src/core/types.ts b/src/core/types.ts index 1528e466c9d..14333ba02a8 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -17,7 +17,8 @@ export type QueryListener = (queryInfo: QueryInfo) => void; export type OnQueryUpdated = ( observableQuery: ObservableQuery, - diff: Cache.DiffResult, + newDiff: Cache.DiffResult, + oldDiff?: Cache.DiffResult, ) => boolean | Promise>; export type OperationVariables = Record; From 4b66ff286669240b5257bc2c3638e1ac241cb1dc Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Mon, 19 Apr 2021 11:45:17 -0400 Subject: [PATCH 154/380] Use deep equality check before calling watch.callback. Similar to #7997, but further upstream. --- src/cache/inmemory/inMemoryCache.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/cache/inmemory/inMemoryCache.ts b/src/cache/inmemory/inMemoryCache.ts index d289b7ac213..e1c97759441 100644 --- a/src/cache/inmemory/inMemoryCache.ts +++ b/src/cache/inmemory/inMemoryCache.ts @@ -3,6 +3,7 @@ import './fixPolyfills'; import { DocumentNode } from 'graphql'; import { OptimisticWrapperFunction, wrap } from 'optimism'; +import { equal } from '@wry/equality'; import { ApolloCache, BatchOptions } from '../core/cache'; import { Cache } from '../core/types/Cache'; @@ -498,7 +499,7 @@ export class InMemoryCache extends ApolloCache { } } - if (!lastDiff || lastDiff.result !== diff.result) { + if (!lastDiff || !equal(lastDiff.result, diff.result)) { c.callback(c.lastDiff = diff, lastDiff); } } From 58d320b70ac7fdf03289738a0ef6fee7216b7472 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 20 Apr 2021 10:01:45 -0400 Subject: [PATCH 155/380] Improve spy mocking for queryManager.refetchQueries test. https://github.com/apollographql/apollo-client/pull/8000#discussion_r616266128 --- src/__tests__/client.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/__tests__/client.ts b/src/__tests__/client.ts index 6f0f58ecb61..446693a53d9 100644 --- a/src/__tests__/client.ts +++ b/src/__tests__/client.ts @@ -2428,18 +2428,18 @@ describe('client', () => { }); it('has a refetchQueries method which calls QueryManager', async () => { - // TODO(dannycochran) const client = new ApolloClient({ link: ApolloLink.empty(), cache: new InMemoryCache(), }); - // @ts-ignore - const spy = jest.spyOn(client.queryManager, 'refetchQueries'); - await client.refetchQueries({ - include: ['Author1'], - }); - expect(spy).toHaveBeenCalled(); + const spy = jest.spyOn(client['queryManager'], 'refetchQueries'); + spy.mockImplementation(() => new Map); + + const options = { include: ['Author1'] }; + await client.refetchQueries(options); + + expect(spy).toHaveBeenCalledWith(options); }); itAsync('should propagate errors from network interface to observers', (resolve, reject) => { From 897a4ff559a4a9fc4e75f816cb742259fb6d6015 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 20 Apr 2021 10:20:44 -0400 Subject: [PATCH 156/380] Promote BatchOptions from ApolloCache module to Cache.BatchOptions. https://github.com/apollographql/apollo-client/pull/8000#discussion_r616275019 --- src/cache/core/cache.ts | 27 +---------------------- src/cache/core/types/Cache.ts | 34 +++++++++++++++++++++++++++++ src/cache/inmemory/inMemoryCache.ts | 6 ++--- 3 files changed, 38 insertions(+), 29 deletions(-) diff --git a/src/cache/core/cache.ts b/src/cache/core/cache.ts index 0dac825ef13..441d49a1ef0 100644 --- a/src/cache/core/cache.ts +++ b/src/cache/core/cache.ts @@ -11,31 +11,6 @@ import { Cache } from './types/Cache'; export type Transaction = (c: ApolloCache) => void; -export type BatchOptions> = { - // 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; - - removeOptimistic?: string; - - // 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. - onWatchUpdated?: ( - this: C, - watch: Cache.WatchOptions, - newDiff: Cache.DiffResult, - oldDiff?: Cache.DiffResult, - ) => any; -}; - export abstract class ApolloCache implements DataProxy { // required to implement // core API @@ -84,7 +59,7 @@ export abstract class ApolloCache implements DataProxy { // 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) { + public batch(options: Cache.BatchOptions) { const optimisticId = typeof options.optimistic === "string" ? options.optimistic : options.optimistic === false ? null : void 0; diff --git a/src/cache/core/types/Cache.ts b/src/cache/core/types/Cache.ts index 35bd4e1aab5..cba9499ec0a 100644 --- a/src/cache/core/types/Cache.ts +++ b/src/cache/core/types/Cache.ts @@ -1,5 +1,6 @@ import { DataProxy } from './DataProxy'; import { Modifier, Modifiers } from './common'; +import { ApolloCache } from '../cache'; export namespace Cache { export type WatchCallback = ( @@ -53,6 +54,39 @@ export namespace Cache { broadcast?: boolean; } + export interface BatchOptions> { + // 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 the + // given 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 (runing the batch operation + // against the current top layer of the cache), and passing false is the + // same as passing null (running the operation against root/non-optimistic + // cache data). + optimistic: string | boolean; + + // If you specify the ID of an optimistic layer using this option, that + // layer will be removed as part of the batch transaction, triggering at + // most one broadcast for both the transaction and the removal of the layer. + // Note: this option is needed because calling cache.removeOptimistic during + // the transaction function may not be not safe, since any modifications to + // cache layers may be discarded after the transaction finishes. + removeOptimistic?: string; + + // 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. + onWatchUpdated?: ( + this: C, + watch: Cache.WatchOptions, + diff: Cache.DiffResult, + lastDiff: Cache.DiffResult | undefined, + ) => any; + } + export import DiffResult = DataProxy.DiffResult; export import ReadQueryOptions = DataProxy.ReadQueryOptions; export import ReadFragmentOptions = DataProxy.ReadFragmentOptions; diff --git a/src/cache/inmemory/inMemoryCache.ts b/src/cache/inmemory/inMemoryCache.ts index e1c97759441..d5f6e557a03 100644 --- a/src/cache/inmemory/inMemoryCache.ts +++ b/src/cache/inmemory/inMemoryCache.ts @@ -5,7 +5,7 @@ import { DocumentNode } from 'graphql'; import { OptimisticWrapperFunction, wrap } from 'optimism'; import { equal } from '@wry/equality'; -import { ApolloCache, BatchOptions } from '../core/cache'; +import { ApolloCache } from '../core/cache'; import { Cache } from '../core/types/Cache'; import { MissingFieldError } from '../core/types/common'; import { @@ -39,7 +39,7 @@ export interface InMemoryCacheConfig extends ApolloReducerConfig { } type BroadcastOptions = Pick< - BatchOptions, + Cache.BatchOptions, | "optimistic" | "onWatchUpdated" > @@ -339,7 +339,7 @@ export class InMemoryCache extends ApolloCache { private txCount = 0; - public batch(options: BatchOptions) { + public batch(options: Cache.BatchOptions) { const { transaction, optimistic = true, From 6eb4d2c9539e670b6e1623e7d31f0119c05c5f2e Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 20 Apr 2021 10:34:14 -0400 Subject: [PATCH 157/380] Reuse diff/lastDiff terminology, instead of newDiff/oldDiff. https://github.com/apollographql/apollo-client/pull/8000#discussion_r616276917 --- src/cache/core/types/Cache.ts | 4 ++-- src/core/QueryManager.ts | 4 ++-- src/core/types.ts | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/cache/core/types/Cache.ts b/src/cache/core/types/Cache.ts index cba9499ec0a..b9a1b713cb0 100644 --- a/src/cache/core/types/Cache.ts +++ b/src/cache/core/types/Cache.ts @@ -4,8 +4,8 @@ import { ApolloCache } from '../cache'; export namespace Cache { export type WatchCallback = ( - newDiff: Cache.DiffResult, - oldDiff?: Cache.DiffResult, + diff: Cache.DiffResult, + lastDiff?: Cache.DiffResult, ) => void; export interface ReadOptions diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index 92ddf6e21d1..8c7afe75714 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -1137,14 +1137,14 @@ export class QueryManager { // temporary optimistic layer, in case that ever matters. removeOptimistic, - onWatchUpdated: onQueryUpdated && function (watch, newDiff, oldDiff) { + onWatchUpdated: onQueryUpdated && function (watch, diff, lastDiff) { const oq = watch.watcher instanceof QueryInfo && watch.watcher.observableQuery; if (oq) { includedQueriesById.delete(oq.queryId); - return maybeAddResult(oq, onQueryUpdated(oq, newDiff, oldDiff)); + return maybeAddResult(oq, onQueryUpdated(oq, diff, lastDiff)); } }, }); diff --git a/src/core/types.ts b/src/core/types.ts index 14333ba02a8..e849fa85875 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -17,8 +17,8 @@ export type QueryListener = (queryInfo: QueryInfo) => void; export type OnQueryUpdated = ( observableQuery: ObservableQuery, - newDiff: Cache.DiffResult, - oldDiff?: Cache.DiffResult, + diff: Cache.DiffResult, + lastDiff?: Cache.DiffResult, ) => boolean | Promise>; export type OperationVariables = Record; From 30fe80969958da6eb4e09173cbaae442d8b53dd9 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 20 Apr 2021 10:39:23 -0400 Subject: [PATCH 158/380] Consolidate options destructuring. https://github.com/apollographql/apollo-client/pull/8000#discussion_r616270942 --- src/cache/inmemory/inMemoryCache.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cache/inmemory/inMemoryCache.ts b/src/cache/inmemory/inMemoryCache.ts index d5f6e557a03..660c9bb22de 100644 --- a/src/cache/inmemory/inMemoryCache.ts +++ b/src/cache/inmemory/inMemoryCache.ts @@ -344,6 +344,7 @@ export class InMemoryCache extends ApolloCache { transaction, optimistic = true, removeOptimistic, + onWatchUpdated, } = options; const perform = (layer?: EntityStore) => { @@ -361,7 +362,6 @@ export class InMemoryCache extends ApolloCache { } }; - const { onWatchUpdated } = options; const alreadyDirty = new Set(); if (onWatchUpdated && !this.txCount) { From f913260913881bc00d599cbda5008468afcd4dfb Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 20 Apr 2021 11:31:19 -0400 Subject: [PATCH 159/380] {Public,Private}RefetchQueriesOptions => {,Internal}RefetchQueriesOptions https://github.com/apollographql/apollo-client/pull/8000#discussion_r616766562 --- src/core/ApolloClient.ts | 4 ++-- src/core/QueryManager.ts | 4 ++-- src/core/watchQueryOptions.ts | 9 ++++++--- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/core/ApolloClient.ts b/src/core/ApolloClient.ts index ed1fe05e365..7bf6185b84f 100644 --- a/src/core/ApolloClient.ts +++ b/src/core/ApolloClient.ts @@ -23,7 +23,7 @@ import { MutationOptions, SubscriptionOptions, WatchQueryFetchPolicy, - PublicRefetchQueriesOptions, + RefetchQueriesOptions, } from './watchQueryOptions'; import { @@ -539,7 +539,7 @@ export class ApolloClient implements DataProxy { TData, TCache extends ApolloCache = ApolloCache, >( - options: PublicRefetchQueriesOptions, + options: RefetchQueriesOptions, ): { queries: ObservableQuery[]; updates: any[]; diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index 8c7afe75714..a10e044878f 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -29,7 +29,7 @@ import { WatchQueryFetchPolicy, ErrorPolicy, RefetchQueryDescription, - PrivateRefetchQueriesOptions, + InternalRefetchQueriesOptions, } from './watchQueryOptions'; import { ObservableQuery } from './ObservableQuery'; import { NetworkStatus, isNetworkRequestInFlight } from './networkStatus'; @@ -1041,7 +1041,7 @@ export class QueryManager { optimistic = false, removeOptimistic = optimistic ? makeUniqueId("refetchQueries") : void 0, onQueryUpdated, - }: PrivateRefetchQueriesOptions>) { + }: InternalRefetchQueriesOptions>) { const includedQueriesById = new Map(); const results = new Map, any>(); diff --git a/src/core/watchQueryOptions.ts b/src/core/watchQueryOptions.ts index ac8c1cccfcc..7d4606c50f3 100644 --- a/src/core/watchQueryOptions.ts +++ b/src/core/watchQueryOptions.ts @@ -191,7 +191,9 @@ export interface SubscriptionOptions; -export interface PublicRefetchQueriesOptions< +// Used by ApolloClient["refetchQueries"] +// TODO Improve documentation comments for this public type. +export interface RefetchQueriesOptions< TData, TCache extends ApolloCache, > { @@ -205,10 +207,11 @@ export interface PublicRefetchQueriesOptions< onQueryUpdated?: OnQueryUpdated; } -export interface PrivateRefetchQueriesOptions< +// Used by QueryManager["refetchQueries"] +export interface InternalRefetchQueriesOptions< TData, TCache extends ApolloCache, -> extends Omit, "include"> { +> extends Omit, "include"> { // Just like the refetchQueries array for a mutation, allowing both strings // and PureQueryOptions objects. include?: RefetchQueryDescription; From 929feab237bb933de7244425060fa0926922b13b Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 20 Apr 2021 13:23:18 -0400 Subject: [PATCH 160/380] Rename cache.batch options.transaction function to options.update. I've never felt like "transaction" was exactly the right word for what cache.performTransaction and cache.batch are doing. For example, the updates are not automatically undone on failure like the word "transaction" suggests, unless you're recording against an optimistic layer that you plan to remove later, via cache.recordOptimisticTransaction and cache.removeOptimistic. In reality, the primary purpose of cache.performTransaction and cache.batch is to batch up broadcastWatches notifications for cache updates that take place within the callback function. I chose options.update because I think it's generic enough to avoid giving any mistaken interpretations of the purpose or usage of the function, unlike options.transaction. Separately, I believe options.update better aligns with the naming of the options.updateCache function for client.refetchQueries. Which may lead you to ask... Why not call it updateCache, exactly like client.refetchQueries? This is certainly a matter of subjective taste, but to me it feels redundant (and not useful for readability) to have to type the extra -Cache suffix every time you call cache.batch (which is more clearly a cache-updating method than client.refetchQueries is): cache.batch({ updateCache(cache) {...}, ... }) This commit will allow the following code, which eliminates the redundancy without sacrificing readability: cache.batch({ update(cache) {...}, ... }) For comparison, client.refetchQueries needs the extra specificity of updateCache because it gives readers of the code a clue about what's being updated (namely, the cache): client.refetchQueries({ updateCache(cache) {...}, ... }) --- src/cache/core/cache.ts | 2 +- src/cache/core/types/Cache.ts | 2 +- src/cache/inmemory/__tests__/cache.ts | 12 ++++---- src/cache/inmemory/inMemoryCache.ts | 43 +++++++++++++-------------- src/core/QueryManager.ts | 8 ++--- src/core/__tests__/ObservableQuery.ts | 2 +- 6 files changed, 34 insertions(+), 35 deletions(-) diff --git a/src/cache/core/cache.ts b/src/cache/core/cache.ts index 441d49a1ef0..db5c63879a8 100644 --- a/src/cache/core/cache.ts +++ b/src/cache/core/cache.ts @@ -63,7 +63,7 @@ export abstract class ApolloCache implements DataProxy { const optimisticId = typeof options.optimistic === "string" ? options.optimistic : options.optimistic === false ? null : void 0; - this.performTransaction(options.transaction, optimisticId); + this.performTransaction(options.update, optimisticId); } public abstract performTransaction( diff --git a/src/cache/core/types/Cache.ts b/src/cache/core/types/Cache.ts index b9a1b713cb0..e9ff92021c4 100644 --- a/src/cache/core/types/Cache.ts +++ b/src/cache/core/types/Cache.ts @@ -57,7 +57,7 @@ export namespace Cache { export interface BatchOptions> { // Same as the first parameter of performTransaction, except the cache // argument will have the subclass type rather than ApolloCache. - transaction(cache: C): void; + update(cache: C): void; // Passing a string for this option creates a new optimistic layer, with the // given string as its layer.id, just like passing a string for the diff --git a/src/cache/inmemory/__tests__/cache.ts b/src/cache/inmemory/__tests__/cache.ts index 6751b46fd8f..1db6fc073c6 100644 --- a/src/cache/inmemory/__tests__/cache.ts +++ b/src/cache/inmemory/__tests__/cache.ts @@ -1362,7 +1362,7 @@ describe('Cache', () => { const dirtied = new Map>(); cache.batch({ - transaction(cache) { + update(cache) { cache.writeQuery({ query: aQuery, data: { @@ -1403,7 +1403,7 @@ describe('Cache', () => { dirtied.clear(); cache.batch({ - transaction(cache) { + update(cache) { cache.writeQuery({ query: bQuery, data: { @@ -1474,7 +1474,7 @@ describe('Cache', () => { const dirtied = new Map>(); cache.batch({ - transaction(cache) { + update(cache) { cache.modify({ fields: { a(value, { INVALIDATE }) { @@ -1548,7 +1548,7 @@ describe('Cache', () => { const dirtied = new Map>(); cache.batch({ - transaction(cache) { + update(cache) { cache.modify({ fields: { a(value) { @@ -1571,7 +1571,7 @@ describe('Cache', () => { expect(aInfo.diffs).toEqual([ // This diff resulted from the cache.modify call in the cache.batch - // transaction function. + // update function. { complete: true, result: { @@ -1582,7 +1582,7 @@ describe('Cache', () => { expect(abInfo.diffs).toEqual([ // This diff resulted from the cache.modify call in the cache.batch - // transaction function. + // update function. { complete: true, result: { diff --git a/src/cache/inmemory/inMemoryCache.ts b/src/cache/inmemory/inMemoryCache.ts index 660c9bb22de..a154204250d 100644 --- a/src/cache/inmemory/inMemoryCache.ts +++ b/src/cache/inmemory/inMemoryCache.ts @@ -341,7 +341,7 @@ export class InMemoryCache extends ApolloCache { public batch(options: Cache.BatchOptions) { const { - transaction, + update, optimistic = true, removeOptimistic, onWatchUpdated, @@ -354,7 +354,7 @@ export class InMemoryCache extends ApolloCache { this.data = this.optimisticData = layer; } try { - transaction(this); + update(this); } finally { --this.txCount; this.data = data; @@ -365,16 +365,15 @@ export class InMemoryCache extends ApolloCache { const alreadyDirty = new Set(); if (onWatchUpdated && !this.txCount) { - // If an options.onWatchUpdated callback is provided, we want to - // call it with only the Cache.WatchOptions objects affected by - // options.transaction, but there might be dirty watchers already - // waiting to be broadcast that have nothing to do with the - // transaction. To prevent including those watchers in the - // post-transaction broadcast, we perform this initial broadcast to - // collect the dirty watchers, so we can re-dirty them later, after - // the post-transaction broadcast, allowing them to receive their - // pending broadcasts the next time broadcastWatches is called, just - // as they would if we never called cache.batch. + // If an options.onWatchUpdated callback is provided, we want to call it + // with only the Cache.WatchOptions objects affected by options.update, + // but there might be dirty watchers already waiting to be broadcast that + // have nothing to do with the update. To prevent including those watchers + // in the post-update broadcast, we perform this initial broadcast to + // collect the dirty watchers, so we can re-dirty them later, after the + // post-update broadcast, allowing them to receive their pending + // broadcasts the next time broadcastWatches is called, just as they would + // if we never called cache.batch. this.broadcastWatches({ ...options, onWatchUpdated(watch) { @@ -391,14 +390,14 @@ export class InMemoryCache extends ApolloCache { this.optimisticData = this.optimisticData.addLayer(optimistic, perform); } else if (optimistic === false) { // Ensure both this.data and this.optimisticData refer to the root - // (non-optimistic) layer of the cache during the transaction. Note - // that this.data could be a Layer if we are currently executing an - // optimistic transaction function, but otherwise will always be an - // EntityStore.Root instance. + // (non-optimistic) layer of the cache during the update. Note that + // this.data could be a Layer if we are currently executing an optimistic + // update function, but otherwise will always be an EntityStore.Root + // instance. perform(this.data); } else { - // Otherwise, leave this.data and this.optimisticData unchanged and - // run the transaction with broadcast batching. + // Otherwise, leave this.data and this.optimisticData unchanged and run + // the update with broadcast batching. perform(); } @@ -423,8 +422,8 @@ export class InMemoryCache extends ApolloCache { return result; } }); - // Silently re-dirty any watches that were already dirty before the - // transaction was performed, and were not broadcast just now. + // Silently re-dirty any watches that were already dirty before the update + // was performed, and were not broadcast just now. if (alreadyDirty.size) { alreadyDirty.forEach(watch => this.maybeBroadcastWatch.dirty(watch)); } @@ -437,11 +436,11 @@ export class InMemoryCache extends ApolloCache { } public performTransaction( - transaction: (cache: InMemoryCache) => any, + update: (cache: InMemoryCache) => any, optimisticId?: string | null, ) { return this.batch({ - transaction, + update, optimistic: optimisticId || (optimisticId !== null), }); } diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index a10e044878f..b7c694156ae 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -1095,11 +1095,11 @@ export class QueryManager { if (updateCache) { this.cache.batch({ - transaction: updateCache, + update: updateCache, // Since you can perform any combination of cache reads and/or writes in - // the cache.batch transaction function, its optimistic option can be - // either a boolean or a string, representing three distinct modes of + // the cache.batch update function, its optimistic option can be either + // a boolean or a string, representing three distinct modes of // operation: // // * false: read/write only the root layer @@ -1110,7 +1110,7 @@ export class QueryManager { // temporarily created within cache.batch with that string as its ID. If // we then pass that same string as the removeOptimistic option, we can // make cache.batch immediately remove the optimistic layer after - // running the transaction, triggering only one broadcast. + // running the updateCache function, triggering only one broadcast. // // However, the refetchQueries method accepts only true or false for its // optimistic option (not string). We interpret true to mean a temporary diff --git a/src/core/__tests__/ObservableQuery.ts b/src/core/__tests__/ObservableQuery.ts index 5dc2903cc49..7caa643e226 100644 --- a/src/core/__tests__/ObservableQuery.ts +++ b/src/core/__tests__/ObservableQuery.ts @@ -1957,7 +1957,7 @@ describe('ObservableQuery', () => { cache.batch({ optimistic: true, - transaction(cache) { + update(cache) { cache.modify({ fields: { people_one(value, { INVALIDATE }) { From 8a52c589262181c37b36af624d247bf44182fe22 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Wed, 21 Apr 2021 13:31:46 -0400 Subject: [PATCH 161/380] Make OnQueryUpdated lastDiff parameter non-optional. --- src/core/QueryManager.ts | 27 +++++++++++++++++---------- src/core/types.ts | 2 +- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index b7c694156ae..5afbbfe0c9c 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -1042,9 +1042,12 @@ export class QueryManager { removeOptimistic = optimistic ? makeUniqueId("refetchQueries") : void 0, onQueryUpdated, }: InternalRefetchQueriesOptions>) { - const includedQueriesById = new Map(); - const results = new Map, any>(); + const includedQueriesById = new Map | undefined; + }>(); + const results = new Map, any>(); function maybeAddResult(oq: ObservableQuery, result: any): boolean { // The onQueryUpdated function can return false to ignore this query and // skip its normal broadcast, or true to allow the usual broadcast to @@ -1076,7 +1079,10 @@ export class QueryManager { if (typeof queryNameOrOptions === "string") { const queryId = queryIdsByQueryName[queryNameOrOptions]; if (queryId) { - includedQueriesById.set(queryId, queryNameOrOptions); + includedQueriesById.set(queryId, { + desc: queryNameOrOptions, + diff: this.getQuery(queryId).getDiff(), + }); } else { invariant.warn(`Unknown query name ${ JSON.stringify(queryNameOrOptions) @@ -1087,7 +1093,8 @@ export class QueryManager { // We will be issuing a fresh network request for this query, so we // pre-allocate a new query ID here. this.generateQueryId(), - queryNameOrOptions, + { desc: queryNameOrOptions, + diff: void 0 }, ); } }); @@ -1151,23 +1158,23 @@ export class QueryManager { } if (includedQueriesById.size) { - includedQueriesById.forEach((queryNameOrOptions, queryId) => { + includedQueriesById.forEach(({ desc, diff }, queryId) => { const queryInfo = this.getQuery(queryId); let oq = queryInfo.observableQuery; if (oq) { maybeAddResult( oq, onQueryUpdated - ? onQueryUpdated(oq, queryInfo.getDiff()) + ? onQueryUpdated(oq, queryInfo.getDiff(), diff) : oq.refetch(), ); - } else if (typeof queryNameOrOptions === "object") { + } else if (typeof desc === "object") { const options: WatchQueryOptions = { - query: queryNameOrOptions.query, - variables: queryNameOrOptions.variables, + query: desc.query, + variables: desc.variables, fetchPolicy: "network-only", - context: queryNameOrOptions.context, + context: desc.context, }; queryInfo.setObservableQuery(oq = new ObservableQuery({ diff --git a/src/core/types.ts b/src/core/types.ts index e849fa85875..9fa0c0c48ee 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -18,7 +18,7 @@ export type QueryListener = (queryInfo: QueryInfo) => void; export type OnQueryUpdated = ( observableQuery: ObservableQuery, diff: Cache.DiffResult, - lastDiff?: Cache.DiffResult, + lastDiff: Cache.DiffResult | undefined, ) => boolean | Promise>; export type OperationVariables = Record; From 3439fd677e5f81741abf1f8ee238aa8f119888f5 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Wed, 21 Apr 2021 13:52:17 -0400 Subject: [PATCH 162/380] Improve/simplify refetchQueries options.include processing. --- src/core/QueryManager.ts | 116 ++++++++++++++++++---------------- src/core/watchQueryOptions.ts | 3 +- 2 files changed, 64 insertions(+), 55 deletions(-) diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index 5afbbfe0c9c..dac7afa42a8 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -30,6 +30,7 @@ import { ErrorPolicy, RefetchQueryDescription, InternalRefetchQueriesOptions, + RefetchQueryDescriptor, } from './watchQueryOptions'; import { ObservableQuery } from './ObservableQuery'; import { NetworkStatus, isNetworkRequestInFlight } from './networkStatus'; @@ -579,6 +580,7 @@ export class QueryManager { public query( options: QueryOptions, + queryId = this.generateQueryId(), ): Promise> { invariant( options.query, @@ -601,7 +603,6 @@ export class QueryManager { 'pollInterval option only supported on watchQuery.', ); - const queryId = this.generateQueryId(); return this.fetchQuery( queryId, options, @@ -1043,10 +1044,23 @@ export class QueryManager { onQueryUpdated, }: InternalRefetchQueriesOptions>) { const includedQueriesById = new Map | undefined; }>(); + if (include) { + include.forEach(desc => { + getQueryIdsForQueryDescriptor(this, desc).forEach(queryId => { + includedQueriesById.set(queryId, { + desc, + diff: typeof desc === "string" + ? this.getQuery(queryId).getDiff() + : void 0, + }); + }); + }); + } + const results = new Map, any>(); function maybeAddResult(oq: ObservableQuery, result: any): boolean { // The onQueryUpdated function can return false to ignore this query and @@ -1065,41 +1079,6 @@ export class QueryManager { return false; } - if (include) { - const queryIdsByQueryName: Record = Object.create(null); - this.queries.forEach((queryInfo, queryId) => { - const oq = queryInfo.observableQuery; - const queryName = oq && oq.queryName; - if (queryName) { - queryIdsByQueryName[queryName] = queryId; - } - }); - - include.forEach(queryNameOrOptions => { - if (typeof queryNameOrOptions === "string") { - const queryId = queryIdsByQueryName[queryNameOrOptions]; - if (queryId) { - includedQueriesById.set(queryId, { - desc: queryNameOrOptions, - diff: this.getQuery(queryId).getDiff(), - }); - } else { - invariant.warn(`Unknown query name ${ - JSON.stringify(queryNameOrOptions) - } passed to refetchQueries method in options.include array`); - } - } else { - includedQueriesById.set( - // We will be issuing a fresh network request for this query, so we - // pre-allocate a new query ID here. - this.generateQueryId(), - { desc: queryNameOrOptions, - diff: void 0 }, - ); - } - }); - } - if (updateCache) { this.cache.batch({ update: updateCache, @@ -1161,21 +1140,15 @@ export class QueryManager { includedQueriesById.forEach(({ desc, diff }, queryId) => { const queryInfo = this.getQuery(queryId); let oq = queryInfo.observableQuery; - if (oq) { - maybeAddResult( - oq, - onQueryUpdated - ? onQueryUpdated(oq, queryInfo.getDiff(), diff) - : oq.refetch(), - ); + let fallback: undefined | (() => Promise>); - } else if (typeof desc === "object") { - const options: WatchQueryOptions = { - query: desc.query, - variables: desc.variables, + if (typeof desc === "string") { + fallback = () => oq!.refetch(); + } else if (desc && typeof desc === "object") { + const options = { + ...desc, fetchPolicy: "network-only", - context: desc.context, - }; + } as QueryOptions; queryInfo.setObservableQuery(oq = new ObservableQuery({ queryManager: this, @@ -1183,10 +1156,19 @@ export class QueryManager { options, })); - const fetchPromise = this.fetchQuery(queryId, options); - maybeAddResult(oq, fetchPromise); - const stop = () => this.stopQuery(queryId); - fetchPromise.then(stop, stop); + fallback = () => this.query(options, queryId); + } + + if (oq && fallback) { + maybeAddResult( + oq, + // If onQueryUpdated is provided, we want to use it for all included + // queries, even the PureQueryOptions ones. Otherwise, we call the + // fallback function defined above. + onQueryUpdated + ? onQueryUpdated(oq, queryInfo.getDiff(), diff) + : fallback(), + ); } }); } @@ -1375,6 +1357,32 @@ export class QueryManager { } } +function getQueryIdsForQueryDescriptor( + qm: QueryManager, + desc: RefetchQueryDescriptor, +) { + const queryIds: string[] = []; + if (typeof desc === "string") { + qm["queries"].forEach(({ observableQuery: oq }, queryId) => { + if (oq && + oq.queryName === desc && + oq.hasObservers()) { + queryIds.push(queryId); + } + }); + } else { + // We will be issuing a fresh network request for this query, so we + // pre-allocate a new query ID here. + queryIds.push(qm.generateQueryId()); + } + if (process.env.NODE_ENV !== "production" && !queryIds.length) { + invariant.warn(`Unknown query name ${ + JSON.stringify(desc) + } passed to refetchQueries method in options.include array`); + } + return queryIds; +} + const prefixCounts: Record = Object.create(null); function makeUniqueId(prefix: string) { const count = prefixCounts[prefix] || 1; diff --git a/src/core/watchQueryOptions.ts b/src/core/watchQueryOptions.ts index 7d4606c50f3..32b20a3ac71 100644 --- a/src/core/watchQueryOptions.ts +++ b/src/core/watchQueryOptions.ts @@ -189,7 +189,8 @@ export interface SubscriptionOptions; +export type RefetchQueryDescriptor = string | PureQueryOptions; +export type RefetchQueryDescription = RefetchQueryDescriptor[]; // Used by ApolloClient["refetchQueries"] // TODO Improve documentation comments for this public type. From c9d0428b617f5fb7f5f4fdde4896f9372633924c Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Wed, 21 Apr 2021 19:00:32 -0400 Subject: [PATCH 163/380] Make client.refetchQueries(...) awaitable, and rename results array. --- src/core/ApolloClient.ts | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/src/core/ApolloClient.ts b/src/core/ApolloClient.ts index 7bf6185b84f..f461257eff3 100644 --- a/src/core/ApolloClient.ts +++ b/src/core/ApolloClient.ts @@ -540,20 +540,30 @@ export class ApolloClient implements DataProxy { TCache extends ApolloCache = ApolloCache, >( options: RefetchQueriesOptions, - ): { + ): PromiseLike[]> & { queries: ObservableQuery[]; - updates: any[]; + results: any[]; } { const map = this.queryManager.refetchQueries(options); const queries: ObservableQuery[] = []; - const updates: any[] = []; + const results: any[] = []; - map.forEach((result, obsQuery) => { + map.forEach((update, obsQuery) => { queries.push(obsQuery); - updates.push(result); + results.push(update); }); - return { queries, updates }; + return { + // In case you need the raw results immediately, without awaiting + // Promise.all(results): + queries, + results, + // Using a thenable instead of a Promise here allows us to avoid creating + // the Promise if it isn't going to be awaited. + then(onResolved, onRejected) { + return Promise.all(results).then(onResolved, onRejected); + }, + }; } /** From 6bfa317e39c0f76d4b47462ddefc78e450f282e9 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Thu, 22 Apr 2021 14:26:58 -0400 Subject: [PATCH 164/380] Call fallback() if onQueryUpdated returns true for an included query. --- src/core/QueryManager.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index dac7afa42a8..cb4c87dacd8 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -1160,15 +1160,15 @@ export class QueryManager { } if (oq && fallback) { - maybeAddResult( - oq, - // If onQueryUpdated is provided, we want to use it for all included - // queries, even the PureQueryOptions ones. Otherwise, we call the - // fallback function defined above. - onQueryUpdated - ? onQueryUpdated(oq, queryInfo.getDiff(), diff) - : fallback(), - ); + // If onQueryUpdated is provided, we want to use it for all included + // queries, even the PureQueryOptions ones. Otherwise, we call the + // fallback function defined above. + let result = onQueryUpdated && + onQueryUpdated(oq, queryInfo.getDiff(), diff); + if (!onQueryUpdated || result === true) { + result = fallback(); + } + maybeAddResult(oq, result); } }); } From 0e9d5a5a125cadeb7c78d911571e6f7aa5222fc8 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 11 May 2021 17:12:46 -0400 Subject: [PATCH 165/380] Allow onQueryUpdated functions to return anything. The true and false return values still have their special meanings, but anything else goes (not just Promise>). The type of whatever you return should be inferred by TypeScript, so the results array you get by awaiting client.refetchQueries will have the correct type: client.refetchQueries({ ... onQueryUpdated() { return Promise.resolve("value"); } }).then(results => { // TypeScript knows results is a string[], not a Promise[] }); I also removed the TData type parameter from the OnQueryUpdated function type, since there's not usually enough type information for TypeScript to figure out what TData is. --- src/core/ApolloClient.ts | 9 +++++---- src/core/QueryManager.ts | 8 ++++---- src/core/types.ts | 13 ++++++++----- src/core/watchQueryOptions.ts | 10 +++++----- 4 files changed, 22 insertions(+), 18 deletions(-) diff --git a/src/core/ApolloClient.ts b/src/core/ApolloClient.ts index f461257eff3..8f5b79164f3 100644 --- a/src/core/ApolloClient.ts +++ b/src/core/ApolloClient.ts @@ -14,6 +14,7 @@ import { ApolloQueryResult, DefaultContext, OperationVariables, + PromiseResult, Resolvers, } from './types'; @@ -536,13 +537,13 @@ export class ApolloClient implements DataProxy { * Takes optional parameter `includeStandby` which will include queries in standby-mode when refetching. */ public refetchQueries< - TData, TCache extends ApolloCache = ApolloCache, + TResult = any, >( - options: RefetchQueriesOptions, - ): PromiseLike[]> & { + options: RefetchQueriesOptions, + ): PromiseLike[]> & { queries: ObservableQuery[]; - results: any[]; + results: TResult[]; } { const map = this.queryManager.refetchQueries(options); const queries: ObservableQuery[] = []; diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index cb4c87dacd8..579b333e4fb 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -330,7 +330,7 @@ export class QueryManager { update?: MutationUpdaterFunction; refetchQueries?: RefetchQueryDescription; removeOptimistic?: string; - onQueryUpdated?: OnQueryUpdated; + onQueryUpdated?: OnQueryUpdated; }, cache = this.cache, ): Promise { @@ -1036,13 +1036,13 @@ export class QueryManager { return concast; } - public refetchQueries({ + public refetchQueries({ updateCache, include, optimistic = false, removeOptimistic = optimistic ? makeUniqueId("refetchQueries") : void 0, onQueryUpdated, - }: InternalRefetchQueriesOptions>) { + }: InternalRefetchQueriesOptions, TResult>) { const includedQueriesById = new Map | undefined; @@ -1140,7 +1140,7 @@ export class QueryManager { includedQueriesById.forEach(({ desc, diff }, queryId) => { const queryInfo = this.getQuery(queryId); let oq = queryInfo.observableQuery; - let fallback: undefined | (() => Promise>); + let fallback: undefined | (() => any); if (typeof desc === "string") { fallback = () => oq!.refetch(); diff --git a/src/core/types.ts b/src/core/types.ts index 9fa0c0c48ee..5be464545e1 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -15,11 +15,14 @@ export type DefaultContext = Record; export type QueryListener = (queryInfo: QueryInfo) => void; -export type OnQueryUpdated = ( - observableQuery: ObservableQuery, - diff: Cache.DiffResult, - lastDiff: Cache.DiffResult | undefined, -) => boolean | Promise>; +export type OnQueryUpdated = ( + observableQuery: ObservableQuery, + diff: Cache.DiffResult, + lastDiff: Cache.DiffResult | undefined, +) => boolean | TResult; + +export type PromiseResult = + T extends PromiseLike ? U : T; export type OperationVariables = Record; diff --git a/src/core/watchQueryOptions.ts b/src/core/watchQueryOptions.ts index 32b20a3ac71..c2e217489a7 100644 --- a/src/core/watchQueryOptions.ts +++ b/src/core/watchQueryOptions.ts @@ -195,8 +195,8 @@ export type RefetchQueryDescription = RefetchQueryDescriptor[]; // Used by ApolloClient["refetchQueries"] // TODO Improve documentation comments for this public type. export interface RefetchQueriesOptions< - TData, TCache extends ApolloCache, + TResult, > { updateCache?: (cache: TCache) => void; // Although you can pass PureQueryOptions objects in addition to strings in @@ -205,14 +205,14 @@ export interface RefetchQueriesOptions< // public type of the options.include array to string[] (just query names). include?: string[]; optimistic?: boolean; - onQueryUpdated?: OnQueryUpdated; + onQueryUpdated?: OnQueryUpdated; } // Used by QueryManager["refetchQueries"] export interface InternalRefetchQueriesOptions< - TData, TCache extends ApolloCache, -> extends Omit, "include"> { + TResult, +> extends Omit, "include"> { // Just like the refetchQueries array for a mutation, allowing both strings // and PureQueryOptions objects. include?: RefetchQueryDescription; @@ -290,7 +290,7 @@ export interface MutationBaseOptions< * A function that will be called for each ObservableQuery affected by * this mutation, after the mutation has completed. */ - onQueryUpdated?: OnQueryUpdated; + onQueryUpdated?: OnQueryUpdated; /** * Specifies the {@link ErrorPolicy} to be used for this operation From 6bc76acad5223d176449943d17c0ccfff2cc12af Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 11 May 2021 17:48:29 -0400 Subject: [PATCH 166/380] Make explicitly-included refetchQueries queries (re)read from cache. --- src/core/QueryManager.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index 579b333e4fb..1183f35902b 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -1160,11 +1160,14 @@ export class QueryManager { } if (oq && fallback) { + let result: boolean | TResult | undefined; // If onQueryUpdated is provided, we want to use it for all included // queries, even the PureQueryOptions ones. Otherwise, we call the // fallback function defined above. - let result = onQueryUpdated && - onQueryUpdated(oq, queryInfo.getDiff(), diff); + if (onQueryUpdated) { + queryInfo.reset(); // Force queryInfo.getDiff() to read from cache. + result = onQueryUpdated(oq, queryInfo.getDiff(), diff); + } if (!onQueryUpdated || result === true) { result = fallback(); } From fd8a94c414e58a781ce12ea8a2c7a566661ddebe Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 11 May 2021 15:01:04 -0400 Subject: [PATCH 167/380] Basic tests of client.refetchQueries API. --- src/__tests__/refetchQueries.ts | 256 ++++++++++++++++++++++++++++++++ 1 file changed, 256 insertions(+) create mode 100644 src/__tests__/refetchQueries.ts diff --git a/src/__tests__/refetchQueries.ts b/src/__tests__/refetchQueries.ts new file mode 100644 index 00000000000..1c8081ba1eb --- /dev/null +++ b/src/__tests__/refetchQueries.ts @@ -0,0 +1,256 @@ +import { Subscription } from "zen-observable-ts"; + +import { itAsync } from '../utilities/testing/itAsync'; +import { + ApolloClient, + ApolloLink, + InMemoryCache, + gql, + Observable, + TypedDocumentNode, + ObservableQuery, +} from "../core"; + +describe("client.refetchQueries", () => { + itAsync("is public and callable", (resolve, reject) => { + const client = new ApolloClient({ + cache: new InMemoryCache, + }); + expect(typeof client.refetchQueries).toBe("function"); + + const result = client.refetchQueries({ + updateCache(cache) { + expect(cache).toBe(client.cache); + expect(cache.extract()).toEqual({}); + }, + onQueryUpdated(obsQuery, diff) { + reject("should not have called onQueryUpdated"); + return false; + }, + }); + + expect(result.queries).toEqual([]); + expect(result.results).toEqual([]); + + result.then(resolve, reject); + }); + + const aQuery: TypedDocumentNode<{ a: string }> = gql`query A { a }`; + const bQuery: TypedDocumentNode<{ b: string }> = gql`query B { b }`; + const abQuery: TypedDocumentNode<{ + a: string; + b: string; + }> = gql`query AB { a b }`; + + function makeClient() { + return new ApolloClient({ + cache: new InMemoryCache, + link: new ApolloLink(operation => new Observable(observer => { + const data: Record = {}; + operation.operationName.split("").forEach(letter => { + data[letter.toLowerCase()] = letter.toUpperCase(); + }); + observer.next({ data }); + observer.complete(); + })), + }); + } + + const subs: Subscription[] = []; + function unsubscribe() { + subs.splice(0).forEach(sub => sub.unsubscribe()); + } + + function setup(client = makeClient()) { + function watch(query: TypedDocumentNode) { + const obsQuery = client.watchQuery({ query }); + return new Promise>((resolve, reject) => { + subs.push(obsQuery.subscribe({ + error: reject, + next(result) { + expect(result.loading).toBe(false); + resolve(obsQuery); + }, + })); + }); + } + + return Promise.all([ + watch(aQuery), + watch(bQuery), + watch(abQuery), + ]); + } + + // Not a great way to sort objects, but it will give us stable orderings in + // these specific tests (especially since the keys are all "a" and/or "b"). + function sortObjects(array: T) { + array.sort((a, b) => { + const aKey = Object.keys(a).join(","); + const bKey = Object.keys(b).join(","); + if (aKey < bKey) return -1; + if (bKey < aKey) return 1; + return 0; + }); + } + + itAsync("includes watched queries affected by updateCache", async (resolve, reject) => { + const client = makeClient(); + const [ + aObs, + bObs, + abObs, + ] = await setup(client); + + const ayyResults = await client.refetchQueries({ + updateCache(cache) { + cache.writeQuery({ + query: aQuery, + data: { + a: "Ayy", + }, + }); + }, + + onQueryUpdated(obs, diff) { + if (obs === aObs) { + expect(diff.result).toEqual({ a: "Ayy" }); + } else if (obs === bObs) { + reject("bQuery should not have been updated"); + } else if (obs === abObs) { + expect(diff.result).toEqual({ a: "Ayy", b: "B" }); + } else { + reject("unexpected ObservableQuery"); + } + return Promise.resolve(diff.result); + }, + }); + + sortObjects(ayyResults); + + expect(ayyResults).toEqual([ + { a: "Ayy" }, + { a: "Ayy", b: "B" }, + // Note that no bQuery result is included here. + ]); + + const beeResults = await client.refetchQueries({ + updateCache(cache) { + cache.writeQuery({ + query: bQuery, + data: { + b: "Bee", + }, + }); + }, + + onQueryUpdated(obs, diff) { + if (obs === aObs) { + reject("aQuery should not have been updated"); + } else if (obs === bObs) { + expect(diff.result).toEqual({ b: "Bee" }); + } else if (obs === abObs) { + expect(diff.result).toEqual({ a: "Ayy", b: "Bee" }); + } else { + reject("unexpected ObservableQuery"); + } + return diff.result; + }, + }); + + sortObjects(beeResults); + + expect(beeResults).toEqual([ + // Note that no aQuery result is included here. + { a: "Ayy", b: "Bee" }, + { b: "Bee" }, + ]); + + unsubscribe(); + resolve(); + }); + + itAsync("includes watched queries named in options.include", async (resolve, reject) => { + const client = makeClient(); + const [ + aObs, + bObs, + abObs, + ] = await setup(client); + + const ayyResults = await client.refetchQueries({ + updateCache(cache) { + cache.writeQuery({ + query: aQuery, + data: { + a: "Ayy", + }, + }); + }, + + // This is the options.include array mentioned in the test description. + include: ["B"], + + onQueryUpdated(obs, diff) { + if (obs === aObs) { + expect(diff.result).toEqual({ a: "Ayy" }); + } else if (obs === bObs) { + expect(diff.result).toEqual({ b: "B" }); + } else if (obs === abObs) { + expect(diff.result).toEqual({ a: "Ayy", b: "B" }); + } else { + reject("unexpected ObservableQuery"); + } + return Promise.resolve(diff.result); + }, + }); + + sortObjects(ayyResults); + + expect(ayyResults).toEqual([ + { a: "Ayy" }, + { a: "Ayy", b: "B" }, + // Included this time! + { b: "B" }, + ]); + + const beeResults = await client.refetchQueries({ + updateCache(cache) { + cache.writeQuery({ + query: bQuery, + data: { + b: "Bee", + }, + }); + }, + + // The "A" here causes aObs to be included, but the "AB" should be + // redundant because that query is already included. + include: ["A", "AB"], + + onQueryUpdated(obs, diff) { + if (obs === aObs) { + expect(diff.result).toEqual({ a: "Ayy" }); + } else if (obs === bObs) { + expect(diff.result).toEqual({ b: "Bee" }); + } else if (obs === abObs) { + expect(diff.result).toEqual({ a: "Ayy", b: "Bee" }); + } else { + reject("unexpected ObservableQuery"); + } + return diff.result; + }, + }); + + sortObjects(beeResults); + + expect(beeResults).toEqual([ + { a: "Ayy" }, // Included this time! + { a: "Ayy", b: "Bee" }, + { b: "Bee" }, + ]); + + unsubscribe(); + resolve(); + }); +}); From e02f8a11f807686873d3a3ba269b84041829e5e7 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 11 May 2021 19:42:14 -0400 Subject: [PATCH 168/380] Refetch updateCache-triggered queries if onQueryUpdated not provided. --- src/__tests__/refetchQueries.ts | 45 +++++++++++++++++++++++++++++++++ src/core/ApolloClient.ts | 2 +- src/core/QueryManager.ts | 21 ++++++++++++--- src/core/watchQueryOptions.ts | 7 ++++- 4 files changed, 69 insertions(+), 6 deletions(-) diff --git a/src/__tests__/refetchQueries.ts b/src/__tests__/refetchQueries.ts index 1c8081ba1eb..eef4723002e 100644 --- a/src/__tests__/refetchQueries.ts +++ b/src/__tests__/refetchQueries.ts @@ -253,4 +253,49 @@ describe("client.refetchQueries", () => { unsubscribe(); resolve(); }); + + itAsync("refetches watched queries if onQueryUpdated not provided", async (resolve, reject) => { + const client = makeClient(); + const [ + aObs, + bObs, + abObs, + ] = await setup(client); + + const aSpy = jest.spyOn(aObs, "refetch"); + const bSpy = jest.spyOn(bObs, "refetch"); + const abSpy = jest.spyOn(abObs, "refetch"); + + const ayyResults = ( + await client.refetchQueries({ + include: ["B"], + updateCache(cache) { + cache.writeQuery({ + query: aQuery, + data: { + a: "Ayy", + }, + }); + }, + }) + ).map(result => result.data as object); + + sortObjects(ayyResults); + + // These results have reverted back to what the ApolloLink returns ("A" + // rather than "Ayy"), because we let them be refetched (by not providing + // an onQueryUpdated function). + expect(ayyResults).toEqual([ + { a: "A" }, + { a: "A", b: "B" }, + { b: "B" }, + ]); + + expect(aSpy).toHaveBeenCalledTimes(1); + expect(bSpy).toHaveBeenCalledTimes(1); + expect(abSpy).toHaveBeenCalledTimes(1); + + unsubscribe(); + resolve(); + }); }); diff --git a/src/core/ApolloClient.ts b/src/core/ApolloClient.ts index 8f5b79164f3..ae8b27705b4 100644 --- a/src/core/ApolloClient.ts +++ b/src/core/ApolloClient.ts @@ -538,7 +538,7 @@ export class ApolloClient implements DataProxy { */ public refetchQueries< TCache extends ApolloCache = ApolloCache, - TResult = any, + TResult = Promise>, >( options: RefetchQueriesOptions, ): PromiseLike[]> & { diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index 1183f35902b..5462fb12d1f 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -410,7 +410,9 @@ export class QueryManager { // Let the caller of client.mutate optionally determine the refetching // behavior for watched queries after the mutation.update function runs. - onQueryUpdated: mutation.onQueryUpdated, + // If no onQueryUpdated function was provided for this mutation, pass + // null instead of undefined to disable the default refetching behavior. + onQueryUpdated: mutation.onQueryUpdated || null, }).forEach(result => results.push(result)); @@ -1123,14 +1125,25 @@ export class QueryManager { // temporary optimistic layer, in case that ever matters. removeOptimistic, - onWatchUpdated: onQueryUpdated && function (watch, diff, lastDiff) { + onWatchUpdated(watch, diff, lastDiff) { const oq = watch.watcher instanceof QueryInfo && watch.watcher.observableQuery; if (oq) { - includedQueriesById.delete(oq.queryId); - return maybeAddResult(oq, onQueryUpdated(oq, diff, lastDiff)); + if (onQueryUpdated) { + includedQueriesById.delete(oq.queryId); + return maybeAddResult(oq, onQueryUpdated(oq, diff, lastDiff)); + } + if (onQueryUpdated !== null) { + // If we don't have an onQueryUpdated function, and onQueryUpdated + // was not disabled by passing null, make sure this query is + // "included" like any other options.include-specified query. + includedQueriesById.set(oq.queryId, { + desc: oq.queryName || ``, + diff, + }); + } } }, }); diff --git a/src/core/watchQueryOptions.ts b/src/core/watchQueryOptions.ts index c2e217489a7..4543abcb44e 100644 --- a/src/core/watchQueryOptions.ts +++ b/src/core/watchQueryOptions.ts @@ -205,7 +205,12 @@ export interface RefetchQueriesOptions< // public type of the options.include array to string[] (just query names). include?: string[]; optimistic?: boolean; - onQueryUpdated?: OnQueryUpdated; + // If no onQueryUpdated function is provided, any queries affected by the + // updateCache function or included in the options.include array will be + // refetched by default. Passing null instead of undefined disables this + // default refetching behavior for affected queries, though included queries + // will still be refetched. + onQueryUpdated?: OnQueryUpdated | null; } // Used by QueryManager["refetchQueries"] From 967ade34b3bc096f844ca907344456057075f272 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Wed, 12 May 2021 11:13:29 -0400 Subject: [PATCH 169/380] Add a few TODOs to client.refetchQueries test file. --- src/__tests__/refetchQueries.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/__tests__/refetchQueries.ts b/src/__tests__/refetchQueries.ts index eef4723002e..e30d7cd8efd 100644 --- a/src/__tests__/refetchQueries.ts +++ b/src/__tests__/refetchQueries.ts @@ -298,4 +298,20 @@ describe("client.refetchQueries", () => { unsubscribe(); resolve(); }); + + it("can run updateQuery function against optimistic cache layer", () => { + // TODO + }); + + it("can return true from onQueryUpdated to choose default refetching behavior", () => { + // TODO + }); + + it("can return false from onQueryUpdated to skip the updated query", () => { + // TODO + }); + + it("can refetch no-cache queries", () => { + // TODO + }); }); From dfe9e684d3bdebc6445a68a54ed2bfa0f36ebb07 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 12 Jan 2021 14:34:16 -0500 Subject: [PATCH 170/380] Make dirtying of removed EntityStore Layer fields more precise. Previously, removing an optimistic EntityStore layer with removeOptimistic would dirty all fields of any StoreObjects contained by the removed layer, ignoring the possibility that some of those field values might have been inherited as-is from the parent layer. With this commit, we dirty only those fields whose values will be observably changed by removing the layer, which requires comparing field values between the layer-to-be-removed and its parent layer. --- src/cache/inmemory/entityStore.ts | 38 ++++++++++++++++++++++++++----- 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/src/cache/inmemory/entityStore.ts b/src/cache/inmemory/entityStore.ts index 5bf4792e026..82afc70a73d 100644 --- a/src/cache/inmemory/entityStore.ts +++ b/src/cache/inmemory/entityStore.ts @@ -642,18 +642,44 @@ class Layer extends EntityStore { const parent = this.parent.removeLayer(layerId); if (layerId === this.id) { - // Dirty every ID we're removing. if (this.group.caching) { + // Dirty every ID we're removing. Technically we might be able to avoid + // dirtying fields that have values in higher layers, but we don't have + // easy access to higher layers here, and we're about to recreate those + // layers anyway (see parent.addLayer below). Object.keys(this.data).forEach(dataId => { - // If this.data[dataId] contains nothing different from what - // lies beneath, we can avoid dirtying this dataId and all of - // its fields, and simply discard this Layer. The only reason we - // call this.delete here is to dirty the removed fields. - if (this.data[dataId] !== (parent as Layer).lookup(dataId)) { + const ownStoreObject = this.data[dataId]; + const parentStoreObject = parent["lookup"](dataId); + if (!parentStoreObject) { + // The StoreObject identified by dataId was defined in this layer + // but will be undefined in the parent layer, so we can delete the + // whole entity using this.delete(dataId). Since we're about to + // throw this layer away, the only goal of this deletion is to dirty + // the removed fields. this.delete(dataId); + } else if (!ownStoreObject) { + // This layer had an entry for dataId but it was undefined, which + // means the entity was deleted in this layer, and it's about to + // become undeleted when we remove this layer, so we need to dirty + // all fields that are about to be reexposed. + this.group.dirty(dataId, "__exists"); + Object.keys(parentStoreObject).forEach(storeFieldName => { + this.group.dirty(dataId, storeFieldName); + }); + } else if (ownStoreObject !== parentStoreObject) { + // If ownStoreObject is not exactly the same as parentStoreObject, + // dirty any fields whose values will change as a result of this + // removal. + Object.keys(ownStoreObject).forEach(storeFieldName => { + if (!equal(ownStoreObject[storeFieldName], + parentStoreObject[storeFieldName])) { + this.group.dirty(dataId, storeFieldName); + } + }); } }); } + return parent; } From bee43b0ccb6791e985e907391c373ba67ea00a88 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Wed, 12 May 2021 18:08:45 -0400 Subject: [PATCH 171/380] Test client.refetchQueries({ optimistic: true }). --- src/__tests__/refetchQueries.ts | 80 +++++++++++++++++++++++++++++++-- 1 file changed, 77 insertions(+), 3 deletions(-) diff --git a/src/__tests__/refetchQueries.ts b/src/__tests__/refetchQueries.ts index e30d7cd8efd..41474f2d409 100644 --- a/src/__tests__/refetchQueries.ts +++ b/src/__tests__/refetchQueries.ts @@ -23,7 +23,7 @@ describe("client.refetchQueries", () => { expect(cache).toBe(client.cache); expect(cache.extract()).toEqual({}); }, - onQueryUpdated(obsQuery, diff) { + onQueryUpdated() { reject("should not have called onQueryUpdated"); return false; }, @@ -299,8 +299,82 @@ describe("client.refetchQueries", () => { resolve(); }); - it("can run updateQuery function against optimistic cache layer", () => { - // TODO + itAsync("can run updateQuery function against optimistic cache layer", async (resolve, reject) => { + const client = makeClient(); + const [ + aObs, + bObs, + abObs, + ] = await setup(client); + + client.cache.watch({ + query: abQuery, + optimistic: false, + callback(diff) { + reject("should not have notified non-optimistic watcher"); + }, + }); + + expect(client.cache.extract(true)).toEqual({ + ROOT_QUERY: { + __typename: "Query", + "a": "A", + "b": "B", + }, + }); + + const results = await client.refetchQueries({ + // This causes the update to run against a temporary optimistic layer. + optimistic: true, + + updateCache(cache) { + const modified = cache.modify({ + fields: { + a(value, { DELETE }) { + expect(value).toEqual("A"); + return DELETE; + }, + }, + }); + expect(modified).toBe(true); + }, + + onQueryUpdated(obs, diff) { + expect(diff.complete).toBe(true); + + // Even though we evicted the Query.a field in the updateCache function, + // that optimistic layer was discarded before broadcasting results, so + // we're back to the original (non-optimistic) data. + if (obs === aObs) { + expect(diff.result).toEqual({ a: "A" }); + } else if (obs === bObs) { + reject("bQuery should not have been updated"); + } else if (obs === abObs) { + expect(diff.result).toEqual({ a: "A", b: "B" }); + } else { + reject("unexpected ObservableQuery"); + } + + return diff.result; + }, + }); + + sortObjects(results); + + expect(results).toEqual([ + { a: "A" }, + { a: "A", b: "B" }, + ]); + + expect(client.cache.extract(true)).toEqual({ + ROOT_QUERY: { + __typename: "Query", + "a": "A", + "b": "B", + }, + }); + + resolve(); }); it("can return true from onQueryUpdated to choose default refetching behavior", () => { From 70bd843e436917019cd0ccf936e8f037ecf56f5e Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Thu, 13 May 2021 14:42:11 -0400 Subject: [PATCH 172/380] Move refetchQueries-related types into src/core/types.ts. --- src/core/ApolloClient.ts | 2 +- src/core/QueryManager.ts | 6 +++--- src/core/types.ts | 37 +++++++++++++++++++++++++++++++++ src/core/watchQueryOptions.ts | 39 +---------------------------------- 4 files changed, 42 insertions(+), 42 deletions(-) diff --git a/src/core/ApolloClient.ts b/src/core/ApolloClient.ts index ae8b27705b4..f19a044683b 100644 --- a/src/core/ApolloClient.ts +++ b/src/core/ApolloClient.ts @@ -16,6 +16,7 @@ import { OperationVariables, PromiseResult, Resolvers, + RefetchQueriesOptions, } from './types'; import { @@ -24,7 +25,6 @@ import { MutationOptions, SubscriptionOptions, WatchQueryFetchPolicy, - RefetchQueriesOptions, } from './watchQueryOptions'; import { diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index 5462fb12d1f..a65955266ef 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -28,9 +28,6 @@ import { MutationOptions, WatchQueryFetchPolicy, ErrorPolicy, - RefetchQueryDescription, - InternalRefetchQueriesOptions, - RefetchQueryDescriptor, } from './watchQueryOptions'; import { ObservableQuery } from './ObservableQuery'; import { NetworkStatus, isNetworkRequestInFlight } from './networkStatus'; @@ -39,6 +36,9 @@ import { OperationVariables, MutationUpdaterFunction, OnQueryUpdated, + RefetchQueryDescription, + InternalRefetchQueriesOptions, + RefetchQueryDescriptor, } from './types'; import { LocalState } from './LocalState'; diff --git a/src/core/types.ts b/src/core/types.ts index 5be464545e1..6de3e58c404 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -24,6 +24,43 @@ export type OnQueryUpdated = ( export type PromiseResult = T extends PromiseLike ? U : T; +export type RefetchQueryDescriptor = string | PureQueryOptions; +export type RefetchQueryDescription = RefetchQueryDescriptor[]; + +// Used by ApolloClient["refetchQueries"] +// TODO Improve documentation comments for this public type. +export interface RefetchQueriesOptions< + TCache extends ApolloCache, + TResult, +> { + updateCache?: (cache: TCache) => void; + // Although you can pass PureQueryOptions objects in addition to strings in + // the refetchQueries array for a mutation, the client.refetchQueries method + // deliberately discourages passing PureQueryOptions, by restricting the + // public type of the options.include array to string[] (just query names). + include?: string[]; + optimistic?: boolean; + // If no onQueryUpdated function is provided, any queries affected by the + // updateCache function or included in the options.include array will be + // refetched by default. Passing null instead of undefined disables this + // default refetching behavior for affected queries, though included queries + // will still be refetched. + onQueryUpdated?: OnQueryUpdated | null; +} + +// Used by QueryManager["refetchQueries"] +export interface InternalRefetchQueriesOptions< + TCache extends ApolloCache, + TResult, +> extends Omit, "include"> { + // Just like the refetchQueries array for a mutation, allowing both strings + // and PureQueryOptions objects. + include?: RefetchQueryDescription; + // This part of the API is a (useful) implementation detail, but need not be + // exposed in the public client.refetchQueries API (above). + removeOptimistic?: string; +} + export type OperationVariables = Record; export type PureQueryOptions = { diff --git a/src/core/watchQueryOptions.ts b/src/core/watchQueryOptions.ts index 4543abcb44e..ed958668540 100644 --- a/src/core/watchQueryOptions.ts +++ b/src/core/watchQueryOptions.ts @@ -5,10 +5,10 @@ import { FetchResult } from '../link/core'; import { DefaultContext, MutationQueryReducersMap, - PureQueryOptions, OperationVariables, MutationUpdaterFunction, OnQueryUpdated, + RefetchQueryDescription, } from './types'; import { ApolloCache } from '../cache'; @@ -189,43 +189,6 @@ export interface SubscriptionOptions, - TResult, -> { - updateCache?: (cache: TCache) => void; - // Although you can pass PureQueryOptions objects in addition to strings in - // the refetchQueries array for a mutation, the client.refetchQueries method - // deliberately discourages passing PureQueryOptions, by restricting the - // public type of the options.include array to string[] (just query names). - include?: string[]; - optimistic?: boolean; - // If no onQueryUpdated function is provided, any queries affected by the - // updateCache function or included in the options.include array will be - // refetched by default. Passing null instead of undefined disables this - // default refetching behavior for affected queries, though included queries - // will still be refetched. - onQueryUpdated?: OnQueryUpdated | null; -} - -// Used by QueryManager["refetchQueries"] -export interface InternalRefetchQueriesOptions< - TCache extends ApolloCache, - TResult, -> extends Omit, "include"> { - // Just like the refetchQueries array for a mutation, allowing both strings - // and PureQueryOptions objects. - include?: RefetchQueryDescription; - // This part of the API is a (useful) implementation detail, but need not be - // exposed in the public client.refetchQueries API (above). - removeOptimistic?: string; -} - export interface MutationBaseOptions< TData = any, TVariables = OperationVariables, From b75bd7dd73c6f7753fbeca944dbd08dc63eafa5b Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Thu, 13 May 2021 15:29:02 -0400 Subject: [PATCH 173/380] Infer better refetchQueries return type from onQueryUpdated return type. --- src/core/ApolloClient.ts | 26 ++++++---------- src/core/QueryManager.ts | 5 +-- src/core/types.ts | 46 ++++++++++++++++++++++++++-- src/utilities/index.ts | 2 ++ src/utilities/types/IsStrictlyAny.ts | 17 ++++++++++ 5 files changed, 74 insertions(+), 22 deletions(-) create mode 100644 src/utilities/types/IsStrictlyAny.ts diff --git a/src/core/ApolloClient.ts b/src/core/ApolloClient.ts index f19a044683b..a2ab7b299c3 100644 --- a/src/core/ApolloClient.ts +++ b/src/core/ApolloClient.ts @@ -14,9 +14,9 @@ import { ApolloQueryResult, DefaultContext, OperationVariables, - PromiseResult, Resolvers, RefetchQueriesOptions, + RefetchQueriesResult, } from './types'; import { @@ -541,30 +541,22 @@ export class ApolloClient implements DataProxy { TResult = Promise>, >( options: RefetchQueriesOptions, - ): PromiseLike[]> & { - queries: ObservableQuery[]; - results: TResult[]; - } { + ): RefetchQueriesResult { const map = this.queryManager.refetchQueries(options); const queries: ObservableQuery[] = []; - const results: any[] = []; + const results: TResult[] = []; map.forEach((update, obsQuery) => { queries.push(obsQuery); results.push(update); }); - return { - // In case you need the raw results immediately, without awaiting - // Promise.all(results): - queries, - results, - // Using a thenable instead of a Promise here allows us to avoid creating - // the Promise if it isn't going to be awaited. - then(onResolved, onRejected) { - return Promise.all(results).then(onResolved, onRejected); - }, - }; + const result = Promise.all(results) as RefetchQueriesResult; + // In case you need the raw results immediately, without awaiting + // Promise.all(results): + result.queries = queries; + result.results = results; + return result; } /** diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index a65955266ef..9a067905d1c 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -1044,7 +1044,8 @@ export class QueryManager { optimistic = false, removeOptimistic = optimistic ? makeUniqueId("refetchQueries") : void 0, onQueryUpdated, - }: InternalRefetchQueriesOptions, TResult>) { + }: InternalRefetchQueriesOptions, TResult> + ): Map, TResult> { const includedQueriesById = new Map | undefined; @@ -1063,7 +1064,7 @@ export class QueryManager { }); } - const results = new Map, any>(); + const results = new Map, TResult>(); function maybeAddResult(oq: ObservableQuery, result: any): boolean { // The onQueryUpdated function can return false to ignore this query and // skip its normal broadcast, or true to allow the usual broadcast to diff --git a/src/core/types.ts b/src/core/types.ts index 6de3e58c404..15b9c004905 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -8,6 +8,7 @@ import { NetworkStatus } from './networkStatus'; import { Resolver } from './LocalState'; import { ObservableQuery } from './ObservableQuery'; import { Cache } from '../cache'; +import { IsStrictlyAny } from '../utilities'; export { TypedDocumentNode } from '@graphql-typed-document-node/core'; @@ -21,9 +22,6 @@ export type OnQueryUpdated = ( lastDiff: Cache.DiffResult | undefined, ) => boolean | TResult; -export type PromiseResult = - T extends PromiseLike ? U : T; - export type RefetchQueryDescriptor = string | PureQueryOptions; export type RefetchQueryDescription = RefetchQueryDescriptor[]; @@ -48,6 +46,48 @@ export interface RefetchQueriesOptions< onQueryUpdated?: OnQueryUpdated | null; } +// The client.refetchQueries method returns a thenable (PromiseLike) object +// whose result is an array of Promise.resolve'd TResult values, where TResult +// is whatever type the (optional) onQueryUpdated function returns. When no +// onQueryUpdated function is given, TResult defaults to ApolloQueryResult +// (thanks to default type parameters for client.refetchQueries). +export type RefetchQueriesPromiseResults = + // If onQueryUpdated returns any, all bets are off, so the results array must + // be a generic any[] array, which is much less confusing than the union type + // we get if we don't check for any. I hoped `any extends TResult` would do + // the trick here, instead of IsStrictlyAny, but you can see for yourself what + // fails in the refetchQueries tests if you try making that simplification. + IsStrictlyAny extends true ? any[] : + // If the onQueryUpdated function passed to client.refetchQueries returns true + // or false, that means either to refetch the query (true) or to skip the + // query (false). Since refetching produces an ApolloQueryResult, and + // skipping produces nothing, the fully-resolved array of all results produced + // will be an ApolloQueryResult[], when TResult extends boolean. + TResult extends boolean ? ApolloQueryResult[] : + // If onQueryUpdated returns a PromiseLike, that thenable will be passed as + // an array element to Promise.all, so we infer/unwrap the array type U here. + TResult extends PromiseLike ? U[] : + // All other onQueryUpdated results end up in the final Promise.all array as + // themselves, with their original TResult type. Note that TResult will + // default to ApolloQueryResult if no onQueryUpdated function is passed + // to client.refetchQueries. + TResult[]; + +export type RefetchQueriesResult = + // The result of client.refetchQueries is thenable/awaitable, if you just want + // an array of fully resolved results, but you can also access the raw results + // immediately by examining the additional { queries, results } properties of + // the RefetchQueriesResult object. + Promise> & { + // An array of ObservableQuery objects corresponding 1:1 to TResult values + // in the results arrays (both the TResult[] array below, and the results + // array resolved by the Promise above). + queries: ObservableQuery[]; + // These are the raw TResult values returned by any onQueryUpdated functions + // that were invoked by client.refetchQueries. + results: TResult[]; + }; + // Used by QueryManager["refetchQueries"] export interface InternalRefetchQueriesOptions< TCache extends ApolloCache, diff --git a/src/utilities/index.ts b/src/utilities/index.ts index 7617a9187b3..9657e447ac1 100644 --- a/src/utilities/index.ts +++ b/src/utilities/index.ts @@ -86,3 +86,5 @@ export * from './common/arrays'; export * from './common/errorHandling'; export * from './common/canUse'; export * from './common/compact'; + +export * from './types/IsStrictlyAny'; diff --git a/src/utilities/types/IsStrictlyAny.ts b/src/utilities/types/IsStrictlyAny.ts new file mode 100644 index 00000000000..a27932666d4 --- /dev/null +++ b/src/utilities/types/IsStrictlyAny.ts @@ -0,0 +1,17 @@ +// If (and only if) T is any, the union 'a' | 'b' is returned here, representing +// both branches of this conditional type. Only UnionForAny produces this +// union type; all other inputs produce the 'b' literal type. +type UnionForAny = T extends never ? 'a' : 'b'; + +// If that 'A' | 'B' union is then passed to UnionToIntersection, the result +// should be 'A' & 'B', which TypeScript simplifies to the never type, since the +// literal string types 'A' and 'B' are incompatible. More explanation of this +// helper type: https://stackoverflow.com/a/50375286/62076 +type UnionToIntersection = + (U extends any ? (k: U) => void : never) extends + ((k: infer I) => void) ? I : never + +// Returns true if T is any, or false for any other type. +// From https://stackoverflow.com/a/61625296/128454 +export type IsStrictlyAny = + UnionToIntersection> extends never ? true : false; From 30139fc9dd901e376e4205b2d3b61e677408f3fa Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Thu, 13 May 2021 16:48:48 -0400 Subject: [PATCH 174/380] Improve comments about IsStrictlyAny and related types. --- src/utilities/types/IsStrictlyAny.ts | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/utilities/types/IsStrictlyAny.ts b/src/utilities/types/IsStrictlyAny.ts index a27932666d4..6607dac5945 100644 --- a/src/utilities/types/IsStrictlyAny.ts +++ b/src/utilities/types/IsStrictlyAny.ts @@ -1,17 +1,17 @@ -// If (and only if) T is any, the union 'a' | 'b' is returned here, representing +// Returns true if T is any, or false for any other type. +// Inspired by https://stackoverflow.com/a/61625296/128454. +export type IsStrictlyAny = + UnionToIntersection> extends never ? true : false; + +// If (and only if) T is any, the union 'a' | 1 is returned here, representing // both branches of this conditional type. Only UnionForAny produces this -// union type; all other inputs produce the 'b' literal type. -type UnionForAny = T extends never ? 'a' : 'b'; +// union type; all other inputs produce the 1 literal type. +type UnionForAny = T extends never ? 'a' : 1; -// If that 'A' | 'B' union is then passed to UnionToIntersection, the result -// should be 'A' & 'B', which TypeScript simplifies to the never type, since the -// literal string types 'A' and 'B' are incompatible. More explanation of this -// helper type: https://stackoverflow.com/a/50375286/62076 +// If that 'a' | 1 union is then passed to UnionToIntersection, the result +// should be 'a' & 1, which TypeScript simplifies to the never type, since the +// literal type 'a' and the literal type 1 are incompatible. More explanation of +// this helper type: https://stackoverflow.com/a/50375286/62076. type UnionToIntersection = (U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never - -// Returns true if T is any, or false for any other type. -// From https://stackoverflow.com/a/61625296/128454 -export type IsStrictlyAny = - UnionToIntersection> extends never ? true : false; From 028fd7510dbb0b04f788204e47eb4fb43148eb8e Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Thu, 13 May 2021 16:53:41 -0400 Subject: [PATCH 175/380] Use interface instead of type for RefetchQueriesResult. --- src/core/types.ts | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/core/types.ts b/src/core/types.ts index 15b9c004905..eaabe06972a 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -73,20 +73,20 @@ export type RefetchQueriesPromiseResults = // to client.refetchQueries. TResult[]; -export type RefetchQueriesResult = - // The result of client.refetchQueries is thenable/awaitable, if you just want - // an array of fully resolved results, but you can also access the raw results - // immediately by examining the additional { queries, results } properties of - // the RefetchQueriesResult object. - Promise> & { - // An array of ObservableQuery objects corresponding 1:1 to TResult values - // in the results arrays (both the TResult[] array below, and the results - // array resolved by the Promise above). - queries: ObservableQuery[]; - // These are the raw TResult values returned by any onQueryUpdated functions - // that were invoked by client.refetchQueries. - results: TResult[]; - }; +// The result of client.refetchQueries is thenable/awaitable, if you just want +// an array of fully resolved results, but you can also access the raw results +// immediately by examining the additional { queries, results } properties of +// the RefetchQueriesResult object. +export interface RefetchQueriesResult +extends Promise> { + // An array of ObservableQuery objects corresponding 1:1 to TResult values + // in the results arrays (both the TResult[] array below, and the results + // array resolved by the Promise above). + queries: ObservableQuery[]; + // These are the raw TResult values returned by any onQueryUpdated functions + // that were invoked by client.refetchQueries. + results: TResult[]; +} // Used by QueryManager["refetchQueries"] export interface InternalRefetchQueriesOptions< From 8ccfbb2b82c83bd2d91d67d61df9a68f085e2db9 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Thu, 13 May 2021 17:26:38 -0400 Subject: [PATCH 176/380] Add a test of returning true from onQueryUpdated. Notice that the type of `results` in client.refetchQueries(...).then(results => ...) is correctly inferred to be ApolloQueryResult[], because the onQueryUpdated function returned true, which triggers a refetch, which returns a Promise>. --- src/__tests__/refetchQueries.ts | 76 ++++++++++++++++++++++++++++++--- 1 file changed, 69 insertions(+), 7 deletions(-) diff --git a/src/__tests__/refetchQueries.ts b/src/__tests__/refetchQueries.ts index 41474f2d409..89fab376a92 100644 --- a/src/__tests__/refetchQueries.ts +++ b/src/__tests__/refetchQueries.ts @@ -120,7 +120,9 @@ describe("client.refetchQueries", () => { } else if (obs === abObs) { expect(diff.result).toEqual({ a: "Ayy", b: "B" }); } else { - reject("unexpected ObservableQuery"); + reject(`unexpected ObservableQuery ${ + obs.queryId + } with name ${obs.queryName}`); } return Promise.resolve(diff.result); }, @@ -152,7 +154,9 @@ describe("client.refetchQueries", () => { } else if (obs === abObs) { expect(diff.result).toEqual({ a: "Ayy", b: "Bee" }); } else { - reject("unexpected ObservableQuery"); + reject(`unexpected ObservableQuery ${ + obs.queryId + } with name ${obs.queryName}`); } return diff.result; }, @@ -199,7 +203,9 @@ describe("client.refetchQueries", () => { } else if (obs === abObs) { expect(diff.result).toEqual({ a: "Ayy", b: "B" }); } else { - reject("unexpected ObservableQuery"); + reject(`unexpected ObservableQuery ${ + obs.queryId + } with name ${obs.queryName}`); } return Promise.resolve(diff.result); }, @@ -236,7 +242,9 @@ describe("client.refetchQueries", () => { } else if (obs === abObs) { expect(diff.result).toEqual({ a: "Ayy", b: "Bee" }); } else { - reject("unexpected ObservableQuery"); + reject(`unexpected ObservableQuery ${ + obs.queryId + } with name ${obs.queryName}`); } return diff.result; }, @@ -352,7 +360,9 @@ describe("client.refetchQueries", () => { } else if (obs === abObs) { expect(diff.result).toEqual({ a: "A", b: "B" }); } else { - reject("unexpected ObservableQuery"); + reject(`unexpected ObservableQuery ${ + obs.queryId + } with name ${obs.queryName}`); } return diff.result; @@ -377,8 +387,60 @@ describe("client.refetchQueries", () => { resolve(); }); - it("can return true from onQueryUpdated to choose default refetching behavior", () => { - // TODO + itAsync("can return true from onQueryUpdated to choose default refetching behavior", async (resolve, reject) => { + const client = makeClient(); + const [ + aObs, + bObs, + abObs, + ] = await setup(client); + + const refetchResult = client.refetchQueries({ + include: ["A", "B"], + onQueryUpdated(obs, diff) { + if (obs === aObs) { + expect(diff.result).toEqual({ a: "A" }); + } else if (obs === bObs) { + expect(diff.result).toEqual({ b: "B" }); + } else if (obs === abObs) { + reject("abQuery should not have been updated"); + } else { + reject(`unexpected ObservableQuery ${ + obs.queryId + } with name ${obs.queryName}`); + } + return true; + }, + }); + + expect(refetchResult.results.length).toBe(2); + refetchResult.results.forEach(result => { + expect(result).toBeInstanceOf(Promise); + }); + + expect(refetchResult.queries.map(obs => { + expect(obs).toBeInstanceOf(ObservableQuery); + return obs.queryName; + }).sort()).toEqual(["A", "B"]); + + const results = (await refetchResult).map(result => { + // These results are ApolloQueryResult, as inferred by TypeScript. + expect(Object.keys(result).sort()).toEqual([ + "data", + "loading", + "networkStatus", + ]); + return result.data; + }); + + sortObjects(results); + + expect(results).toEqual([ + { a: "A" }, + { b: "B" }, + ]); + + resolve(); }); it("can return false from onQueryUpdated to skip the updated query", () => { From b000064322c3cabc82a0ae0106e8a467ce8c755e Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Thu, 13 May 2021 17:33:05 -0400 Subject: [PATCH 177/380] Add a test of returning false from onQueryUpdated. --- src/__tests__/refetchQueries.ts | 56 +++++++++++++++++++++++++++++++-- 1 file changed, 54 insertions(+), 2 deletions(-) diff --git a/src/__tests__/refetchQueries.ts b/src/__tests__/refetchQueries.ts index 89fab376a92..d628ad0810c 100644 --- a/src/__tests__/refetchQueries.ts +++ b/src/__tests__/refetchQueries.ts @@ -443,8 +443,60 @@ describe("client.refetchQueries", () => { resolve(); }); - it("can return false from onQueryUpdated to skip the updated query", () => { - // TODO + itAsync("can return false from onQueryUpdated to skip the updated query", async (resolve, reject) => { + const client = makeClient(); + const [ + aObs, + bObs, + abObs, + ] = await setup(client); + + const refetchResult = client.refetchQueries({ + include: ["A", "B"], + onQueryUpdated(obs, diff) { + if (obs === aObs) { + expect(diff.result).toEqual({ a: "A" }); + } else if (obs === bObs) { + expect(diff.result).toEqual({ b: "B" }); + } else if (obs === abObs) { + reject("abQuery should not have been updated"); + } else { + reject(`unexpected ObservableQuery ${ + obs.queryId + } with name ${obs.queryName}`); + } + // Skip refetching all but the B query. + return obs.queryName === "B"; + }, + }); + + expect(refetchResult.results.length).toBe(1); + refetchResult.results.forEach(result => { + expect(result).toBeInstanceOf(Promise); + }); + + expect(refetchResult.queries.map(obs => { + expect(obs).toBeInstanceOf(ObservableQuery); + return obs.queryName; + }).sort()).toEqual(["B"]); + + const results = (await refetchResult).map(result => { + // These results are ApolloQueryResult, as inferred by TypeScript. + expect(Object.keys(result).sort()).toEqual([ + "data", + "loading", + "networkStatus", + ]); + return result.data; + }); + + sortObjects(results); + + expect(results).toEqual([ + { b: "B" }, + ]); + + resolve(); }); it("can refetch no-cache queries", () => { From dcb8288e341676498ca594943ff9df0843b6bb52 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Thu, 13 May 2021 17:34:56 -0400 Subject: [PATCH 178/380] Clarify remaining TODO about testing no-cache with refetchQueries. --- src/__tests__/refetchQueries.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/__tests__/refetchQueries.ts b/src/__tests__/refetchQueries.ts index d628ad0810c..be9330e93b1 100644 --- a/src/__tests__/refetchQueries.ts +++ b/src/__tests__/refetchQueries.ts @@ -500,6 +500,7 @@ describe("client.refetchQueries", () => { }); it("can refetch no-cache queries", () => { - // TODO + // TODO The options.updateCache function won't work for these queries, but + // the options.include array should work, at least. }); }); From 0c3fad6f56ab47f6326a01dccb18f18f40d81527 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Thu, 13 May 2021 19:08:52 -0400 Subject: [PATCH 179/380] Failing test of using updateCache and returning true from onQueryUpdated. --- src/__tests__/refetchQueries.ts | 85 ++++++++++++++++++++++++++++++++- src/cache/core/types/Cache.ts | 2 +- 2 files changed, 85 insertions(+), 2 deletions(-) diff --git a/src/__tests__/refetchQueries.ts b/src/__tests__/refetchQueries.ts index be9330e93b1..597a6aa17e4 100644 --- a/src/__tests__/refetchQueries.ts +++ b/src/__tests__/refetchQueries.ts @@ -443,7 +443,90 @@ describe("client.refetchQueries", () => { resolve(); }); - itAsync("can return false from onQueryUpdated to skip the updated query", async (resolve, reject) => { + itAsync("can return true from onQueryUpdated when using options.updateCache", async (resolve, reject) => { + const client = makeClient(); + const [ + aObs, + bObs, + abObs, + ] = await setup(client); + + const refetchResult = client.refetchQueries({ + updateCache(cache) { + cache.writeQuery({ + query: bQuery, + data: { + b: "Beetlejuice" + }, + }); + }, + + onQueryUpdated(obs, diff) { + if (obs === aObs) { + reject("aQuery should not have been updated"); + } else if (obs === bObs) { + expect(diff.result).toEqual({ b: "Beetlejuice" }); + } else if (obs === abObs) { + expect(diff.result).toEqual({ a: "A", b: "Beetlejuice" }); + } else { + reject(`unexpected ObservableQuery ${ + obs.queryId + } with name ${obs.queryName}`); + } + + expect(client.cache.extract()).toEqual({ + ROOT_QUERY: { + __typename: "Query", + a: "A", + b: "Beetlejuice", + }, + }); + + return true; + }, + }); + + expect(refetchResult.results.length).toBe(2); + refetchResult.results.forEach(result => { + expect(result).toBeInstanceOf(Promise); + }); + + expect(refetchResult.queries.map(obs => { + expect(obs).toBeInstanceOf(ObservableQuery); + return obs.queryName; + }).sort()).toEqual(["AB", "B"]); + + const results = (await refetchResult).map(result => { + // These results are ApolloQueryResult, as inferred by TypeScript. + expect(Object.keys(result).sort()).toEqual([ + "data", + "loading", + "networkStatus", + ]); + return result.data; + }); + + sortObjects(results); + + expect(results).toEqual([ + // Since we returned true from onQueryUpdated, the results were refetched, + // replacing "Beetlejuice" with "B" again. + { a: "A", b: "B"}, + { b: "B" }, + ]); + + expect(client.cache.extract()).toEqual({ + ROOT_QUERY: { + __typename: "Query", + a: "A", + b: "B", + }, + }); + + resolve(); + }); + + itAsync("can return false from onQueryUpdated to skip/ignore a query", async (resolve, reject) => { const client = makeClient(); const [ aObs, diff --git a/src/cache/core/types/Cache.ts b/src/cache/core/types/Cache.ts index e9ff92021c4..98a21afd4f5 100644 --- a/src/cache/core/types/Cache.ts +++ b/src/cache/core/types/Cache.ts @@ -62,7 +62,7 @@ export namespace Cache { // Passing a string for this option creates a new optimistic layer, with the // given 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 (runing the batch operation + // passing undefined to performTransaction (running the batch operation // against the current top layer of the cache), and passing false is the // same as passing null (running the operation against root/non-optimistic // cache data). From c948655ba0919107088f19e761a5b701b70f7fd9 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Thu, 13 May 2021 19:29:07 -0400 Subject: [PATCH 180/380] Inline maybeAddResult helper function. --- src/core/QueryManager.ts | 39 +++++++++++++++++++++------------------ 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index 9a067905d1c..fdc55bb2a03 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -1065,22 +1065,6 @@ export class QueryManager { } const results = new Map, TResult>(); - function maybeAddResult(oq: ObservableQuery, result: any): boolean { - // The onQueryUpdated function can return false to ignore this query and - // skip its normal broadcast, or true to allow the usual broadcast to - // happen (when diff.result has changed). - if (result === false || result === true) { - return result; - } - - // Returning anything other than true or false from onQueryUpdated will - // cause the result to be included in the results Map, while also - // canceling/overriding the normal broadcast. - results.set(oq, result); - - // Prevent the normal cache broadcast of this result. - return false; - } if (updateCache) { this.cache.batch({ @@ -1134,8 +1118,25 @@ export class QueryManager { if (oq) { if (onQueryUpdated) { includedQueriesById.delete(oq.queryId); - return maybeAddResult(oq, onQueryUpdated(oq, diff, lastDiff)); + + const result = onQueryUpdated(oq, diff, lastDiff); + + // The onQueryUpdated function can return false to ignore this + // query and skip its normal broadcast, or true to allow the usual + // broadcast to happen (when diff.result has changed). + if (result === true || result === false) { + return result; + } + + // Returning anything other than true or false from onQueryUpdated + // will cause the result to be included in the results Map, while + // also canceling/overriding the normal broadcast. + results.set(oq, result); + + // Prevent the normal cache broadcast of this result. + return false; } + if (onQueryUpdated !== null) { // If we don't have an onQueryUpdated function, and onQueryUpdated // was not disabled by passing null, make sure this query is @@ -1185,7 +1186,9 @@ export class QueryManager { if (!onQueryUpdated || result === true) { result = fallback(); } - maybeAddResult(oq, result); + if (result !== false) { + results.set(oq, result as TResult); + } } }); } From 01b13c70b7092c50fd402a23bfd98f29302012c7 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Thu, 13 May 2021 19:35:16 -0400 Subject: [PATCH 181/380] Store both lastDiff and diff in includedQueriesById. --- src/core/QueryManager.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index fdc55bb2a03..e4becc82e7a 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -1048,7 +1048,8 @@ export class QueryManager { ): Map, TResult> { const includedQueriesById = new Map | undefined; + lastDiff?: Cache.DiffResult; + diff?: Cache.DiffResult; }>(); if (include) { @@ -1056,7 +1057,7 @@ export class QueryManager { getQueryIdsForQueryDescriptor(this, desc).forEach(queryId => { includedQueriesById.set(queryId, { desc, - diff: typeof desc === "string" + lastDiff: typeof desc === "string" ? this.getQuery(queryId).getDiff() : void 0, }); @@ -1143,6 +1144,7 @@ export class QueryManager { // "included" like any other options.include-specified query. includedQueriesById.set(oq.queryId, { desc: oq.queryName || ``, + lastDiff, diff, }); } @@ -1152,7 +1154,7 @@ export class QueryManager { } if (includedQueriesById.size) { - includedQueriesById.forEach(({ desc, diff }, queryId) => { + includedQueriesById.forEach(({ desc, lastDiff, diff }, queryId) => { const queryInfo = this.getQuery(queryId); let oq = queryInfo.observableQuery; let fallback: undefined | (() => any); @@ -1180,8 +1182,11 @@ export class QueryManager { // queries, even the PureQueryOptions ones. Otherwise, we call the // fallback function defined above. if (onQueryUpdated) { - queryInfo.reset(); // Force queryInfo.getDiff() to read from cache. - result = onQueryUpdated(oq, queryInfo.getDiff(), diff); + if (!diff) { + queryInfo.reset(); // Force queryInfo.getDiff() to read from cache. + diff = queryInfo.getDiff(); + } + result = onQueryUpdated(oq, diff, lastDiff); } if (!onQueryUpdated || result === true) { result = fallback(); From 75e455c7b2b83fbac4752eb8b7874e15680e2d0a Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Thu, 13 May 2021 20:03:44 -0400 Subject: [PATCH 182/380] Fix failing test of updateCache with onQueryUpdated returning true. --- src/core/QueryManager.ts | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index e4becc82e7a..153b10d6ec2 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -1118,23 +1118,27 @@ export class QueryManager { if (oq) { if (onQueryUpdated) { + // Since we're about to handle this query now, remove it from + // includedQueriesById, in case it was added earlier because of + // options.include. includedQueriesById.delete(oq.queryId); - const result = onQueryUpdated(oq, diff, lastDiff); + let result = onQueryUpdated(oq, diff, lastDiff); - // The onQueryUpdated function can return false to ignore this - // query and skip its normal broadcast, or true to allow the usual - // broadcast to happen (when diff.result has changed). - if (result === true || result === false) { - return result; + if (result === true) { + // The onQueryUpdated function requested the default refetching + // behavior by returning true. + result = oq.refetch() as any; } - // Returning anything other than true or false from onQueryUpdated - // will cause the result to be included in the results Map, while - // also canceling/overriding the normal broadcast. - results.set(oq, result); + // Record the result in the results Map, as long as onQueryUpdated + // did not return false to skip/ignore this result. + if (result !== false) { + results.set(oq, result as TResult); + } - // Prevent the normal cache broadcast of this result. + // Prevent the normal cache broadcast of this result, since we've + // already handled it. return false; } From 311f6927429072d78a68fac42d40ab1290a1e332 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Thu, 13 May 2021 20:18:34 -0400 Subject: [PATCH 183/380] Use fewer forced typecasts in QueryManager#refetchQueries. --- src/core/ApolloClient.ts | 13 +++++++++---- src/core/QueryManager.ts | 19 +++++++++++-------- src/core/types.ts | 9 ++++++++- 3 files changed, 28 insertions(+), 13 deletions(-) diff --git a/src/core/ApolloClient.ts b/src/core/ApolloClient.ts index a2ab7b299c3..53485777b07 100644 --- a/src/core/ApolloClient.ts +++ b/src/core/ApolloClient.ts @@ -17,6 +17,7 @@ import { Resolvers, RefetchQueriesOptions, RefetchQueriesResult, + InternalRefetchQueriesResult, } from './types'; import { @@ -544,18 +545,22 @@ export class ApolloClient implements DataProxy { ): RefetchQueriesResult { const map = this.queryManager.refetchQueries(options); const queries: ObservableQuery[] = []; - const results: TResult[] = []; + const results: InternalRefetchQueriesResult[] = []; - map.forEach((update, obsQuery) => { + map.forEach((result, obsQuery) => { queries.push(obsQuery); - results.push(update); + results.push(result); }); - const result = Promise.all(results) as RefetchQueriesResult; + const result = Promise.all( + results as TResult[] + ) as RefetchQueriesResult; + // In case you need the raw results immediately, without awaiting // Promise.all(results): result.queries = queries; result.results = results; + return result; } diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index 153b10d6ec2..8c8880b1282 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -39,6 +39,8 @@ import { RefetchQueryDescription, InternalRefetchQueriesOptions, RefetchQueryDescriptor, + InternalRefetchQueriesResult, + InternalRefetchQueriesMap, } from './types'; import { LocalState } from './LocalState'; @@ -1045,7 +1047,7 @@ export class QueryManager { removeOptimistic = optimistic ? makeUniqueId("refetchQueries") : void 0, onQueryUpdated, }: InternalRefetchQueriesOptions, TResult> - ): Map, TResult> { + ): InternalRefetchQueriesMap { const includedQueriesById = new Map; @@ -1065,7 +1067,7 @@ export class QueryManager { }); } - const results = new Map, TResult>(); + const results: InternalRefetchQueriesMap = new Map; if (updateCache) { this.cache.batch({ @@ -1123,18 +1125,19 @@ export class QueryManager { // options.include. includedQueriesById.delete(oq.queryId); - let result = onQueryUpdated(oq, diff, lastDiff); + let result: boolean | InternalRefetchQueriesResult = + onQueryUpdated(oq, diff, lastDiff); if (result === true) { // The onQueryUpdated function requested the default refetching // behavior by returning true. - result = oq.refetch() as any; + result = oq.refetch(); } // Record the result in the results Map, as long as onQueryUpdated // did not return false to skip/ignore this result. if (result !== false) { - results.set(oq, result as TResult); + results.set(oq, result); } // Prevent the normal cache broadcast of this result, since we've @@ -1161,7 +1164,7 @@ export class QueryManager { includedQueriesById.forEach(({ desc, lastDiff, diff }, queryId) => { const queryInfo = this.getQuery(queryId); let oq = queryInfo.observableQuery; - let fallback: undefined | (() => any); + let fallback: undefined | (() => Promise>); if (typeof desc === "string") { fallback = () => oq!.refetch(); @@ -1181,7 +1184,7 @@ export class QueryManager { } if (oq && fallback) { - let result: boolean | TResult | undefined; + let result: undefined | boolean | InternalRefetchQueriesResult; // If onQueryUpdated is provided, we want to use it for all included // queries, even the PureQueryOptions ones. Otherwise, we call the // fallback function defined above. @@ -1196,7 +1199,7 @@ export class QueryManager { result = fallback(); } if (result !== false) { - results.set(oq, result as TResult); + results.set(oq, result!); } } }); diff --git a/src/core/types.ts b/src/core/types.ts index eaabe06972a..f3c4644385e 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -85,7 +85,7 @@ extends Promise> { queries: ObservableQuery[]; // These are the raw TResult values returned by any onQueryUpdated functions // that were invoked by client.refetchQueries. - results: TResult[]; + results: InternalRefetchQueriesResult[]; } // Used by QueryManager["refetchQueries"] @@ -101,6 +101,13 @@ export interface InternalRefetchQueriesOptions< removeOptimistic?: string; } +export type InternalRefetchQueriesResult = + TResult | Promise>; + +export type InternalRefetchQueriesMap = + Map, + InternalRefetchQueriesResult>; + export type OperationVariables = Record; export type PureQueryOptions = { From c2bce76690b3f43a5c21b59679c7e99d11467622 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Thu, 13 May 2021 20:38:31 -0400 Subject: [PATCH 184/380] Move makeUniqueId from QueryManager.ts into @apollo/client/utilities. --- src/__tests__/__snapshots__/exports.ts.snap | 1 + src/core/QueryManager.ts | 8 +------- src/utilities/common/makeUniqueId.ts | 9 +++++++++ src/utilities/index.ts | 1 + 4 files changed, 12 insertions(+), 7 deletions(-) create mode 100644 src/utilities/common/makeUniqueId.ts diff --git a/src/__tests__/__snapshots__/exports.ts.snap b/src/__tests__/__snapshots__/exports.ts.snap index cf064e3d32a..b292f4d5144 100644 --- a/src/__tests__/__snapshots__/exports.ts.snap +++ b/src/__tests__/__snapshots__/exports.ts.snap @@ -353,6 +353,7 @@ Array [ "isReference", "iterateObserversSafely", "makeReference", + "makeUniqueId", "maybeDeepFreeze", "mergeDeep", "mergeDeepArray", diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index 8c8880b1282..24a3b64d0dc 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -19,6 +19,7 @@ import { isNonEmptyArray, Concast, ConcastSourcesIterable, + makeUniqueId, } from '../utilities'; import { ApolloError, isApolloError } from '../errors'; import { @@ -1414,10 +1415,3 @@ function getQueryIdsForQueryDescriptor( } return queryIds; } - -const prefixCounts: Record = Object.create(null); -function makeUniqueId(prefix: string) { - const count = prefixCounts[prefix] || 1; - prefixCounts[prefix] = count + 1; - return `${prefix}:${count}:${Math.random().toString(36).slice(2)}`; -} diff --git a/src/utilities/common/makeUniqueId.ts b/src/utilities/common/makeUniqueId.ts new file mode 100644 index 00000000000..b0e804bd7fb --- /dev/null +++ b/src/utilities/common/makeUniqueId.ts @@ -0,0 +1,9 @@ +const prefixCounts = new Map(); + +// These IDs won't be globally unique, but they will be unique within this +// process, thanks to the counter, and unguessable thanks to the random suffix. +export function makeUniqueId(prefix: string) { + const count = prefixCounts.get(prefix) || 1; + prefixCounts.set(prefix, count + 1); + return `${prefix}:${count}:${Math.random().toString(36).slice(2)}`; +} diff --git a/src/utilities/index.ts b/src/utilities/index.ts index 9657e447ac1..91540d00e2d 100644 --- a/src/utilities/index.ts +++ b/src/utilities/index.ts @@ -86,5 +86,6 @@ export * from './common/arrays'; export * from './common/errorHandling'; export * from './common/canUse'; export * from './common/compact'; +export * from './common/makeUniqueId'; export * from './types/IsStrictlyAny'; From 512804cdc6919d0ce04139f18443ede92b81d7c6 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 18 May 2021 16:25:48 -0400 Subject: [PATCH 185/380] Allow including client.refetchQueries queries by DocumentNode. --- src/__tests__/__snapshots__/exports.ts.snap | 1 + src/__tests__/refetchQueries.ts | 89 +++++++++++++++++++++ src/core/QueryManager.ts | 12 +-- src/core/types.ts | 4 +- src/utilities/graphql/storeUtils.ts | 10 +++ src/utilities/index.ts | 1 + 6 files changed, 110 insertions(+), 7 deletions(-) diff --git a/src/__tests__/__snapshots__/exports.ts.snap b/src/__tests__/__snapshots__/exports.ts.snap index b292f4d5144..3032b34917e 100644 --- a/src/__tests__/__snapshots__/exports.ts.snap +++ b/src/__tests__/__snapshots__/exports.ts.snap @@ -347,6 +347,7 @@ Array [ "graphQLResultHasError", "hasClientExports", "hasDirectives", + "isDocumentNode", "isField", "isInlineFragment", "isNonEmptyArray", diff --git a/src/__tests__/refetchQueries.ts b/src/__tests__/refetchQueries.ts index 597a6aa17e4..d5b5bd72236 100644 --- a/src/__tests__/refetchQueries.ts +++ b/src/__tests__/refetchQueries.ts @@ -262,6 +262,95 @@ describe("client.refetchQueries", () => { resolve(); }); + itAsync("includes query DocumentNode objects specified in options.include", async (resolve, reject) => { + const client = makeClient(); + const [ + aObs, + bObs, + abObs, + ] = await setup(client); + + const ayyResults = await client.refetchQueries({ + updateCache(cache) { + cache.writeQuery({ + query: aQuery, + data: { + a: "Ayy", + }, + }); + }, + + // Note that we're passing query DocumentNode objects instead of query + // name strings, in this test. + include: [bQuery, abQuery], + + onQueryUpdated(obs, diff) { + if (obs === aObs) { + expect(diff.result).toEqual({ a: "Ayy" }); + } else if (obs === bObs) { + expect(diff.result).toEqual({ b: "B" }); + } else if (obs === abObs) { + expect(diff.result).toEqual({ a: "Ayy", b: "B" }); + } else { + reject(`unexpected ObservableQuery ${ + obs.queryId + } with name ${obs.queryName}`); + } + return Promise.resolve(diff.result); + }, + }); + + sortObjects(ayyResults); + + expect(ayyResults).toEqual([ + { a: "Ayy" }, + { a: "Ayy", b: "B" }, + // Included this time! + { b: "B" }, + ]); + + const beeResults = await client.refetchQueries({ + updateCache(cache) { + cache.writeQuery({ + query: bQuery, + data: { + b: "Bee", + }, + }); + }, + + // The abQuery and "AB" should be redundant, but the aQuery here is + // important for aObs to be included. + include: [abQuery, "AB", aQuery], + + onQueryUpdated(obs, diff) { + if (obs === aObs) { + expect(diff.result).toEqual({ a: "Ayy" }); + } else if (obs === bObs) { + expect(diff.result).toEqual({ b: "Bee" }); + } else if (obs === abObs) { + expect(diff.result).toEqual({ a: "Ayy", b: "Bee" }); + } else { + reject(`unexpected ObservableQuery ${ + obs.queryId + } with name ${obs.queryName}`); + } + return diff.result; + }, + }); + + sortObjects(beeResults); + + expect(beeResults).toEqual([ + { a: "Ayy" }, // Included this time! + { a: "Ayy", b: "Bee" }, + { b: "Bee" }, + ]); + + unsubscribe(); + resolve(); + }); + itAsync("refetches watched queries if onQueryUpdated not provided", async (resolve, reject) => { const client = makeClient(); const [ diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index 24a3b64d0dc..021d19fccf9 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -20,6 +20,7 @@ import { Concast, ConcastSourcesIterable, makeUniqueId, + isDocumentNode, } from '../utilities'; import { ApolloError, isApolloError } from '../errors'; import { @@ -1060,7 +1061,7 @@ export class QueryManager { getQueryIdsForQueryDescriptor(this, desc).forEach(queryId => { includedQueriesById.set(queryId, { desc, - lastDiff: typeof desc === "string" + lastDiff: typeof desc === "string" || isDocumentNode(desc) ? this.getQuery(queryId).getDiff() : void 0, }); @@ -1167,7 +1168,7 @@ export class QueryManager { let oq = queryInfo.observableQuery; let fallback: undefined | (() => Promise>); - if (typeof desc === "string") { + if (typeof desc === "string" || isDocumentNode(desc)) { fallback = () => oq!.refetch(); } else if (desc && typeof desc === "object") { const options = { @@ -1395,10 +1396,11 @@ function getQueryIdsForQueryDescriptor( desc: RefetchQueryDescriptor, ) { const queryIds: string[] = []; - if (typeof desc === "string") { - qm["queries"].forEach(({ observableQuery: oq }, queryId) => { + const isName = typeof desc === "string"; + if (isName || isDocumentNode(desc)) { + qm["queries"].forEach(({ observableQuery: oq, document }, queryId) => { if (oq && - oq.queryName === desc && + desc === (isName ? oq.queryName : document) && oq.hasObservers()) { queryIds.push(queryId); } diff --git a/src/core/types.ts b/src/core/types.ts index f3c4644385e..a6907179a34 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -22,7 +22,7 @@ export type OnQueryUpdated = ( lastDiff: Cache.DiffResult | undefined, ) => boolean | TResult; -export type RefetchQueryDescriptor = string | PureQueryOptions; +export type RefetchQueryDescriptor = string | DocumentNode | PureQueryOptions; export type RefetchQueryDescription = RefetchQueryDescriptor[]; // Used by ApolloClient["refetchQueries"] @@ -36,7 +36,7 @@ export interface RefetchQueriesOptions< // the refetchQueries array for a mutation, the client.refetchQueries method // deliberately discourages passing PureQueryOptions, by restricting the // public type of the options.include array to string[] (just query names). - include?: string[]; + include?: Exclude[]; optimistic?: boolean; // If no onQueryUpdated function is provided, any queries affected by the // updateCache function or included in the options.include array will be diff --git a/src/utilities/graphql/storeUtils.ts b/src/utilities/graphql/storeUtils.ts index 70e0bc0eb5b..a0aa5e8d2d1 100644 --- a/src/utilities/graphql/storeUtils.ts +++ b/src/utilities/graphql/storeUtils.ts @@ -15,6 +15,7 @@ import { SelectionNode, NameNode, SelectionSetNode, + DocumentNode, } from 'graphql'; import { InvariantError } from 'ts-invariant'; @@ -48,6 +49,15 @@ export interface StoreObject { [storeFieldName: string]: StoreValue; } +export function isDocumentNode(value: any): value is DocumentNode { + return ( + value !== null && + typeof value === "object" && + (value as DocumentNode).kind === "Document" && + Array.isArray((value as DocumentNode).definitions) + ); +} + function isStringValue(value: ValueNode): value is StringValueNode { return value.kind === 'StringValue'; } diff --git a/src/utilities/index.ts b/src/utilities/index.ts index 91540d00e2d..c878b83622b 100644 --- a/src/utilities/index.ts +++ b/src/utilities/index.ts @@ -33,6 +33,7 @@ export { Directives, VariableValue, makeReference, + isDocumentNode, isReference, isField, isInlineFragment, From 491eeafcccc88d7043d1b0eb29357595996e3a4b Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 18 May 2021 16:44:04 -0400 Subject: [PATCH 186/380] Update CHANGELOG.md to reflect changes made in PR #8000. --- CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d5abd61956..6f4e29418fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,12 +34,15 @@ - `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.
[@benjamn](https://github.com/benjamn) in [#7819](https://github.com/apollographql/apollo-client/pull/7819) -- Mutations now accept an optional callback function called `reobserveQuery`, which will be passed the `ObservableQuery` and `Cache.DiffResult` objects for any queries invalidated by cache writes performed by the mutation's final `update` function. Using `reobserveQuery`, you can override the default `FetchPolicy` of the query, by (for example) calling `ObservableQuery` methods like `refetch` to force a network request. This automatic detection of invalidated queries provides an alternative to manually enumerating queries using the `refetchQueries` mutation option. Also, if you return a `Promise` from `reobserveQuery`, the mutation will automatically await that `Promise`, rendering the `awaitRefetchQueries` option unnecessary.
+- Mutations now accept an optional callback function called `onQueryUpdated`, which will be passed the `ObservableQuery` and `Cache.DiffResult` objects for any queries invalidated by cache writes performed by the mutation's final `update` function. Using `onQueryUpdated`, you can override the default `FetchPolicy` of the query, by (for example) calling `ObservableQuery` methods like `refetch` to force a network request. This automatic detection of invalidated queries provides an alternative to manually enumerating queries using the `refetchQueries` mutation option. Also, if you return a `Promise` from `onQueryUpdated`, the mutation will automatically await that `Promise`, rendering the `awaitRefetchQueries` option unnecessary.
[@benjamn](https://github.com/benjamn) in [#7827](https://github.com/apollographql/apollo-client/pull/7827) - Support `client.refetchQueries` as an imperative way to refetch queries, without having to pass `options.refetchQueries` to `client.mutate`.
[@dannycochran](https://github.com/dannycochran) in [#7431](https://github.com/apollographql/apollo-client/pull/7431) +- Improve standalone `client.refetchQueries` method to support automatic detection of queries needing to be refetched.
+ [@benjamn](https://github.com/benjamn) in [#8000](https://github.com/apollographql/apollo-client/pull/8000) + - When `@apollo/client` is imported as CommonJS (for example, in Node.js), the global `process` variable is now shadowed with a stripped-down object that includes only `process.env.NODE_ENV` (since that's all Apollo Client needs), eliminating the significant performance penalty of repeatedly accessing `process.env` at runtime.
[@benjamn](https://github.com/benjamn) in [#7627](https://github.com/apollographql/apollo-client/pull/7627) From 0ae162869439f4454af67499d1089fb427339332 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Wed, 19 May 2021 09:59:29 -0400 Subject: [PATCH 187/380] Bump @apollo/client npm version to 3.4.0-beta.28. --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index df62cbf1421..db635b58a5c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-beta.27", + "version": "3.4.0-beta.28", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index d57fb51e4eb..78217028c4f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-beta.27", + "version": "3.4.0-beta.28", "description": "A fully-featured caching GraphQL client.", "private": true, "keywords": [ From 31e0752f5c94e65f3da4ffd2e357aa4aefe4165b Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 18 May 2021 19:16:16 -0400 Subject: [PATCH 188/380] Restrict ObjectCanon prototypes to {Array,Object}.prototype and null. (#8260) --- src/cache/inmemory/__tests__/object-canon.ts | 4 +++- src/cache/inmemory/__tests__/readFromStore.ts | 12 ++++++------ src/cache/inmemory/helpers.ts | 1 - src/cache/inmemory/object-canon.ts | 11 ++++++----- src/cache/inmemory/readFromStore.ts | 2 +- 5 files changed, 16 insertions(+), 14 deletions(-) diff --git a/src/cache/inmemory/__tests__/object-canon.ts b/src/cache/inmemory/__tests__/object-canon.ts index 83014a7f3bb..8af77d614cf 100644 --- a/src/cache/inmemory/__tests__/object-canon.ts +++ b/src/cache/inmemory/__tests__/object-canon.ts @@ -46,7 +46,9 @@ describe("ObjectCanon", () => { expect(canon.admit(c2)).toBe(c2); }); - it("preserves custom prototypes", () => { + // TODO Reenable this when ObjectCanon allows enabling canonization for + // arbitrary prototypes (not just {Array,Object}.prototype and null). + it.skip("preserves custom prototypes", () => { const canon = new ObjectCanon; class Custom { diff --git a/src/cache/inmemory/__tests__/readFromStore.ts b/src/cache/inmemory/__tests__/readFromStore.ts index 3df74615432..54d9e691741 100644 --- a/src/cache/inmemory/__tests__/readFromStore.ts +++ b/src/cache/inmemory/__tests__/readFromStore.ts @@ -2044,11 +2044,11 @@ describe('reading from the store', () => { } const nonCanonicalQueryResult0 = readQuery(false); - expect(canon.isCanonical(nonCanonicalQueryResult0)).toBe(false); + expect(canon.isKnown(nonCanonicalQueryResult0)).toBe(false); expect(nonCanonicalQueryResult0).toEqual({ count: 0 }); const canonicalQueryResult0 = readQuery(true); - expect(canon.isCanonical(canonicalQueryResult0)).toBe(true); + expect(canon.isKnown(canonicalQueryResult0)).toBe(true); // The preservation of { count: 0 } proves the result didn't have to be // recomputed, but merely canonized. expect(canonicalQueryResult0).toEqual({ count: 0 }); @@ -2058,7 +2058,7 @@ describe('reading from the store', () => { }); const canonicalQueryResult1 = readQuery(true); - expect(canon.isCanonical(canonicalQueryResult1)).toBe(true); + expect(canon.isKnown(canonicalQueryResult1)).toBe(true); expect(canonicalQueryResult1).toEqual({ count: 1 }); const nonCanonicalQueryResult1 = readQuery(false); @@ -2101,7 +2101,7 @@ describe('reading from the store', () => { } const canonicalFragmentResult1 = readFragment(true); - expect(canon.isCanonical(canonicalFragmentResult1)).toBe(true); + expect(canon.isKnown(canonicalFragmentResult1)).toBe(true); expect(canonicalFragmentResult1).toEqual({ count: 0 }); const nonCanonicalFragmentResult1 = readFragment(false); @@ -2115,13 +2115,13 @@ describe('reading from the store', () => { const nonCanonicalFragmentResult2 = readFragment(false); expect(readFragment(false)).toBe(nonCanonicalFragmentResult2); - expect(canon.isCanonical(nonCanonicalFragmentResult2)).toBe(false); + expect(canon.isKnown(nonCanonicalFragmentResult2)).toBe(false); expect(nonCanonicalFragmentResult2).toEqual({ count: 1 }); expect(readFragment(false)).toBe(nonCanonicalFragmentResult2); const canonicalFragmentResult2 = readFragment(true); expect(readFragment(true)).toBe(canonicalFragmentResult2); - expect(canon.isCanonical(canonicalFragmentResult2)).toBe(true); + expect(canon.isKnown(canonicalFragmentResult2)).toBe(true); expect(canonicalFragmentResult2).toEqual({ count: 1 }); }); }); diff --git a/src/cache/inmemory/helpers.ts b/src/cache/inmemory/helpers.ts index 9777c8330a0..b4627cab30b 100644 --- a/src/cache/inmemory/helpers.ts +++ b/src/cache/inmemory/helpers.ts @@ -14,7 +14,6 @@ import { export const { hasOwnProperty: hasOwn, - toString: objToStr, } = Object.prototype; export function getTypenameFromStoreObject( diff --git a/src/cache/inmemory/object-canon.ts b/src/cache/inmemory/object-canon.ts index d9a2747e2a7..7c76971d220 100644 --- a/src/cache/inmemory/object-canon.ts +++ b/src/cache/inmemory/object-canon.ts @@ -1,6 +1,5 @@ import { Trie } from "@wry/trie"; import { canUseWeakMap } from "../../utilities"; -import { objToStr } from "./helpers"; function isObjectOrArray(value: any): value is object { return !!value && typeof value === "object"; @@ -82,7 +81,7 @@ export class ObjectCanon { keys?: SortedKeysInfo; }>(canUseWeakMap); - public isCanonical(value: any): boolean { + public isKnown(value: any): boolean { return isObjectOrArray(value) && this.known.has(value); } @@ -106,8 +105,9 @@ export class ObjectCanon { const original = this.passes.get(value); if (original) return original; - switch (objToStr.call(value)) { - case "[object Array]": { + const proto = Object.getPrototypeOf(value); + switch (proto) { + case Array.prototype: { if (this.known.has(value)) return value; const array: any[] = (value as any[]).map(this.admit, this); // Arrays are looked up in the Trie using their recursively @@ -126,7 +126,8 @@ export class ObjectCanon { return node.array; } - case "[object Object]": { + case null: + case Object.prototype: { if (this.known.has(value)) return value; const proto = Object.getPrototypeOf(value); const array = [proto]; diff --git a/src/cache/inmemory/readFromStore.ts b/src/cache/inmemory/readFromStore.ts index 2572c6c0607..803855c7526 100644 --- a/src/cache/inmemory/readFromStore.ts +++ b/src/cache/inmemory/readFromStore.ts @@ -273,7 +273,7 @@ export class StoreReader { // If result is canonical, then it could only have been previously // cached by the canonizing version of executeSelectionSet, so we can // avoid checking both possibilities here. - this.canon.isCanonical(result), + this.canon.isKnown(result), ); if (latest && result === latest.result) { return true; From 9fab4a15165b6897828254a7e839af5efd1519e6 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Wed, 19 May 2021 12:07:15 -0400 Subject: [PATCH 189/380] Bump @apollo/client npm version to 3.4.0-rc.0. :tada: This is the first _release candidate_ of Apollo Client v3.4, which means we will only be fixing bugs and finishing documentation (not adding any additional features, unless absolutely necessary to fix bugs) before the final 3.4.0 release. --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index db635b58a5c..321556c0e00 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-beta.28", + "version": "3.4.0-rc.0", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 78217028c4f..22927e59e6d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-beta.28", + "version": "3.4.0-rc.0", "description": "A fully-featured caching GraphQL client.", "private": true, "keywords": [ From 09ac78aadb928727346b73d5a5bd196153fb9764 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Tue, 18 May 2021 21:08:51 -0400 Subject: [PATCH 190/380] pass along missing field errors to the user --- src/cache/core/types/common.ts | 9 ++- src/core/ObservableQuery.ts | 11 +++ src/errors/index.ts | 14 ++-- src/react/hooks/__tests__/useQuery.test.tsx | 77 +++++++++++++++++++++ 4 files changed, 105 insertions(+), 6 deletions(-) diff --git a/src/cache/core/types/common.ts b/src/cache/core/types/common.ts index 87fb2c066a5..fdad9b47547 100644 --- a/src/cache/core/types/common.ts +++ b/src/cache/core/types/common.ts @@ -18,14 +18,19 @@ import { StorageType } from '../../inmemory/policies'; // Readonly, somewhat surprisingly. export type SafeReadonly = T extends object ? Readonly : T; -export class MissingFieldError { +export class MissingFieldError extends Error { constructor( public readonly message: string, public readonly path: (string | number)[], public readonly query: import('graphql').DocumentNode, public readonly clientOnly: boolean, public readonly variables?: Record, - ) {} + ) { + super(message); + // We're not using `Object.setPrototypeOf` here as it isn't fully + // supported on Android (see issue #3236). + (this as any).__proto__ = MissingFieldError.prototype; + } } export interface FieldSpecifier { diff --git a/src/core/ObservableQuery.ts b/src/core/ObservableQuery.ts index 76b31b12d51..998cbb9577a 100644 --- a/src/core/ObservableQuery.ts +++ b/src/core/ObservableQuery.ts @@ -162,6 +162,7 @@ export class ObservableQuery< !this.queryManager.transform(this.options.query).hasForcedResolvers ) { const diff = this.queryInfo.getDiff(); + // XXX the only reason this typechecks is that diff.result is inferred as any result.data = ( diff.complete || this.options.returnPartialData @@ -180,6 +181,16 @@ export class ObservableQuery< } else { result.partial = true; } + + if ( + !diff.complete && + !this.options.partialRefetch && + !result.loading && + !result.data && + !result.error + ) { + result.error = new ApolloError({ clientErrors: diff.missing }); + } } if (saveAsLastResult) { diff --git a/src/errors/index.ts b/src/errors/index.ts index 99b279d8588..956fa3df4e5 100644 --- a/src/errors/index.ts +++ b/src/errors/index.ts @@ -15,10 +15,12 @@ export function isApolloError(err: Error): err is ApolloError { const generateErrorMessage = (err: ApolloError) => { let message = ''; // If we have GraphQL errors present, add that to the error message. - if (isNonEmptyArray(err.graphQLErrors)) { - err.graphQLErrors.forEach((graphQLError: GraphQLError) => { - const errorMessage = graphQLError - ? graphQLError.message + if (isNonEmptyArray(err.graphQLErrors) || isNonEmptyArray(err.clientErrors)) { + const errors = ((err.graphQLErrors || []) as readonly Error[]) + .concat(err.clientErrors || []); + errors.forEach((error: Error) => { + const errorMessage = error + ? error.message : 'Error message not found.'; message += `${errorMessage}\n`; }); @@ -36,6 +38,7 @@ const generateErrorMessage = (err: ApolloError) => { export class ApolloError extends Error { public message: string; public graphQLErrors: ReadonlyArray; + public clientErrors: ReadonlyArray; public networkError: Error | ServerParseError | ServerError | null; // An object that can be used to provide some additional information @@ -48,17 +51,20 @@ export class ApolloError extends Error { // value or the constructed error will be meaningless. constructor({ graphQLErrors, + clientErrors, networkError, errorMessage, extraInfo, }: { graphQLErrors?: ReadonlyArray; + clientErrors?: ReadonlyArray; networkError?: Error | ServerParseError | ServerError | null; errorMessage?: string; extraInfo?: any; }) { super(errorMessage); this.graphQLErrors = graphQLErrors || []; + this.clientErrors = clientErrors || []; this.networkError = networkError || null; this.message = errorMessage || generateErrorMessage(this); this.extraInfo = extraInfo; diff --git a/src/react/hooks/__tests__/useQuery.test.tsx b/src/react/hooks/__tests__/useQuery.test.tsx index 5969458ac7e..126c93f6bd1 100644 --- a/src/react/hooks/__tests__/useQuery.test.tsx +++ b/src/react/hooks/__tests__/useQuery.test.tsx @@ -2645,6 +2645,83 @@ describe('useQuery Hook', () => { }); }); + describe('Missing Fields', () => { + itAsync( + 'should have errors populated with missing field errors from the cache', + (resolve, reject) => { + const carQuery: DocumentNode = gql` + query cars($id: Int) { + cars(id: $id) { + id + make + model + vin + __typename + } + } + `; + + const carData = { + cars: [ + { + id: 1, + make: 'Audi', + model: 'RS8', + vine: 'DOLLADOLLABILL', + __typename: 'Car' + } + ] + }; + + const mocks = [ + { + request: { query: carQuery, variables: { id: 1 } }, + result: { data: carData } + }, + ]; + + let renderCount = 0; + function App() { + const { loading, data, error } = useQuery(carQuery, { + variables: { id: 1 }, + }); + + switch (renderCount) { + case 0: + expect(loading).toBeTruthy(); + expect(data).toBeUndefined(); + expect(error).toBeUndefined(); + break; + case 1: + expect(loading).toBeFalsy(); + expect(data).toBeUndefined(); + expect(error).toBeDefined(); + // TODO: ApolloError.name is Error for some reason + // expect(error!.name).toBe(ApolloError); + expect(error!.clientErrors.length).toEqual(1); + expect(error!.message).toMatch(/Can't find field 'vin' on Car:1/); + break; + default: + throw new Error("Unexpected render"); + } + + renderCount += 1; + return null; + } + + render( + + + + ); + + return wait(() => { + expect(renderCount).toBe(2); + }).then(resolve, reject); + }, + ); + }); + describe('Previous data', () => { itAsync('should persist previous data when a query is re-run', (resolve, reject) => { const query = gql` From 2de299a5a8cd74643959d69a312f7ddb29b4c6d2 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Wed, 19 May 2021 12:43:35 -0400 Subject: [PATCH 191/380] update CHANGELOG and package.json --- CHANGELOG.md | 2 ++ package.json | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cb8d9d49fc8..6bbdd9067fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,6 +67,8 @@ - Fully remove result cache entries from LRU dependency system when the corresponding entities are removed from `InMemoryCache` by eviction, or by any other means.
[@sofianhn](https://github.com/sofianhn) and [@benjamn](https://github.com/benjamn) in [#8147](https://github.com/apollographql/apollo-client/pull/8147) +- Expose missing field errors in results.
[@brainkim](github.com/brainkim) in [#8262](https://github.com/apollographql/apollo-client/pull/8262) + ### Documentation TBD diff --git a/package.json b/package.json index 22927e59e6d..0a2ecc44ff8 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ { "name": "apollo-client", "path": "./dist/apollo-client.cjs.min.js", - "maxSize": "26.6 kB" + "maxSize": "26.62 kB" } ], "peerDependencies": { From 9ec7f1720a2a6b446c206061d72b4cbacac694e9 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Fri, 23 Apr 2021 16:51:05 -0400 Subject: [PATCH 192/380] Remove context.clientOnly tracking from executeSelectionSetImpl. https://github.com/apollographql/apollo-client/pull/8262#discussion_r636328571 --- src/cache/core/types/common.ts | 1 - src/cache/inmemory/__tests__/entityStore.ts | 4 ---- src/cache/inmemory/__tests__/policies.ts | 1 - src/cache/inmemory/__tests__/readFromStore.ts | 2 -- src/cache/inmemory/readFromStore.ts | 19 ------------------- 5 files changed, 27 deletions(-) diff --git a/src/cache/core/types/common.ts b/src/cache/core/types/common.ts index fdad9b47547..5c7ac7db401 100644 --- a/src/cache/core/types/common.ts +++ b/src/cache/core/types/common.ts @@ -23,7 +23,6 @@ export class MissingFieldError extends Error { public readonly message: string, public readonly path: (string | number)[], public readonly query: import('graphql').DocumentNode, - public readonly clientOnly: boolean, public readonly variables?: Record, ) { super(message); diff --git a/src/cache/inmemory/__tests__/entityStore.ts b/src/cache/inmemory/__tests__/entityStore.ts index 5f300956dfc..396b683aede 100644 --- a/src/cache/inmemory/__tests__/entityStore.ts +++ b/src/cache/inmemory/__tests__/entityStore.ts @@ -1213,14 +1213,12 @@ describe('EntityStore', () => { 'Can\'t find field \'hobby\' on Author:{"name":"Ted Chiang"} object', ["authorOfBook", "hobby"], expect.anything(), // query - false, // clientOnly expect.anything(), // variables ), new MissingFieldError( 'Can\'t find field \'publisherOfBook\' on ROOT_QUERY object', ["publisherOfBook"], expect.anything(), // query - false, // clientOnly expect.anything(), // variables ), ], @@ -1814,7 +1812,6 @@ describe('EntityStore', () => { "Dangling reference to missing Author:2 object", ["book", "author"], expect.anything(), // query - false, // clientOnly expect.anything(), // variables ), ]; @@ -2084,7 +2081,6 @@ describe('EntityStore', () => { 'Can\'t find field \'title\' on Book:{"isbn":"031648637X"} object', ["book", "title"], expect.anything(), // query - false, // clientOnly expect.anything(), // variables ), ], diff --git a/src/cache/inmemory/__tests__/policies.ts b/src/cache/inmemory/__tests__/policies.ts index 0c7267553a2..0721b76f896 100644 --- a/src/cache/inmemory/__tests__/policies.ts +++ b/src/cache/inmemory/__tests__/policies.ts @@ -1352,7 +1352,6 @@ describe("type policies", function () { `Can't find field 'result' on Job:{"name":"Job #${jobNumber}"} object`, ["jobs", jobNumber - 1, "result"], expect.anything(), // query - false, // clientOnly expect.anything(), // variables ); } diff --git a/src/cache/inmemory/__tests__/readFromStore.ts b/src/cache/inmemory/__tests__/readFromStore.ts index 54d9e691741..c3c1deff2a6 100644 --- a/src/cache/inmemory/__tests__/readFromStore.ts +++ b/src/cache/inmemory/__tests__/readFromStore.ts @@ -729,7 +729,6 @@ describe('reading from the store', () => { }`, ["normal", "missing"], query, - false, // clientOnly {}, // variables ), new MissingFieldError( @@ -738,7 +737,6 @@ describe('reading from the store', () => { }`, ["clientOnly", "missing"], query, - true, // clientOnly {}, // variables ), ]); diff --git a/src/cache/inmemory/readFromStore.ts b/src/cache/inmemory/readFromStore.ts index 803855c7526..36caf7ea1d4 100644 --- a/src/cache/inmemory/readFromStore.ts +++ b/src/cache/inmemory/readFromStore.ts @@ -46,7 +46,6 @@ interface ReadContext extends ReadMergeModifyContext { canonizeResults: boolean; fragmentMap: FragmentMap; path: (string | number)[]; - clientOnly: boolean; }; export type ExecResult = { @@ -62,7 +61,6 @@ function missingFromInvariant( err.message, context.path.slice(), context.query, - context.clientOnly, context.variables, ); } @@ -241,7 +239,6 @@ export class StoreReader { canonizeResults, fragmentMap: createFragmentMap(getFragmentDefinitions(query)), path: [], - clientOnly: false, }, }); @@ -344,20 +341,6 @@ export class StoreReader { const resultName = resultKeyNameFromField(selection); context.path.push(resultName); - // If this field has an @client directive, then the field and - // everything beneath it is client-only, meaning it will never be - // sent to the server. - const wasClientOnly = context.clientOnly; - // Once we enter a client-only subtree of the query, we can avoid - // repeatedly checking selection.directives. - context.clientOnly = wasClientOnly || !!( - // We don't use the hasDirectives helper here, because it looks - // for directives anywhere inside the AST node, whereas we only - // care about directives directly attached to this field. - selection.directives && - selection.directives.some(d => d.name.value === "client") - ); - if (fieldValue === void 0) { if (!addTypenameToDocument.added(selection)) { getMissing().push( @@ -407,8 +390,6 @@ export class StoreReader { objectsToMerge.push({ [resultName]: fieldValue }); } - context.clientOnly = wasClientOnly; - invariant(context.path.pop() === resultName); } else { From 4da232c44fd16bbe3bf996df2a8d66a71891ecea Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 25 May 2021 10:56:00 -0400 Subject: [PATCH 193/380] Bump @apollo/client npm version to 3.4.0-rc.1. --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index ebdb7cfa24a..78d668308a4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-rc.0", + "version": "3.4.0-rc.1", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 144c009d460..1e1b22b86a6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-rc.0", + "version": "3.4.0-rc.1", "description": "A fully-featured caching GraphQL client.", "private": true, "keywords": [ From 5dc9db1b98d8f5b04ab7dbdc061ad966816ec4d5 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Mon, 24 May 2021 11:47:09 -0400 Subject: [PATCH 194/380] Use canonicalStringify instead of JSON.stringify in more places. Possible thanks to #8222. --- src/cache/inmemory/inMemoryCache.ts | 2 +- src/cache/inmemory/readFromStore.ts | 4 ++-- src/cache/inmemory/writeToStore.ts | 3 ++- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/cache/inmemory/inMemoryCache.ts b/src/cache/inmemory/inMemoryCache.ts index a154204250d..dc28a4868b3 100644 --- a/src/cache/inmemory/inMemoryCache.ts +++ b/src/cache/inmemory/inMemoryCache.ts @@ -137,7 +137,7 @@ export class InMemoryCache extends ApolloCache { // separation is to include c.callback in the cache key for // maybeBroadcastWatch calls. See issue #5733. c.callback, - JSON.stringify({ optimistic, rootId, variables }), + canonicalStringify({ optimistic, rootId, variables }), ); } } diff --git a/src/cache/inmemory/readFromStore.ts b/src/cache/inmemory/readFromStore.ts index 36caf7ea1d4..b0ddc9fcad2 100644 --- a/src/cache/inmemory/readFromStore.ts +++ b/src/cache/inmemory/readFromStore.ts @@ -36,7 +36,7 @@ import { getTypenameFromStoreObject } from './helpers'; import { Policies } from './policies'; import { InMemoryCache } from './inMemoryCache'; import { MissingFieldError } from '../core/types/common'; -import { ObjectCanon } from './object-canon'; +import { canonicalStringify, ObjectCanon } from './object-canon'; export type VariableMap = { [name: string]: any }; @@ -235,7 +235,7 @@ export class StoreReader { query, policies, variables, - varString: JSON.stringify(variables), + varString: canonicalStringify(variables), canonizeResults, fragmentMap: createFragmentMap(getFragmentDefinitions(query)), path: [], diff --git a/src/cache/inmemory/writeToStore.ts b/src/cache/inmemory/writeToStore.ts index 091e47884db..d50f54ecdec 100644 --- a/src/cache/inmemory/writeToStore.ts +++ b/src/cache/inmemory/writeToStore.ts @@ -28,6 +28,7 @@ import { StoreReader } from './readFromStore'; import { InMemoryCache } from './inMemoryCache'; import { EntityStore } from './entityStore'; import { Cache } from '../../core'; +import { canonicalStringify } from './object-canon'; export interface WriteContext extends ReadMergeModifyContext { readonly written: { @@ -81,7 +82,7 @@ export class StoreWriter { return merger.merge(existing, incoming) as T; }, variables, - varString: JSON.stringify(variables), + varString: canonicalStringify(variables), fragmentMap: createFragmentMap(getFragmentDefinitions(query)), overwrite: !!overwrite, }, From f69f25006994679791239b969f23d2a62118a29b Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 25 May 2021 16:29:30 -0400 Subject: [PATCH 195/380] Use canonicalStringify in deduplicating identical simultaneous queries. --- src/core/QueryManager.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index 021d19fccf9..abcd0676267 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -3,7 +3,7 @@ import { invariant, InvariantError } from 'ts-invariant'; import { equal } from '@wry/equality'; import { ApolloLink, execute, FetchResult } from '../link/core'; -import { Cache, ApolloCache } from '../cache'; +import { Cache, ApolloCache, canonicalStringify } from '../cache'; import { getDefaultValues, @@ -836,7 +836,7 @@ export class QueryManager { const byVariables = inFlightLinkObservables.get(serverQuery) || new Map(); inFlightLinkObservables.set(serverQuery, byVariables); - const varJson = JSON.stringify(variables); + const varJson = canonicalStringify(variables); observable = byVariables.get(varJson); if (!observable) { From 51873d95f97f9192bb9d70776d8d4049efdc886c Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Mon, 24 May 2021 17:01:27 -0400 Subject: [PATCH 196/380] Delete ROOT_MUTATION from cache immediately after update. Should alleviate #3592, while also helping to isolate mutations from one another, so multiple mutation results never overlap in the cache. --- .../__snapshots__/mutationResults.ts.snap | 15 ----- src/__tests__/mutationResults.ts | 65 +++++++------------ src/__tests__/optimistic.ts | 9 --- src/core/QueryManager.ts | 11 ++++ .../hoc/__tests__/mutations/queries.test.tsx | 5 -- 5 files changed, 36 insertions(+), 69 deletions(-) diff --git a/src/__tests__/__snapshots__/mutationResults.ts.snap b/src/__tests__/__snapshots__/mutationResults.ts.snap index ebcfff7bb25..0359fdf4d36 100644 --- a/src/__tests__/__snapshots__/mutationResults.ts.snap +++ b/src/__tests__/__snapshots__/mutationResults.ts.snap @@ -8,12 +8,6 @@ Object { "__typename": "Person", "name": "Jenn Creighton", }, - "ROOT_MUTATION": Object { - "__typename": "Mutation", - "newPerson({\\"name\\":\\"Jenn Creighton\\"})": Object { - "__ref": "Person:{\\"name\\":\\"Jenn Creighton\\"}", - }, - }, } `; @@ -27,14 +21,5 @@ Object { "__typename": "Person", "name": "Jenn Creighton", }, - "ROOT_MUTATION": Object { - "__typename": "Mutation", - "newPerson({\\"name\\":\\"Ellen Shapiro\\"})": Object { - "__ref": "Person:{\\"name\\":\\"Ellen Shapiro\\"}", - }, - "newPerson({\\"name\\":\\"Jenn Creighton\\"})": Object { - "__ref": "Person:{\\"name\\":\\"Jenn Creighton\\"}", - }, - }, } `; diff --git a/src/__tests__/mutationResults.ts b/src/__tests__/mutationResults.ts index 3da12270b34..2744ef80a97 100644 --- a/src/__tests__/mutationResults.ts +++ b/src/__tests__/mutationResults.ts @@ -934,20 +934,15 @@ describe('mutation results', () => { client.mutate({ mutation, }), - ]) - .then(() => { - expect((client.cache as InMemoryCache).extract()).toEqual({ - ROOT_MUTATION: { - __typename: 'Mutation', - 'result({"a":1,"b":2})': 'hello', - 'result({"a":1,"c":3})': 'world', - 'result({"b":2,"c":3})': 'goodbye', - 'result({})': 'moon', - }, - }); - resolve(); - }) - .catch(reject); + ]).then(results => { + expect(client.cache.extract()).toEqual({}); + expect(results).toEqual([ + { data: { result: "hello" }}, + { data: { result: "world" }}, + { data: { result: "goodbye" }}, + { data: { result: "moon" }}, + ]); + }).then(resolve, reject); }); itAsync('allows mutations with default values', (resolve, reject) => { @@ -1012,19 +1007,14 @@ describe('mutation results', () => { mutation, variables: { c: 3 }, }), - ]) - .then(() => { - expect((client.cache as InMemoryCache).extract()).toEqual({ - ROOT_MUTATION: { - __typename: 'Mutation', - 'result({"a":1,"b":"water"})': 'hello', - 'result({"a":2,"b":"cheese","c":3})': 'world', - 'result({"a":1,"b":"cheese","c":3})': 'goodbye', - }, - }); - resolve(); - }) - .catch(reject); + ]).then(results => { + expect(client.cache.extract()).toEqual({}); + expect(results).toEqual([ + { data: { result: 'hello' }}, + { data: { result: 'world' }}, + { data: { result: 'goodbye' }}, + ]); + }).then(resolve, reject); }); itAsync('will pass null to the network interface when provided', (resolve, reject) => { @@ -1090,19 +1080,14 @@ describe('mutation results', () => { mutation, variables: { a: null, b: null, c: null }, }), - ]) - .then(() => { - expect((client.cache as InMemoryCache).extract()).toEqual({ - ROOT_MUTATION: { - __typename: 'Mutation', - 'result({"a":1,"b":2,"c":null})': 'hello', - 'result({"a":1,"b":null,"c":3})': 'world', - 'result({"a":null,"b":null,"c":null})': 'moon', - }, - }); - resolve(); - }) - .catch(reject); + ]).then(results => { + expect(client.cache.extract()).toEqual({}); + expect(results).toEqual([ + { data: { result: 'hello' }}, + { data: { result: 'world' }}, + { data: { result: 'moon' }}, + ]); + }).then(resolve, reject); }); describe('store transaction updater', () => { diff --git a/src/__tests__/optimistic.ts b/src/__tests__/optimistic.ts index 93d6fe6bfcf..981f7f1100a 100644 --- a/src/__tests__/optimistic.ts +++ b/src/__tests__/optimistic.ts @@ -2049,9 +2049,6 @@ describe('optimistic mutation results', () => { mutationItem, ], }, - ROOT_MUTATION: { - __typename: "Mutation", - }, }); // Now that the mutation is finished, reading optimistically from @@ -2083,9 +2080,6 @@ describe('optimistic mutation results', () => { manualItem2, ], }, - ROOT_MUTATION: { - __typename: "Mutation", - }, }); cache.removeOptimistic("manual"); @@ -2099,9 +2093,6 @@ describe('optimistic mutation results', () => { mutationItem, ], }, - ROOT_MUTATION: { - __typename: "Mutation", - }, }); }).then(() => { diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index abcd0676267..994a86e32d5 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -401,6 +401,17 @@ export class QueryManager { variables: mutation.variables, }); } + + // TODO Do this with cache.evict({ id: 'ROOT_MUTATION' }) but make it + // shallow to allow rolling back optimistic evictions. + if (!skipCache) { + cache.modify({ + id: 'ROOT_MUTATION', + fields(_fieldValue, { DELETE }) { + return DELETE; + }, + }); + } }, include: mutation.refetchQueries, diff --git a/src/react/hoc/__tests__/mutations/queries.test.tsx b/src/react/hoc/__tests__/mutations/queries.test.tsx index d31cca489f3..5ba0bb3c676 100644 --- a/src/react/hoc/__tests__/mutations/queries.test.tsx +++ b/src/react/hoc/__tests__/mutations/queries.test.tsx @@ -175,11 +175,6 @@ describe('graphql(mutation) query integration', () => { this.props.mutate!().then(result => { expect(stripSymbols(result && result.data)).toEqual(mutationData); }); - - const dataInStore = cache.extract(true); - expect(stripSymbols(dataInStore.ROOT_MUTATION!.createTodo)).toEqual( - optimisticResponse.createTodo - ); return null; } From aeaeedcc7b552b350663153ddf0182980e102357 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Wed, 31 Mar 2021 16:15:14 -0400 Subject: [PATCH 197/380] Allow field policy read functions to run for mutation result fields. Should fix #7665. --- src/__tests__/mutationResults.ts | 95 +++++++++++++++++++++ src/core/QueryManager.ts | 136 ++++++++++++++++++------------- 2 files changed, 176 insertions(+), 55 deletions(-) diff --git a/src/__tests__/mutationResults.ts b/src/__tests__/mutationResults.ts index 2744ef80a97..c43d5a8a70c 100644 --- a/src/__tests__/mutationResults.ts +++ b/src/__tests__/mutationResults.ts @@ -494,6 +494,101 @@ describe('mutation results', () => { ).then(resolve, reject); }); + describe('InMemoryCache type/field policies', () => { + const startTime = Date.now(); + const link = new ApolloLink(operation => new Observable(observer => { + observer.next({ + data: { + __typename: "Mutation", + doSomething: { + __typename: "MutationPayload", + time: startTime, + }, + }, + }); + observer.complete(); + })); + + const mutation = gql` + mutation DoSomething { + doSomething { + time + } + } + `; + + it('mutation update function receives result from cache', () => { + let timeReadCount = 0; + let timeMergeCount = 0; + + const client = new ApolloClient({ + link, + cache: new InMemoryCache({ + typePolicies: { + MutationPayload: { + fields: { + time: { + read(ms: number = Date.now()) { + ++timeReadCount; + return new Date(ms); + }, + merge(existing, incoming: number) { + ++timeMergeCount; + expect(existing).toBeUndefined(); + return incoming; + }, + }, + }, + }, + }, + }), + }); + + return client.mutate({ + mutation, + update(cache, { + data: { + doSomething: { + __typename, + time, + }, + }, + }) { + expect(__typename).toBe("MutationPayload"); + expect(time).toBeInstanceOf(Date); + expect(time.getTime()).toBe(startTime); + expect(timeReadCount).toBe(1); + expect(timeMergeCount).toBe(1); + expect(cache.extract()).toEqual({ + ROOT_MUTATION: { + __typename: "Mutation", + doSomething: { + __typename: "MutationPayload", + time: startTime, + }, + }, + }); + }, + }).then(({ + data: { + doSomething: { + __typename, + time, + }, + }, + }) => { + expect(__typename).toBe("MutationPayload"); + expect(time).toBeInstanceOf(Date); + expect(time.getTime()).toBe(startTime); + expect(timeReadCount).toBe(1); + expect(timeMergeCount).toBe(1); + // The ROOT_MUTATION object exists only briefly, for the duration of the + // mutation update, and is removed after the mutation write is finished. + expect(client.cache.extract()).toEqual({}); + }); + }); + }); + describe('updateQueries', () => { const mutation = gql` mutation createTodo { diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index 994a86e32d5..75c2f7cfad9 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -64,6 +64,16 @@ interface MutationStoreValue { type UpdateQueries = MutationOptions["updateQueries"]; +interface TransformCacheEntry { + document: DocumentNode; + hasClientExports: boolean; + hasForcedResolvers: boolean; + clientQuery: DocumentNode | null; + serverQuery: DocumentNode | null; + defaultVars: OperationVariables; + asQuery: DocumentNode; +} + export class QueryManager { public cache: ApolloCache; public link: ApolloLink; @@ -239,47 +249,25 @@ export class QueryManager { delete storeResult.errors; } - if (fetchPolicy === 'no-cache') { - const results: any[] = []; - - this.refetchQueries({ - include: refetchQueries, - onQueryUpdated, - }).forEach(result => results.push(result)); - - return Promise.all( - awaitRefetchQueries ? results : [], - ).then(() => storeResult); - } - - const markPromise = self.markMutationResult< + return self.markMutationResult< TData, TVariables, TContext, TCache >({ mutationId, - result, + result: storeResult, document: mutation, variables, errorPolicy, context, update: updateWithProxyFn, updateQueries, + awaitRefetchQueries, refetchQueries, removeOptimistic: optimisticResponse ? mutationId : void 0, onQueryUpdated, }); - - if (awaitRefetchQueries || onQueryUpdated) { - // Returning the result of markMutationResult here makes the - // mutation await the Promise that markMutationResult returns, - // since we are returning markPromise from the map function - // we passed to asyncMap above. - return markPromise.then(() => storeResult); - } - - return storeResult; }, ).subscribe({ @@ -332,19 +320,23 @@ export class QueryManager { context?: TContext; updateQueries: UpdateQueries; update?: MutationUpdaterFunction; + awaitRefetchQueries?: boolean; refetchQueries?: RefetchQueryDescription; removeOptimistic?: string; onQueryUpdated?: OnQueryUpdated; }, cache = this.cache, - ): Promise { - if (shouldWriteResult(mutation.result, mutation.errorPolicy)) { - const cacheWrites: Cache.WriteOptions[] = [{ - result: mutation.result.data, + ): Promise> { + let { result } = mutation; + const cacheWrites: Cache.WriteOptions[] = []; + + if (shouldWriteResult(result, mutation.errorPolicy)) { + cacheWrites.push({ + result: result.data, dataId: 'ROOT_MUTATION', query: mutation.document, variables: mutation.variables, - }]; + }); const { updateQueries } = mutation; if (updateQueries) { @@ -367,7 +359,7 @@ export class QueryManager { if (complete && currentQueryResult) { // Run our reducer using the current query result and the mutation result. const nextQueryResult = updater(currentQueryResult, { - mutationResult: mutation.result, + mutationResult: result, queryName: document && getOperationName(document) || void 0, queryVariables: variables!, }); @@ -384,11 +376,19 @@ export class QueryManager { } }); } + } + if ( + cacheWrites.length > 0 || + mutation.refetchQueries || + mutation.update || + mutation.onQueryUpdated || + mutation.removeOptimistic + ) { const results: any[] = []; this.refetchQueries({ - updateCache(cache: TCache) { + updateCache: (cache: TCache) => { cacheWrites.forEach(write => cache.write(write)); // If the mutation has some writes associated with it then we need to @@ -396,7 +396,26 @@ export class QueryManager { // a write action. const { update } = mutation; if (update) { - update(cache, mutation.result, { + // Re-read the ROOT_MUTATION data we just wrote into the cache + // (the first cache.write call in the cacheWrites.forEach loop + // above), so field read functions have a chance to run for + // fields within mutation result objects. + const diff = cache.diff({ + id: "ROOT_MUTATION", + // The cache complains if passed a mutation where it expects a + // query, so we transform mutations and subscriptions to queries + // (only once, thanks to this.transformCache). + query: this.transform(mutation.document).asQuery, + variables: mutation.variables, + optimistic: false, + returnPartialData: true, + }); + + if (diff.complete) { + result = { ...result, data: diff.result }; + } + + update(cache, result, { context: mutation.context, variables: mutation.variables, }); @@ -404,14 +423,12 @@ export class QueryManager { // TODO Do this with cache.evict({ id: 'ROOT_MUTATION' }) but make it // shallow to allow rolling back optimistic evictions. - if (!skipCache) { - cache.modify({ - id: 'ROOT_MUTATION', - fields(_fieldValue, { DELETE }) { - return DELETE; - }, - }); - } + cache.modify({ + id: 'ROOT_MUTATION', + fields(_fieldValue, { DELETE }) { + return DELETE; + }, + }); }, include: mutation.refetchQueries, @@ -431,10 +448,15 @@ export class QueryManager { }).forEach(result => results.push(result)); - return Promise.all(results).then(() => void 0); + if (mutation.awaitRefetchQueries || mutation.onQueryUpdated) { + // Returning a promise here makes the mutation await that promise, so we + // include results in that promise's work if awaitRefetchQueries or an + // onQueryUpdated function was specified. + return Promise.all(results).then(() => result); + } } - return Promise.resolve(); + return Promise.resolve(result); } public markMutationOptimistic>( @@ -498,17 +520,9 @@ export class QueryManager { } } - private transformCache = new (canUseWeakMap ? WeakMap : Map)< - DocumentNode, - Readonly<{ - document: Readonly; - hasClientExports: boolean; - hasForcedResolvers: boolean; - clientQuery: Readonly | null; - serverQuery: Readonly | null; - defaultVars: Readonly; - }> - >(); + private transformCache = new ( + canUseWeakMap ? WeakMap : Map + )(); public transform(document: DocumentNode) { const { transformCache } = this; @@ -521,7 +535,7 @@ export class QueryManager { const clientQuery = this.localState.clientQuery(transformed); const serverQuery = forLink && this.localState.serverQuery(forLink); - const cacheEntry = { + const cacheEntry: TransformCacheEntry = { document: transformed, // TODO These two calls (hasClientExports and shouldForceResolvers) // could probably be merged into a single traversal. @@ -532,6 +546,18 @@ export class QueryManager { defaultVars: getDefaultValues( getOperationDefinition(transformed) ) as OperationVariables, + // Transform any mutation or subscription operations to query operations + // so we can read/write them from/to the cache. + asQuery: { + ...transformed, + definitions: transformed.definitions.map(def => { + if (def.kind === "OperationDefinition" && + def.operation !== "query") { + return { ...def, operation: "query" }; + } + return def; + }), + } }; const add = (doc: DocumentNode | null) => { From b7ad4416914a210963363b240dcb2818c4b977df Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Mon, 24 May 2021 17:15:02 -0400 Subject: [PATCH 198/380] Enforce fetchPolicy:"no-cache" more consistently for mutations. While I was working with this code, I noticed that the fetchPolicy: "no-cache" option for mutations was not always respected (cache writes would happen anyway), so I fixed that. --- src/__tests__/mutationResults.ts | 65 ++++++++++++++++++++++++++++++++ src/core/QueryManager.ts | 59 +++++++++++++++++------------ 2 files changed, 100 insertions(+), 24 deletions(-) diff --git a/src/__tests__/mutationResults.ts b/src/__tests__/mutationResults.ts index c43d5a8a70c..729f72eb110 100644 --- a/src/__tests__/mutationResults.ts +++ b/src/__tests__/mutationResults.ts @@ -587,6 +587,71 @@ describe('mutation results', () => { expect(client.cache.extract()).toEqual({}); }); }); + + it('mutation update function runs even when fetchPolicy is "no-cache"', async () => { + let timeReadCount = 0; + let timeMergeCount = 0; + let mutationUpdateCount = 0; + + const client = new ApolloClient({ + link, + cache: new InMemoryCache({ + typePolicies: { + MutationPayload: { + fields: { + time: { + read(ms: number = Date.now()) { + ++timeReadCount; + return new Date(ms); + }, + merge(existing, incoming: number) { + ++timeMergeCount; + expect(existing).toBeUndefined(); + return incoming; + }, + }, + }, + }, + }, + }), + }); + + return client.mutate({ + mutation, + fetchPolicy: "no-cache", + update(cache, { + data: { + doSomething: { + __typename, + time, + }, + }, + }) { + expect(++mutationUpdateCount).toBe(1); + expect(__typename).toBe("MutationPayload"); + expect(time).not.toBeInstanceOf(Date); + expect(time).toBe(startTime); + expect(timeReadCount).toBe(0); + expect(timeMergeCount).toBe(0); + expect(cache.extract()).toEqual({}); + }, + }).then(({ + data: { + doSomething: { + __typename, + time, + }, + }, + }) => { + expect(__typename).toBe("MutationPayload"); + expect(time).not.toBeInstanceOf(Date); + expect(time).toBe(+startTime); + expect(timeReadCount).toBe(0); + expect(timeMergeCount).toBe(0); + expect(mutationUpdateCount).toBe(1); + expect(client.cache.extract()).toEqual({}); + }); + }); }); describe('updateQueries', () => { diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index 75c2f7cfad9..d81830f878a 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -203,6 +203,7 @@ export class QueryManager { mutationId, document: mutation, variables, + fetchPolicy, errorPolicy, context, updateQueries, @@ -259,6 +260,7 @@ export class QueryManager { result: storeResult, document: mutation, variables, + fetchPolicy, errorPolicy, context, update: updateWithProxyFn, @@ -316,6 +318,7 @@ export class QueryManager { result: FetchResult; document: DocumentNode; variables?: TVariables; + fetchPolicy?: "no-cache"; errorPolicy: ErrorPolicy; context?: TContext; updateQueries: UpdateQueries; @@ -329,8 +332,9 @@ export class QueryManager { ): Promise> { let { result } = mutation; const cacheWrites: Cache.WriteOptions[] = []; + const skipCache = mutation.fetchPolicy === "no-cache"; - if (shouldWriteResult(result, mutation.errorPolicy)) { + if (!skipCache && shouldWriteResult(result, mutation.errorPolicy)) { cacheWrites.push({ result: result.data, dataId: 'ROOT_MUTATION', @@ -389,30 +393,34 @@ export class QueryManager { this.refetchQueries({ updateCache: (cache: TCache) => { - cacheWrites.forEach(write => cache.write(write)); + if (!skipCache) { + cacheWrites.forEach(write => cache.write(write)); + } // If the mutation has some writes associated with it then we need to // apply those writes to the store by running this reducer again with // a write action. const { update } = mutation; if (update) { - // Re-read the ROOT_MUTATION data we just wrote into the cache - // (the first cache.write call in the cacheWrites.forEach loop - // above), so field read functions have a chance to run for - // fields within mutation result objects. - const diff = cache.diff({ - id: "ROOT_MUTATION", - // The cache complains if passed a mutation where it expects a - // query, so we transform mutations and subscriptions to queries - // (only once, thanks to this.transformCache). - query: this.transform(mutation.document).asQuery, - variables: mutation.variables, - optimistic: false, - returnPartialData: true, - }); + if (!skipCache) { + // Re-read the ROOT_MUTATION data we just wrote into the cache + // (the first cache.write call in the cacheWrites.forEach loop + // above), so field read functions have a chance to run for + // fields within mutation result objects. + const diff = cache.diff({ + id: "ROOT_MUTATION", + // The cache complains if passed a mutation where it expects a + // query, so we transform mutations and subscriptions to queries + // (only once, thanks to this.transformCache). + query: this.transform(mutation.document).asQuery, + variables: mutation.variables, + optimistic: false, + returnPartialData: true, + }); - if (diff.complete) { - result = { ...result, data: diff.result }; + if (diff.complete) { + result = { ...result, data: diff.result }; + } } update(cache, result, { @@ -423,12 +431,14 @@ export class QueryManager { // TODO Do this with cache.evict({ id: 'ROOT_MUTATION' }) but make it // shallow to allow rolling back optimistic evictions. - cache.modify({ - id: 'ROOT_MUTATION', - fields(_fieldValue, { DELETE }) { - return DELETE; - }, - }); + if (!skipCache) { + cache.modify({ + id: 'ROOT_MUTATION', + fields(_fieldValue, { DELETE }) { + return DELETE; + }, + }); + } }, include: mutation.refetchQueries, @@ -465,6 +475,7 @@ export class QueryManager { mutationId: string; document: DocumentNode; variables?: TVariables; + fetchPolicy?: "no-cache"; errorPolicy: ErrorPolicy; context?: TContext; updateQueries: UpdateQueries, From 8a8f1d1177ff39b44d5003c475e4c3adefdbe101 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Wed, 26 May 2021 10:27:56 -0400 Subject: [PATCH 199/380] Remove only non-__typename fields from ROOT_MUTATION. I suspect this change will be slightly less disruptive than completely removing the ROOT_MUTATION object from the cache after each mutation. It's safe to keep __typename because it isn't sensitive information, like the arguments of root mutation fields. It's convenient to keep __typename because that data (and the enclosing object) would otherwise have to be recreated the next time a mutation writes its results into the cache. We could use the mutation.document root fields to perform this removal more precisely, but that's more work for no clear gain. --- .../__snapshots__/mutationResults.ts.snap | 6 ++++ src/__tests__/mutationResults.ts | 30 +++++++++++++++---- src/__tests__/optimistic.ts | 9 ++++++ src/core/QueryManager.ts | 4 +-- 4 files changed, 41 insertions(+), 8 deletions(-) diff --git a/src/__tests__/__snapshots__/mutationResults.ts.snap b/src/__tests__/__snapshots__/mutationResults.ts.snap index 0359fdf4d36..211d8a2fa53 100644 --- a/src/__tests__/__snapshots__/mutationResults.ts.snap +++ b/src/__tests__/__snapshots__/mutationResults.ts.snap @@ -8,6 +8,9 @@ Object { "__typename": "Person", "name": "Jenn Creighton", }, + "ROOT_MUTATION": Object { + "__typename": "Mutation", + }, } `; @@ -21,5 +24,8 @@ Object { "__typename": "Person", "name": "Jenn Creighton", }, + "ROOT_MUTATION": Object { + "__typename": "Mutation", + }, } `; diff --git a/src/__tests__/mutationResults.ts b/src/__tests__/mutationResults.ts index 729f72eb110..4dcf30030b7 100644 --- a/src/__tests__/mutationResults.ts +++ b/src/__tests__/mutationResults.ts @@ -582,9 +582,15 @@ describe('mutation results', () => { expect(time.getTime()).toBe(startTime); expect(timeReadCount).toBe(1); expect(timeMergeCount).toBe(1); - // The ROOT_MUTATION object exists only briefly, for the duration of the - // mutation update, and is removed after the mutation write is finished. - expect(client.cache.extract()).toEqual({}); + + // The contents of the ROOT_MUTATION object exist only briefly, for the + // duration of the mutation update, and are removed after the mutation + // write is finished. + expect(client.cache.extract()).toEqual({ + ROOT_MUTATION: { + __typename: "Mutation", + }, + }); }); }); @@ -1095,7 +1101,11 @@ describe('mutation results', () => { mutation, }), ]).then(results => { - expect(client.cache.extract()).toEqual({}); + expect(client.cache.extract()).toEqual({ + ROOT_MUTATION: { + __typename: "Mutation", + }, + }); expect(results).toEqual([ { data: { result: "hello" }}, { data: { result: "world" }}, @@ -1168,7 +1178,11 @@ describe('mutation results', () => { variables: { c: 3 }, }), ]).then(results => { - expect(client.cache.extract()).toEqual({}); + expect(client.cache.extract()).toEqual({ + ROOT_MUTATION: { + __typename: "Mutation", + }, + }); expect(results).toEqual([ { data: { result: 'hello' }}, { data: { result: 'world' }}, @@ -1241,7 +1255,11 @@ describe('mutation results', () => { variables: { a: null, b: null, c: null }, }), ]).then(results => { - expect(client.cache.extract()).toEqual({}); + expect(client.cache.extract()).toEqual({ + ROOT_MUTATION: { + __typename: "Mutation", + }, + }); expect(results).toEqual([ { data: { result: 'hello' }}, { data: { result: 'world' }}, diff --git a/src/__tests__/optimistic.ts b/src/__tests__/optimistic.ts index 981f7f1100a..93d6fe6bfcf 100644 --- a/src/__tests__/optimistic.ts +++ b/src/__tests__/optimistic.ts @@ -2049,6 +2049,9 @@ describe('optimistic mutation results', () => { mutationItem, ], }, + ROOT_MUTATION: { + __typename: "Mutation", + }, }); // Now that the mutation is finished, reading optimistically from @@ -2080,6 +2083,9 @@ describe('optimistic mutation results', () => { manualItem2, ], }, + ROOT_MUTATION: { + __typename: "Mutation", + }, }); cache.removeOptimistic("manual"); @@ -2093,6 +2099,9 @@ describe('optimistic mutation results', () => { mutationItem, ], }, + ROOT_MUTATION: { + __typename: "Mutation", + }, }); }).then(() => { diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index d81830f878a..5cf7386bcc2 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -434,8 +434,8 @@ export class QueryManager { if (!skipCache) { cache.modify({ id: 'ROOT_MUTATION', - fields(_fieldValue, { DELETE }) { - return DELETE; + fields(value, { fieldName, DELETE }) { + return fieldName === "__typename" ? value : DELETE; }, }); } From 7a3b6b98c4fe63480943653511d5a2ac9a829b2e Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Wed, 26 May 2021 11:16:32 -0400 Subject: [PATCH 200/380] Reorganize mutation options types within src/react/types.ts. --- src/core/watchQueryOptions.ts | 24 ++++++++--------- src/react/components/Mutation.tsx | 4 +-- src/react/components/Query.tsx | 2 +- src/react/components/Subscription.tsx | 2 +- src/react/types/types.ts | 39 +++++---------------------- 5 files changed, 23 insertions(+), 48 deletions(-) diff --git a/src/core/watchQueryOptions.ts b/src/core/watchQueryOptions.ts index ed958668540..c7cc51d25e5 100644 --- a/src/core/watchQueryOptions.ts +++ b/src/core/watchQueryOptions.ts @@ -270,6 +270,18 @@ export interface MutationBaseOptions< * GraphQL document to that variable's value. */ variables?: TVariables; + + /** + * The context to be passed to the link execution chain. This context will + * only be used with this mutation. It will not be used with + * `refetchQueries`. Refetched queries use the context they were + * initialized with (since the intitial context is stored as part of the + * `ObservableQuery` instance). If a specific context is needed when + * refetching queries, make sure it is configured (via the + * [query `context` option](https://www.apollographql.com/docs/react/api/apollo-client#ApolloClient.query)) + * when the query is first initialized/run. + */ + context?: TContext; } export interface MutationOptions< @@ -284,18 +296,6 @@ export interface MutationOptions< */ mutation: DocumentNode | TypedDocumentNode; - /** - * The context to be passed to the link execution chain. This context will - * only be used with the mutation. It will not be used with - * `refetchQueries`. Refetched queries use the context they were - * initialized with (since the intitial context is stored as part of the - * `ObservableQuery` instance). If a specific context is needed when - * refetching queries, make sure it is configured (via the - * [`query` `context` option](https://www.apollographql.com/docs/react/api/apollo-client#ApolloClient.query)) - * when the query is first initialized/run. - */ - context?: TContext; - /** * Specifies the {@link FetchPolicy} to be used for this query. Mutations only * support a 'no-cache' fetchPolicy. If you don't want to disable the cache, diff --git a/src/react/components/Mutation.tsx b/src/react/components/Mutation.tsx index 0a1f7569039..f4ebc454bbd 100644 --- a/src/react/components/Mutation.tsx +++ b/src/react/components/Mutation.tsx @@ -30,5 +30,5 @@ Mutation.propTypes = { children: PropTypes.func.isRequired, onCompleted: PropTypes.func, onError: PropTypes.func, - fetchPolicy: PropTypes.string -}; + fetchPolicy: PropTypes.string, +} as Mutation["propTypes"]; diff --git a/src/react/components/Query.tsx b/src/react/components/Query.tsx index ee7b79c3253..ad9b2aa23c2 100644 --- a/src/react/components/Query.tsx +++ b/src/react/components/Query.tsx @@ -29,4 +29,4 @@ Query.propTypes = { ssr: PropTypes.bool, partialRefetch: PropTypes.bool, returnPartialData: PropTypes.bool -}; +} as Query["propTypes"]; diff --git a/src/react/components/Subscription.tsx b/src/react/components/Subscription.tsx index 696c201936f..4704ad67a0c 100644 --- a/src/react/components/Subscription.tsx +++ b/src/react/components/Subscription.tsx @@ -22,4 +22,4 @@ Subscription.propTypes = { onSubscriptionData: PropTypes.func, onSubscriptionComplete: PropTypes.func, shouldResubscribe: PropTypes.oneOfType([PropTypes.func, PropTypes.bool]) -}; +} as Subscription["propTypes"]; diff --git a/src/react/types/types.ts b/src/react/types/types.ts index 9d165d1e97f..12e2d7aa12a 100644 --- a/src/react/types/types.ts +++ b/src/react/types/types.ts @@ -10,17 +10,14 @@ import { ApolloClient, ApolloQueryResult, DefaultContext, - ErrorPolicy, FetchMoreOptions, FetchMoreQueryOptions, FetchPolicy, - MutationUpdaterFunction, + MutationOptions, NetworkStatus, ObservableQuery, OperationVariables, PureQueryOptions, - OnQueryUpdated, - WatchQueryFetchPolicy, WatchQueryOptions, } from '../../core'; @@ -147,24 +144,14 @@ export interface BaseMutationOptions< TVariables extends OperationVariables, TContext extends DefaultContext, TCache extends ApolloCache, +> extends Omit< + MutationOptions, + | "mutation" > { - variables?: TVariables; - optimisticResponse?: TData | ((vars: TVariables) => TData); - refetchQueries?: Array | RefetchQueriesFunction; - awaitRefetchQueries?: boolean; - errorPolicy?: ErrorPolicy; - update?: MutationUpdaterFunction; - // Use OnQueryUpdated instead of OnQueryUpdated here because TData - // is the shape of the mutation result, but onQueryUpdated gets called with - // results from any queries affected by the mutation update function, which - // probably do not have the same shape as the mutation result. - onQueryUpdated?: OnQueryUpdated; client?: ApolloClient; notifyOnNetworkStatusChange?: boolean; - context?: TContext; onCompleted?: (data: TData) => void; onError?: (error: ApolloError) => void; - fetchPolicy?: Extract; ignoreResults?: boolean; } @@ -173,19 +160,8 @@ export interface MutationFunctionOptions< TVariables, TContext, TCache extends ApolloCache, -> { - variables?: TVariables; - optimisticResponse?: TData | ((vars: TVariables) => TData); - refetchQueries?: Array | RefetchQueriesFunction; - awaitRefetchQueries?: boolean; - update?: MutationUpdaterFunction; - // Use OnQueryUpdated instead of OnQueryUpdated here because TData - // is the shape of the mutation result, but onQueryUpdated gets called with - // results from any queries affected by the mutation update function, which - // probably do not have the same shape as the mutation result. - onQueryUpdated?: OnQueryUpdated; - context?: TContext; - fetchPolicy?: WatchQueryFetchPolicy; +> extends BaseMutationOptions { + mutation?: DocumentNode | TypedDocumentNode; } export interface MutationResult { @@ -219,8 +195,7 @@ export interface MutationDataOptions< TVariables extends OperationVariables, TContext extends DefaultContext, TCache extends ApolloCache, -> - extends BaseMutationOptions { +> extends BaseMutationOptions { mutation: DocumentNode | TypedDocumentNode; } From 6013f2eaccaff95b8064f95b1701b8d7c3af0f7e Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Wed, 26 May 2021 10:49:29 -0400 Subject: [PATCH 201/380] Provide an option to keep ROOT_MUTATION fields if necessary. This option should limit the disruptiveness of PR #8280 (even though removing ROOT_MUTATION fields is the default behavior), because at least there's an easy way to achieve the old behavior again (that is, by passing `keepRootFields: true` to `client.mutate`). --- CHANGELOG.md | 3 + src/__tests__/mutationResults.ts | 79 +++++ src/core/QueryManager.ts | 7 +- src/core/watchQueryOptions.ts | 11 + .../hooks/__tests__/useMutation.test.tsx | 289 +++++++++++++++++- 5 files changed, 387 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bbdd9067fa..3a4a12db828 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,9 @@ ### Potentially breaking changes +- To avoid retaining sensitive information from mutation root field arguments, Apollo Client v3.4 automatically clears any `ROOT_MUTATION` fields from the cache after each mutation finishes. If you need this information to remain in the cache, you can prevent the removal by passing the `keepRootFields: true` option to `client.mutate`. `ROOT_MUTATION` result data are also passed to the mutation `update` function, so we recommend obtaining the results that way, rather than using `keepRootFields: true`, if possible.
+ [@benjamn](https://github.com/benjamn) in [#8280](https://github.com/apollographql/apollo-client/pull/8280) + - Internally, Apollo Client now uses namespace syntax (e.g. `import * as React from "react"`) for imports whose types are re-exported (and thus may appear in `.d.ts` files). This change should remove any need to configure `esModuleInterop` or `allowSyntheticDefaultImports` in `tsconfig.json`, but might require updating bundler configurations that specify named exports of the `react` and `prop-types` packages, to include exports like `createContext` and `createElement` ([example](https://github.com/apollographql/apollo-client/commit/16b08e1af9ba9934041298496e167aafb128c15d)).
[@devrelm](https://github.com/devrelm) in [#7742](https://github.com/apollographql/apollo-client/pull/7742) diff --git a/src/__tests__/mutationResults.ts b/src/__tests__/mutationResults.ts index 4dcf30030b7..bc8ca258580 100644 --- a/src/__tests__/mutationResults.ts +++ b/src/__tests__/mutationResults.ts @@ -594,6 +594,85 @@ describe('mutation results', () => { }); }); + it('mutations can preserve ROOT_MUTATION cache data with keepRootFields: true', () => { + let timeReadCount = 0; + let timeMergeCount = 0; + + const client = new ApolloClient({ + link, + cache: new InMemoryCache({ + typePolicies: { + MutationPayload: { + fields: { + time: { + read(ms: number = Date.now()) { + ++timeReadCount; + return new Date(ms); + }, + merge(existing, incoming: number) { + ++timeMergeCount; + expect(existing).toBeUndefined(); + return incoming; + }, + }, + }, + }, + }, + }), + }); + + return client.mutate({ + mutation, + keepRootFields: true, + update(cache, { + data: { + doSomething: { + __typename, + time, + }, + }, + }) { + expect(__typename).toBe("MutationPayload"); + expect(time).toBeInstanceOf(Date); + expect(time.getTime()).toBe(startTime); + expect(timeReadCount).toBe(1); + expect(timeMergeCount).toBe(1); + expect(cache.extract()).toEqual({ + ROOT_MUTATION: { + __typename: "Mutation", + doSomething: { + __typename: "MutationPayload", + time: startTime, + }, + }, + }); + }, + }).then(({ + data: { + doSomething: { + __typename, + time, + }, + }, + }) => { + expect(__typename).toBe("MutationPayload"); + expect(time).toBeInstanceOf(Date); + expect(time.getTime()).toBe(startTime); + expect(timeReadCount).toBe(1); + expect(timeMergeCount).toBe(1); + + expect(client.cache.extract()).toEqual({ + ROOT_MUTATION: { + __typename: "Mutation", + doSomething: { + __typename: "MutationPayload", + time: startTime, + }, + }, + }); + }); + }); + it('mutation update function runs even when fetchPolicy is "no-cache"', async () => { let timeReadCount = 0; let timeMergeCount = 0; diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index 5cf7386bcc2..1ffba7f1030 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -163,6 +163,7 @@ export class QueryManager { onQueryUpdated, errorPolicy = 'none', fetchPolicy, + keepRootFields, context, }: MutationOptions): Promise> { invariant( @@ -208,6 +209,7 @@ export class QueryManager { context, updateQueries, update: updateWithProxyFn, + keepRootFields, }); } @@ -269,6 +271,7 @@ export class QueryManager { refetchQueries, removeOptimistic: optimisticResponse ? mutationId : void 0, onQueryUpdated, + keepRootFields, }); }, @@ -327,6 +330,7 @@ export class QueryManager { refetchQueries?: RefetchQueryDescription; removeOptimistic?: string; onQueryUpdated?: OnQueryUpdated; + keepRootFields?: boolean; }, cache = this.cache, ): Promise> { @@ -431,7 +435,7 @@ export class QueryManager { // TODO Do this with cache.evict({ id: 'ROOT_MUTATION' }) but make it // shallow to allow rolling back optimistic evictions. - if (!skipCache) { + if (!skipCache && !mutation.keepRootFields) { cache.modify({ id: 'ROOT_MUTATION', fields(value, { fieldName, DELETE }) { @@ -480,6 +484,7 @@ export class QueryManager { context?: TContext; updateQueries: UpdateQueries, update?: MutationUpdaterFunction; + keepRootFields?: boolean, }, ) { const data = typeof optimisticResponse === "function" diff --git a/src/core/watchQueryOptions.ts b/src/core/watchQueryOptions.ts index c7cc51d25e5..69e77888279 100644 --- a/src/core/watchQueryOptions.ts +++ b/src/core/watchQueryOptions.ts @@ -303,4 +303,15 @@ export interface MutationOptions< * behavior. */ fetchPolicy?: Extract; + + /** + * To avoid retaining sensitive information from mutation root field + * arguments, Apollo Client v3.4+ automatically clears any `ROOT_MUTATION` + * fields from the cache after each mutation finishes. If you need this + * information to remain in the cache, you can prevent the removal by passing + * `keepRootFields: true` to the mutation. `ROOT_MUTATION` result data are + * also passed to the mutation `update` function, so we recommend obtaining + * the results that way, rather than using this option, if possible. + */ + keepRootFields?: boolean; } diff --git a/src/react/hooks/__tests__/useMutation.test.tsx b/src/react/hooks/__tests__/useMutation.test.tsx index 01f51941862..30d76419833 100644 --- a/src/react/hooks/__tests__/useMutation.test.tsx +++ b/src/react/hooks/__tests__/useMutation.test.tsx @@ -3,7 +3,7 @@ import { DocumentNode, GraphQLError } from 'graphql'; import gql from 'graphql-tag'; import { render, cleanup, wait } from '@testing-library/react'; -import { ApolloClient, ApolloQueryResult, Cache, NetworkStatus, ObservableQuery, TypedDocumentNode } from '../../../core'; +import { ApolloClient, ApolloLink, ApolloQueryResult, Cache, NetworkStatus, Observable, ObservableQuery, TypedDocumentNode } from '../../../core'; import { InMemoryCache } from '../../../cache'; import { itAsync, MockedProvider, mockSingleLink } from '../../../testing'; import { ApolloProvider } from '../../context'; @@ -480,6 +480,293 @@ describe('useMutation Hook', () => { }); }); + describe('ROOT_MUTATION cache data', () => { + const startTime = Date.now(); + const link = new ApolloLink(operation => new Observable(observer => { + observer.next({ + data: { + __typename: "Mutation", + doSomething: { + __typename: "MutationPayload", + time: startTime, + }, + }, + }); + observer.complete(); + })); + + const mutation = gql` + mutation DoSomething { + doSomething { + time + } + } + `; + + itAsync('should be removed by default after the mutation', (resolve, reject) => { + let timeReadCount = 0; + let timeMergeCount = 0; + + const client = new ApolloClient({ + link, + cache: new InMemoryCache({ + typePolicies: { + MutationPayload: { + fields: { + time: { + read(ms: number = Date.now()) { + ++timeReadCount; + return new Date(ms); + }, + merge(existing, incoming: number) { + ++timeMergeCount; + expect(existing).toBeUndefined(); + return incoming; + }, + }, + }, + }, + }, + }), + }); + + let renderCount = 0; + function Component() { + // This test differs from the following test primarily by *not* passing + // keepRootFields: true in the useMutation options. + const [mutate, result] = useMutation(mutation); + + switch (++renderCount) { + case 1: { + expect(result.loading).toBe(false); + expect(result.called).toBe(false); + expect(result.data).toBeUndefined(); + + mutate({ + update(cache, { + data: { + doSomething: { + __typename, + time, + }, + }, + }) { + expect(__typename).toBe("MutationPayload"); + expect(time).toBeInstanceOf(Date); + expect(time.getTime()).toBe(startTime); + expect(timeReadCount).toBe(1); + expect(timeMergeCount).toBe(1); + // The contents of the ROOT_MUTATION object exist only briefly, + // for the duration of the mutation update, and are removed + // after the mutation write is finished. + expect(cache.extract()).toEqual({ + ROOT_MUTATION: { + __typename: "Mutation", + doSomething: { + __typename: "MutationPayload", + time: startTime, + }, + }, + }); + }, + }).then(({ + data: { + doSomething: { + __typename, + time, + }, + }, + }) => { + expect(__typename).toBe("MutationPayload"); + expect(time).toBeInstanceOf(Date); + expect(time.getTime()).toBe(startTime); + expect(timeReadCount).toBe(1); + expect(timeMergeCount).toBe(1); + // The contents of the ROOT_MUTATION object exist only briefly, + // for the duration of the mutation update, and are removed after + // the mutation write is finished. + expect(client.cache.extract()).toEqual({ + ROOT_MUTATION: { + __typename: "Mutation", + }, + }); + }).catch(reject); + + break; + } + case 2: { + expect(result.loading).toBe(true); + expect(result.called).toBe(true); + expect(result.data).toBeUndefined(); + break; + } + case 3: { + expect(result.loading).toBe(false); + expect(result.called).toBe(true); + const { + doSomething: { + __typename, + time, + }, + } = result.data; + expect(__typename).toBe("MutationPayload"); + expect(time).toBeInstanceOf(Date); + expect(time.getTime()).toBe(startTime); + break; + } + default: + console.log(result); + reject("too many renders"); + break; + } + + return null; + } + + render( + + + + ); + + return wait(() => { + expect(renderCount).toBe(3); + }).then(resolve, reject); + }); + + itAsync('can be preserved by passing keepRootFields: true', (resolve, reject) => { + let timeReadCount = 0; + let timeMergeCount = 0; + + const client = new ApolloClient({ + link, + cache: new InMemoryCache({ + typePolicies: { + MutationPayload: { + fields: { + time: { + read(ms: number = Date.now()) { + ++timeReadCount; + return new Date(ms); + }, + merge(existing, incoming: number) { + ++timeMergeCount; + expect(existing).toBeUndefined(); + return incoming; + }, + }, + }, + }, + }, + }), + }); + + let renderCount = 0; + function Component() { + const [mutate, result] = useMutation(mutation, { + // This test differs from the previous test primarily by passing + // keepRootFields:true in the useMutation options. + keepRootFields: true, + }); + + switch (++renderCount) { + case 1: { + expect(result.loading).toBe(false); + expect(result.called).toBe(false); + expect(result.data).toBeUndefined(); + + mutate({ + update(cache, { + data: { + doSomething: { + __typename, + time, + }, + }, + }) { + expect(__typename).toBe("MutationPayload"); + expect(time).toBeInstanceOf(Date); + expect(time.getTime()).toBe(startTime); + expect(timeReadCount).toBe(1); + expect(timeMergeCount).toBe(1); + expect(cache.extract()).toEqual({ + ROOT_MUTATION: { + __typename: "Mutation", + doSomething: { + __typename: "MutationPayload", + time: startTime, + }, + }, + }); + }, + }).then(({ + data: { + doSomething: { + __typename, + time, + }, + }, + }) => { + expect(__typename).toBe("MutationPayload"); + expect(time).toBeInstanceOf(Date); + expect(time.getTime()).toBe(startTime); + expect(timeReadCount).toBe(1); + expect(timeMergeCount).toBe(1); + + expect(client.cache.extract()).toEqual({ + ROOT_MUTATION: { + __typename: "Mutation", + doSomething: { + __typename: "MutationPayload", + time: startTime, + }, + }, + }); + }).catch(reject); + + break; + } + case 2: { + expect(result.loading).toBe(true); + expect(result.called).toBe(true); + expect(result.data).toBeUndefined(); + break; + } + case 3: { + expect(result.loading).toBe(false); + expect(result.called).toBe(true); + const { + doSomething: { + __typename, + time, + }, + } = result.data; + expect(__typename).toBe("MutationPayload"); + expect(time).toBeInstanceOf(Date); + expect(time.getTime()).toBe(startTime); + break; + } + default: + console.log(result); + reject("too many renders"); + break; + } + + return null; + } + + render( + + + + ); + + return wait(() => { + expect(renderCount).toBe(3); + }).then(resolve, reject); + }); + }); + describe('Update function', () => { itAsync('should be called with the provided variables', async (resolve, reject) => { From 4c6728e9e672103e2b9c61898974b7413e30563a Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Wed, 26 May 2021 12:30:09 -0400 Subject: [PATCH 202/380] Bump @apollo/client npm version to 3.4.0-rc.2. --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 78d668308a4..7d7439152c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-rc.1", + "version": "3.4.0-rc.2", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 1e1b22b86a6..cb17cf6f58f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-rc.1", + "version": "3.4.0-rc.2", "description": "A fully-featured caching GraphQL client.", "private": true, "keywords": [ From 01573d393caf743bcf79535a5a9e3c5a85cf61e6 Mon Sep 17 00:00:00 2001 From: Hugh Willson Date: Fri, 28 May 2021 14:47:57 -0400 Subject: [PATCH 203/380] Re-export the MockedResponse ResultFunction type (#8315) This addresses a regression that happened when AC 3 was launched. Fixes #6540 --- CHANGELOG.md | 2 ++ src/utilities/testing/index.ts | 7 ++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a4a12db828..250c686ddbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,8 @@ }) ``` [@benjamn](https://github.com/benjamn) in [#7810](https://github.com/apollographql/apollo-client/pull/7810) +- Make sure the `MockedResponse` `ResultFunction` type is re-exported.
+ [@hwillson](https://github.com/hwillson) in [#8315](https://github.com/apollographql/apollo-client/pull/8315) ### Potentially breaking changes diff --git a/src/utilities/testing/index.ts b/src/utilities/testing/index.ts index db8575a1e08..45c2f3198f1 100644 --- a/src/utilities/testing/index.ts +++ b/src/utilities/testing/index.ts @@ -1,5 +1,10 @@ export { MockedProvider, MockedProviderProps } from './mocking/MockedProvider'; -export { MockLink, mockSingleLink, MockedResponse } from './mocking/mockLink'; +export { + MockLink, + mockSingleLink, + MockedResponse, + ResultFunction +} from './mocking/mockLink'; export { MockSubscriptionLink, mockObservableLink From 5d5c661890af253ff89cfce56fb846b9124ba2dd Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Fri, 28 May 2021 18:57:12 -0400 Subject: [PATCH 204/380] Transform invariant.log(...) expressions (not just .warn and .error). --- config/processInvariants.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/processInvariants.ts b/config/processInvariants.ts index 27ee932b079..c719d5b71bf 100644 --- a/config/processInvariants.ts +++ b/config/processInvariants.ts @@ -90,7 +90,7 @@ function transform(code: string, file: string) { if (node.callee.type === "MemberExpression" && isIdWithName(node.callee.object, "invariant") && - isIdWithName(node.callee.property, "warn", "error")) { + isIdWithName(node.callee.property, "log", "warn", "error")) { if (isNodeEnvLogicalOr(path.parent.node)) { return; } From f76ec87b83e51c2a3375ab0f4412b65ff37da599 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 1 Jun 2021 14:57:17 -0400 Subject: [PATCH 205/380] Consolidate identical isObject functions across codebase. --- src/__tests__/__snapshots__/exports.ts.snap | 1 + src/cache/inmemory/entityStore.ts | 6 +++--- src/cache/inmemory/helpers.ts | 6 +++--- src/cache/inmemory/object-canon.ts | 9 ++++----- src/cache/inmemory/policies.ts | 5 +++-- src/cache/inmemory/readFromStore.ts | 3 ++- src/core/QueryManager.ts | 3 ++- src/utilities/common/maybeDeepFreeze.ts | 9 +++------ src/utilities/common/mergeDeep.ts | 12 +++++------- src/utilities/common/objects.ts | 3 +++ src/utilities/graphql/storeUtils.ts | 6 +++--- src/utilities/index.ts | 1 + 12 files changed, 33 insertions(+), 31 deletions(-) create mode 100644 src/utilities/common/objects.ts diff --git a/src/__tests__/__snapshots__/exports.ts.snap b/src/__tests__/__snapshots__/exports.ts.snap index 3032b34917e..f5acde2fc1e 100644 --- a/src/__tests__/__snapshots__/exports.ts.snap +++ b/src/__tests__/__snapshots__/exports.ts.snap @@ -351,6 +351,7 @@ Array [ "isField", "isInlineFragment", "isNonEmptyArray", + "isNonNullObject", "isReference", "iterateObserversSafely", "makeReference", diff --git a/src/cache/inmemory/entityStore.ts b/src/cache/inmemory/entityStore.ts index 82afc70a73d..5bc80d34a71 100644 --- a/src/cache/inmemory/entityStore.ts +++ b/src/cache/inmemory/entityStore.ts @@ -12,6 +12,7 @@ import { DeepMerger, maybeDeepFreeze, canUseWeakMap, + isNonNullObject, } from '../../utilities'; import { NormalizedCache, NormalizedCacheObject } from './types'; import { hasOwn, fieldNameFromStoreName } from './helpers'; @@ -419,15 +420,14 @@ export abstract class EntityStore implements NormalizedCache { const workSet = new Set([this.data[dataId]]); // Within the store, only arrays and objects can contain child entity // references, so we can prune the traversal using this predicate: - const canTraverse = (obj: any) => obj !== null && typeof obj === 'object'; workSet.forEach(obj => { if (isReference(obj)) { found[obj.__ref] = true; - } else if (canTraverse(obj)) { + } else if (isNonNullObject(obj)) { Object.values(obj!) // No need to add primitive values to the workSet, since they cannot // contain reference objects. - .filter(canTraverse) + .filter(isNonNullObject) .forEach(workSet.add, workSet); } }); diff --git a/src/cache/inmemory/helpers.ts b/src/cache/inmemory/helpers.ts index b4627cab30b..f125ef671a0 100644 --- a/src/cache/inmemory/helpers.ts +++ b/src/cache/inmemory/helpers.ts @@ -10,6 +10,7 @@ import { DeepMerger, resultKeyNameFromField, shouldInclude, + isNonNullObject, } from '../../utilities'; export const { @@ -37,7 +38,7 @@ export function selectionSetMatchesResult( result: Record, variables?: Record, ): boolean { - if (result && typeof result === "object") { + if (isNonNullObject(result)) { return Array.isArray(result) ? result.every(item => selectionSetMatchesResult(selectionSet, item, variables)) : selectionSet.selections.every(field => { @@ -61,8 +62,7 @@ export function selectionSetMatchesResult( export function storeValueIsStoreObject( value: StoreValue, ): value is StoreObject { - return value !== null && - typeof value === "object" && + return isNonNullObject(value) && !isReference(value) && !Array.isArray(value); } diff --git a/src/cache/inmemory/object-canon.ts b/src/cache/inmemory/object-canon.ts index 7c76971d220..e436d74d7dd 100644 --- a/src/cache/inmemory/object-canon.ts +++ b/src/cache/inmemory/object-canon.ts @@ -1,9 +1,8 @@ import { Trie } from "@wry/trie"; -import { canUseWeakMap } from "../../utilities"; - -function isObjectOrArray(value: any): value is object { - return !!value && typeof value === "object"; -} +import { + canUseWeakMap, + isNonNullObject as isObjectOrArray, +} from "../../utilities"; function shallowCopy(value: T): T { if (isObjectOrArray(value)) { diff --git a/src/cache/inmemory/policies.ts b/src/cache/inmemory/policies.ts index 749b8af893f..76a24c6719c 100644 --- a/src/cache/inmemory/policies.ts +++ b/src/cache/inmemory/policies.ts @@ -22,6 +22,7 @@ import { getStoreKeyName, canUseWeakMap, compact, + isNonNullObject, } from '../../utilities'; import { IdGetter, @@ -911,8 +912,8 @@ function makeMergeObjectsFunction( // custom merge function can easily have the any type, so the type // system cannot always enforce the StoreObject | Reference parameter // types of options.mergeObjects. - if (existing && typeof existing === "object" && - incoming && typeof incoming === "object") { + if (isNonNullObject(existing) && + isNonNullObject(incoming)) { const eType = store.getFieldValue(existing, "__typename"); const iType = store.getFieldValue(incoming, "__typename"); const typesDiffer = eType && iType && eType !== iType; diff --git a/src/cache/inmemory/readFromStore.ts b/src/cache/inmemory/readFromStore.ts index b0ddc9fcad2..dbce333b190 100644 --- a/src/cache/inmemory/readFromStore.ts +++ b/src/cache/inmemory/readFromStore.ts @@ -24,6 +24,7 @@ import { mergeDeepArray, getFragmentFromSelection, maybeDeepFreeze, + isNonNullObject, } from '../../utilities'; import { Cache } from '../core/types/Cache'; import { @@ -500,7 +501,7 @@ function assertSelectionSetForIdValue( if (!field.selectionSet) { const workSet = new Set([fieldValue]); workSet.forEach(value => { - if (value && typeof value === "object") { + if (isNonNullObject(value)) { invariant( !isReference(value), `Missing selection set for object of type ${ diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index 1ffba7f1030..cb08575deb5 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -21,6 +21,7 @@ import { ConcastSourcesIterable, makeUniqueId, isDocumentNode, + isNonNullObject, } from '../utilities'; import { ApolloError, isApolloError } from '../errors'; import { @@ -1223,7 +1224,7 @@ export class QueryManager { if (typeof desc === "string" || isDocumentNode(desc)) { fallback = () => oq!.refetch(); - } else if (desc && typeof desc === "object") { + } else if (isNonNullObject(desc)) { const options = { ...desc, fetchPolicy: "network-only", diff --git a/src/utilities/common/maybeDeepFreeze.ts b/src/utilities/common/maybeDeepFreeze.ts index d35923589a1..f8e9d483d1b 100644 --- a/src/utilities/common/maybeDeepFreeze.ts +++ b/src/utilities/common/maybeDeepFreeze.ts @@ -1,16 +1,13 @@ import { isDevelopment, isTest } from './environment'; - -function isObject(value: any) { - return value !== null && typeof value === "object"; -} +import { isNonNullObject } from './objects'; function deepFreeze(value: any) { const workSet = new Set([value]); workSet.forEach(obj => { - if (isObject(obj)) { + if (isNonNullObject(obj)) { if (!Object.isFrozen(obj)) Object.freeze(obj); Object.getOwnPropertyNames(obj).forEach(name => { - if (isObject(obj[name])) workSet.add(obj[name]); + if (isNonNullObject(obj[name])) workSet.add(obj[name]); }); } }); diff --git a/src/utilities/common/mergeDeep.ts b/src/utilities/common/mergeDeep.ts index c9074522ce5..4d1a6613362 100644 --- a/src/utilities/common/mergeDeep.ts +++ b/src/utilities/common/mergeDeep.ts @@ -1,3 +1,5 @@ +import { isNonNullObject } from "./objects"; + const { hasOwnProperty } = Object.prototype; // These mergeDeep and mergeDeepArray utilities merge any number of objects @@ -46,10 +48,6 @@ export function mergeDeepArray(sources: T[]): T { return target; } -function isObject(obj: any): obj is Record { - return obj !== null && typeof obj === 'object'; -} - export type ReconcilerFunction = ( this: DeepMerger, target: Record, @@ -69,7 +67,7 @@ export class DeepMerger { ) {} public merge(target: any, source: any, ...context: TContextArgs): any { - if (isObject(source) && isObject(target)) { + if (isNonNullObject(source) && isNonNullObject(target)) { Object.keys(source).forEach(sourceKey => { if (hasOwnProperty.call(target, sourceKey)) { const targetValue = target[sourceKey]; @@ -97,12 +95,12 @@ export class DeepMerger { return source; } - public isObject = isObject; + public isObject = isNonNullObject; private pastCopies = new Set(); public shallowCopyForMerge(value: T): T { - if (isObject(value) && !this.pastCopies.has(value)) { + if (isNonNullObject(value) && !this.pastCopies.has(value)) { if (Array.isArray(value)) { value = (value as any).slice(0); } else { diff --git a/src/utilities/common/objects.ts b/src/utilities/common/objects.ts new file mode 100644 index 00000000000..51aa2bd3584 --- /dev/null +++ b/src/utilities/common/objects.ts @@ -0,0 +1,3 @@ +export function isNonNullObject(obj: any): obj is Record { + return obj !== null && typeof obj === 'object'; +} diff --git a/src/utilities/graphql/storeUtils.ts b/src/utilities/graphql/storeUtils.ts index a0aa5e8d2d1..8a7f4f45577 100644 --- a/src/utilities/graphql/storeUtils.ts +++ b/src/utilities/graphql/storeUtils.ts @@ -19,6 +19,7 @@ import { } from 'graphql'; import { InvariantError } from 'ts-invariant'; +import { isNonNullObject } from '../common/objects'; import { FragmentMap, getFragmentFromSelection } from './fragments'; export interface Reference { @@ -51,8 +52,7 @@ export interface StoreObject { export function isDocumentNode(value: any): value is DocumentNode { return ( - value !== null && - typeof value === "object" && + isNonNullObject(value) && (value as DocumentNode).kind === "Document" && Array.isArray((value as DocumentNode).definitions) ); @@ -256,7 +256,7 @@ let stringify = function defaultStringify(value: any): string { }; function stringifyReplacer(_key: string, value: any): any { - if (value && typeof value === "object" && !Array.isArray(value)) { + if (isNonNullObject(value) && !Array.isArray(value)) { value = Object.keys(value).sort().reduce((copy, key) => { copy[key] = value[key]; return copy; diff --git a/src/utilities/index.ts b/src/utilities/index.ts index c878b83622b..b5d8b3a51db 100644 --- a/src/utilities/index.ts +++ b/src/utilities/index.ts @@ -84,6 +84,7 @@ export * from './observables/asyncMap'; export * from './observables/Concast'; export * from './observables/subclassing'; export * from './common/arrays'; +export * from './common/objects'; export * from './common/errorHandling'; export * from './common/canUse'; export * from './common/compact'; From 2ae6ddd68b3d7bb61f878060b6127903a6764e9c Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Fri, 28 May 2021 15:46:37 -0400 Subject: [PATCH 206/380] Convert a few default imports to named. This should prevent Rollup from generating unnecessary code like var invariant__default = /*#__PURE__*/_interopDefaultLegacy(invariant); --- src/cache/inmemory/entityStore.ts | 2 +- src/core/index.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/cache/inmemory/entityStore.ts b/src/cache/inmemory/entityStore.ts index 5bc80d34a71..64cad62ccc8 100644 --- a/src/cache/inmemory/entityStore.ts +++ b/src/cache/inmemory/entityStore.ts @@ -1,5 +1,5 @@ import { dep, OptimisticDependencyFunction } from 'optimism'; -import invariant from 'ts-invariant'; +import { invariant } from 'ts-invariant'; import { equal } from '@wry/equality'; import { Trie } from '@wry/trie'; diff --git a/src/core/index.ts b/src/core/index.ts index a8ff03ed7b1..c5c14217944 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -103,7 +103,7 @@ setVerbosity("log"); // then re-exporting them separately, helps keeps bundlers happy without any // additional config changes. export { - default as gql, + gql, resetCaches, disableFragmentWarnings, enableExperimentalFragmentVariables, From e553ed94f22b5e47078e1d1d61f2b9284eb49303 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Thu, 27 May 2021 14:31:46 -0400 Subject: [PATCH 207/380] Update rollup to latest version, 2.50.5. It appears our closing of this old @renovate PR to update Rollup to v2 caused @renovate to stop opening PRs to update Rollup to any v2.x.y version, so our version of Rollup has been out of date since then: https://github.com/apollographql/apollo-client/pull/6033 Though obviously not ideal, this major version lag hasn't been a problem for us because Rollup v1 still works very well, and we use it only to generate CommonJS bundles, which is a relatively stable build target (CommonJS hasn't changed much lately). We rely on other tools like tsc for critical stuff like TypeScript compilation, and we have numerous ways to verify the output of our build system. With that said, Rollup v2 has some great new features that I'm looking forward to trying, such as `output.inlineDynamicImports`. --- package-lock.json | 27 ++++++++++----------------- package.json | 2 +- 2 files changed, 11 insertions(+), 18 deletions(-) diff --git a/package-lock.json b/package-lock.json index 67cd7b26b73..4ac4cb2720b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1672,12 +1672,6 @@ "integrity": "sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==", "dev": true }, - "@types/estree": { - "version": "0.0.42", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.42.tgz", - "integrity": "sha512-K1DPVvnBCPxzD+G51/cxVIoc2X8uUVl1zpJeE6iKcgHMj4+tbat5Xu4TjV7v2QSDbIeAfLi2hIk+u2+s0MlpUQ==", - "dev": true - }, "@types/fast-json-stable-stringify": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@types/fast-json-stable-stringify/-/fast-json-stable-stringify-2.0.0.tgz", @@ -8173,21 +8167,20 @@ } }, "rollup": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-1.31.1.tgz", - "integrity": "sha512-2JREN1YdrS/kpPzEd33ZjtuNbOuBC3ePfuZBdKEybvqcEcszW1ckyVqzcEiEe0nE8sqHK+pbJg+PsAgRJ8+1dg==", + "version": "2.50.5", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.50.5.tgz", + "integrity": "sha512-Ztz4NurU2LbS3Jn5rlhnYv35z6pkjBUmYKr94fOBIKINKRO6kug9NTFHArT7jqwMP2kqEZ39jJuEtkk91NBltQ==", "dev": true, "requires": { - "@types/estree": "*", - "@types/node": "*", - "acorn": "^7.1.0" + "fsevents": "~2.3.1" }, "dependencies": { - "acorn": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.1.1.tgz", - "integrity": "sha512-add7dgA5ppRPxCFJoAGfMDi7PIBXq1RtGo7BhbLaxwrXPOmw8gq48Y9ozT01hUKy9byMjlR20EJhu5zlkErEkg==", - "dev": true + "fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "optional": true } } }, diff --git a/package.json b/package.json index b93cc8c8eac..6fec5cda272 100644 --- a/package.json +++ b/package.json @@ -116,7 +116,7 @@ "recompose": "0.30.0", "resolve": "1.20.0", "rimraf": "3.0.2", - "rollup": "1.31.1", + "rollup": "2.50.5", "rollup-plugin-terser": "7.0.2", "rxjs": "6.6.7", "subscriptions-transport-ws": "0.9.18", From d056d478e51a66d6ec6544414d43e0f3bb6bb243 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Thu, 27 May 2021 16:04:29 -0400 Subject: [PATCH 208/380] Fix bug preventing Rollup from inlining relative CJS modules. Although the upgrade from rollup@1.31.1 to rollup@2.x has been almost entirely seamless (yay!), rollup@2.26.8 included a change (https://github.com/rollup/rollup/pull/3753) that made it possible for the options.external function to receive fully resolved, absolute ID strings, so our implementation of that function needed to be updated to accommodate that possibility. --- config/rollup.config.js | 36 +++++++++++++++++++++++++++++++----- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/config/rollup.config.js b/config/rollup.config.js index 8eccbcf95f0..75af0517328 100644 --- a/config/rollup.config.js +++ b/config/rollup.config.js @@ -7,15 +7,41 @@ const entryPoints = require('./entryPoints'); const distDir = './dist'; -function isExternal(id) { - return !(id.startsWith("./") || id.startsWith("../")); +function isExternal(id, parentId, entryPointsAreExternal = true) { + // Rollup v2.26.8 started passing absolute id strings to this function, thanks + // apparently to https://github.com/rollup/rollup/pull/3753, so we relativize + // the id again in those cases. + if (path.posix.isAbsolute(id)) { + id = path.posix.relative( + path.posix.dirname(parentId), + id, + ); + if (!id.startsWith(".")) { + id = "./" + id; + } + } + + const isRelative = + id.startsWith("./") || + id.startsWith("../"); + + if (!isRelative) { + return true; + } + + if (entryPointsAreExternal && + entryPoints.check(id, parentId)) { + return true; + } + + return false; } function prepareCJS(input, output) { return { input, - external(id) { - return isExternal(id); + external(id, parentId) { + return isExternal(id, parentId, false); }, output: { file: output, @@ -62,7 +88,7 @@ function prepareBundle({ return { input: `${dir}/index.js`, external(id, parentId) { - return isExternal(id) || entryPoints.check(id, parentId); + return isExternal(id, parentId, true); }, output: { file: `${dir}/${bundleName}.cjs.js`, From 1bb182c40c320ec89be3667c8f81965328c07aeb Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Fri, 28 May 2021 16:17:47 -0400 Subject: [PATCH 209/380] Make Rollup inject fewer _interopNamespace helpers. This affects only `react`, `prop-types`, and `hoist-non-react-statics` packages, which all export an object that can be considered a module namespace object, so this transform is not needed: function _interopNamespace(e) { if (e && e.__esModule) return e; var n = Object.create(null); if (e) { Object.keys(e).forEach(function (k) { n[k] = e[k]; }); } n['default'] = e; return Object.freeze(n); } var React__namespace = /*#__PURE__*/_interopNamespace(React); --- config/rollup.config.js | 1 + 1 file changed, 1 insertion(+) diff --git a/config/rollup.config.js b/config/rollup.config.js index 75af0517328..abe73fabcd2 100644 --- a/config/rollup.config.js +++ b/config/rollup.config.js @@ -95,6 +95,7 @@ function prepareBundle({ format: 'cjs', sourcemap: true, exports: 'named', + interop: 'esModule', externalLiveBindings: false, // In Node.js, where these CommonJS bundles are most commonly used, // the expression process.env.NODE_ENV can be very expensive to From 14ffe47922d66def7d2dbb6d7745f5763d4068fb Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Wed, 2 Jun 2021 12:38:42 -0400 Subject: [PATCH 210/380] Bump @apollo/client npm version to 3.4.0-rc.3. --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index c0712aaa0bc..ce7569dd9e3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-rc.2", + "version": "3.4.0-rc.3", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index e28dea78bb6..e8eea8645bf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-rc.2", + "version": "3.4.0-rc.3", "description": "A fully-featured caching GraphQL client.", "private": true, "keywords": [ From b8ee3a6b3d0e63aafffa5e440f1ed70e0f5f4352 Mon Sep 17 00:00:00 2001 From: Daniel Date: Tue, 9 Mar 2021 12:06:48 -0800 Subject: [PATCH 211/380] add public method to retrieve all current observable queries --- src/__tests__/client.ts | 12 ++++++++++++ src/core/ApolloClient.ts | 8 ++++++++ src/core/QueryManager.ts | 32 ++++++++++++++++++++------------ 3 files changed, 40 insertions(+), 12 deletions(-) diff --git a/src/__tests__/client.ts b/src/__tests__/client.ts index 446693a53d9..9e0b4e40f9b 100644 --- a/src/__tests__/client.ts +++ b/src/__tests__/client.ts @@ -2442,6 +2442,18 @@ describe('client', () => { expect(spy).toHaveBeenCalledWith(options); }); + it('has a getObservableQueries method which calls QueryManager', async () => { + const client = new ApolloClient({ + link: ApolloLink.empty(), + cache: new InMemoryCache(), + }); + + // @ts-ignore + const spy = jest.spyOn(client.queryManager, 'getObservableQueries'); + await client.getObservableQueries(); + expect(spy).toHaveBeenCalled(); + }); + itAsync('should propagate errors from network interface to observers', (resolve, reject) => { const link = ApolloLink.from([ () => diff --git a/src/core/ApolloClient.ts b/src/core/ApolloClient.ts index 53485777b07..2d902549e43 100644 --- a/src/core/ApolloClient.ts +++ b/src/core/ApolloClient.ts @@ -603,6 +603,14 @@ export class ApolloClient implements DataProxy { return this.localState.getResolvers(); } + /** + * Get all observable queries which have current observers (e.g. + * they're mounted). + */ + public getObservableQueries() { + return this.queryManager.getObservableQueries(); + } + /** * Set a custom local state fragment matcher. */ diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index cb08575deb5..9f273256e40 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -734,25 +734,33 @@ export class QueryManager { }); } + public getObservableQueries() { + const queries = new Map>(); + this.queries.forEach(({ observableQuery }, queryId) => { + if (observableQuery && observableQuery.hasObservers()) { + queries.set(queryId, observableQuery); + } + }); + return queries; + } + public reFetchObservableQueries( includeStandby: boolean = false, ): Promise[]> { const observableQueryPromises: Promise>[] = []; - this.queries.forEach(({ observableQuery }, queryId) => { - if (observableQuery && observableQuery.hasObservers()) { - const fetchPolicy = observableQuery.options.fetchPolicy; - - observableQuery.resetLastResults(); - if ( - fetchPolicy !== 'cache-only' && - (includeStandby || fetchPolicy !== 'standby') - ) { - observableQueryPromises.push(observableQuery.refetch()); - } + this.getObservableQueries().forEach((observableQuery, queryId) => { + const fetchPolicy = observableQuery.options.fetchPolicy; - this.getQuery(queryId).setDiff(null); + observableQuery.resetLastResults(); + if ( + fetchPolicy !== 'cache-only' && + (includeStandby || fetchPolicy !== 'standby') + ) { + observableQueryPromises.push(observableQuery.refetch()); } + + this.getQuery(queryId).setDiff(null); }); this.broadcastQueries(); From e673f8b78e30bb2fd78c3bc66fb2ee8a487ac5b9 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Wed, 2 Jun 2021 16:28:29 -0400 Subject: [PATCH 212/380] Use getObservableQueries for refetchQueries include handling. --- src/core/ApolloClient.ts | 24 +++-- src/core/QueryManager.ts | 191 +++++++++++++++++++-------------------- src/core/types.ts | 2 +- 3 files changed, 112 insertions(+), 105 deletions(-) diff --git a/src/core/ApolloClient.ts b/src/core/ApolloClient.ts index 2d902549e43..58297858ca0 100644 --- a/src/core/ApolloClient.ts +++ b/src/core/ApolloClient.ts @@ -18,6 +18,7 @@ import { RefetchQueriesOptions, RefetchQueriesResult, InternalRefetchQueriesResult, + RefetchQueryDescription, } from './types'; import { @@ -564,6 +565,21 @@ export class ApolloClient implements DataProxy { return result; } + /** + * Get all currently active ObservableQuery objects, in a Map keyed by query + * ID strings. An "active" query is one that has observers and a fetchPolicy + * other than "standby" or "cache-only". You can include all ObservableQuery + * objects (including the inactive ones) by passing "all" instead of "active", + * or you can include just a subset of active queries by passing an array of + * query names or DocumentNode objects. This method is used internally by + * `client.refetchQueries` to handle its `include` option. + */ + public getObservableQueries( + include: RefetchQueryDescription = "active", + ) { + return this.queryManager.getObservableQueries(include); + } + /** * Exposes the cache's complete state, in a serializable format for later restoration. */ @@ -603,14 +619,6 @@ export class ApolloClient implements DataProxy { return this.localState.getResolvers(); } - /** - * Get all observable queries which have current observers (e.g. - * they're mounted). - */ - public getObservableQueries() { - return this.queryManager.getObservableQueries(); - } - /** * Set a custom local state fragment matcher. */ diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index 9f273256e40..9933501f43a 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -41,7 +41,6 @@ import { OnQueryUpdated, RefetchQueryDescription, InternalRefetchQueriesOptions, - RefetchQueryDescriptor, InternalRefetchQueriesResult, InternalRefetchQueriesMap, } from './types'; @@ -734,13 +733,73 @@ export class QueryManager { }); } - public getObservableQueries() { + public getObservableQueries( + include: RefetchQueryDescription = "active", + ) { const queries = new Map>(); - this.queries.forEach(({ observableQuery }, queryId) => { - if (observableQuery && observableQuery.hasObservers()) { - queries.set(queryId, observableQuery); + const queryNames = new Set(); + const queryDocs = new Set(); + const legacyQueryOptions = new Set(); + + if (Array.isArray(include)) { + include.forEach(desc => { + if (typeof desc === "string") { + queryNames.add(desc); + } else if (isDocumentNode(desc)) { + queryDocs.add(desc); + } else if (isNonNullObject(desc) && desc.query) { + legacyQueryOptions.add(desc); + } + }); + } + + this.queries.forEach(({ observableQuery: oq, document }, queryId) => { + if (oq) { + if (include === "all") { + queries.set(queryId, oq); + return; + } + + const { fetchPolicy } = oq.options; + if (fetchPolicy === "cache-only" || + fetchPolicy === "standby" || + !oq.hasObservers()) { + // Skip inactive queries unless include === "all". + return; + } + + if ( + include === "active" || + queryNames.has(oq.queryName!) || + (document && queryDocs.has(document)) + ) { + queries.set(queryId, oq); + } } }); + + if (legacyQueryOptions.size) { + legacyQueryOptions.forEach((options: QueryOptions) => { + // We will be issuing a fresh network request for this query, so we + // pre-allocate a new query ID here. + const queryId = this.generateQueryId(); + const queryInfo = this.getQuery(queryId).init({ + document: options.query, + variables: options.variables, + }); + const oq = new ObservableQuery({ + queryManager: this, + queryInfo, + options: { + ...options, + fetchPolicy: "network-only", + }, + }); + queryInfo.setObservableQuery(oq); + queries.set(queryId, oq); + }); + } + return queries; } @@ -749,17 +808,11 @@ export class QueryManager { ): Promise[]> { const observableQueryPromises: Promise>[] = []; - this.getObservableQueries().forEach((observableQuery, queryId) => { - const fetchPolicy = observableQuery.options.fetchPolicy; - + this.getObservableQueries( + includeStandby ? "all" : "active" + ).forEach((observableQuery, queryId) => { observableQuery.resetLastResults(); - if ( - fetchPolicy !== 'cache-only' && - (includeStandby || fetchPolicy !== 'standby') - ) { - observableQueryPromises.push(observableQuery.refetch()); - } - + observableQueryPromises.push(observableQuery.refetch()); this.getQuery(queryId).setDiff(null); }); @@ -1112,21 +1165,17 @@ export class QueryManager { onQueryUpdated, }: InternalRefetchQueriesOptions, TResult> ): InternalRefetchQueriesMap { - const includedQueriesById = new Map; lastDiff?: Cache.DiffResult; diff?: Cache.DiffResult; }>(); if (include) { - include.forEach(desc => { - getQueryIdsForQueryDescriptor(this, desc).forEach(queryId => { - includedQueriesById.set(queryId, { - desc, - lastDiff: typeof desc === "string" || isDocumentNode(desc) - ? this.getQuery(queryId).getDiff() - : void 0, - }); + this.getObservableQueries(include).forEach((oq, queryId) => { + includedQueries.set(queryId, { + oq, + lastDiff: this.getQuery(queryId).getDiff(), }); }); } @@ -1187,7 +1236,7 @@ export class QueryManager { // Since we're about to handle this query now, remove it from // includedQueriesById, in case it was added earlier because of // options.include. - includedQueriesById.delete(oq.queryId); + includedQueries.delete(oq.queryId); let result: boolean | InternalRefetchQueriesResult = onQueryUpdated(oq, diff, lastDiff); @@ -1213,58 +1262,35 @@ export class QueryManager { // If we don't have an onQueryUpdated function, and onQueryUpdated // was not disabled by passing null, make sure this query is // "included" like any other options.include-specified query. - includedQueriesById.set(oq.queryId, { - desc: oq.queryName || ``, - lastDiff, - diff, - }); + includedQueries.set(oq.queryId, { oq, lastDiff, diff }); } } }, }); } - if (includedQueriesById.size) { - includedQueriesById.forEach(({ desc, lastDiff, diff }, queryId) => { - const queryInfo = this.getQuery(queryId); - let oq = queryInfo.observableQuery; - let fallback: undefined | (() => Promise>); - - if (typeof desc === "string" || isDocumentNode(desc)) { - fallback = () => oq!.refetch(); - } else if (isNonNullObject(desc)) { - const options = { - ...desc, - fetchPolicy: "network-only", - } as QueryOptions; - - queryInfo.setObservableQuery(oq = new ObservableQuery({ - queryManager: this, - queryInfo, - options, - })); + if (includedQueries.size) { + includedQueries.forEach(({ oq, lastDiff, diff }, queryId) => { + let result: undefined | boolean | InternalRefetchQueriesResult; + + // If onQueryUpdated is provided, we want to use it for all included + // queries, even the PureQueryOptions ones. + if (onQueryUpdated) { + if (!diff) { + const info = oq["queryInfo"]; + info.reset(); // Force info.getDiff() to read from cache. + diff = info.getDiff(); + } + result = onQueryUpdated(oq, diff, lastDiff); + } - fallback = () => this.query(options, queryId); + // Otherwise, we fall back to refetching. + if (!onQueryUpdated || result === true) { + result = oq.refetch(); } - if (oq && fallback) { - let result: undefined | boolean | InternalRefetchQueriesResult; - // If onQueryUpdated is provided, we want to use it for all included - // queries, even the PureQueryOptions ones. Otherwise, we call the - // fallback function defined above. - if (onQueryUpdated) { - if (!diff) { - queryInfo.reset(); // Force queryInfo.getDiff() to read from cache. - diff = queryInfo.getDiff(); - } - result = onQueryUpdated(oq, diff, lastDiff); - } - if (!onQueryUpdated || result === true) { - result = fallback(); - } - if (result !== false) { - results.set(oq, result!); - } + if (result !== false) { + results.set(oq, result!); } }); } @@ -1452,30 +1478,3 @@ export class QueryManager { }; } } - -function getQueryIdsForQueryDescriptor( - qm: QueryManager, - desc: RefetchQueryDescriptor, -) { - const queryIds: string[] = []; - const isName = typeof desc === "string"; - if (isName || isDocumentNode(desc)) { - qm["queries"].forEach(({ observableQuery: oq, document }, queryId) => { - if (oq && - desc === (isName ? oq.queryName : document) && - oq.hasObservers()) { - queryIds.push(queryId); - } - }); - } else { - // We will be issuing a fresh network request for this query, so we - // pre-allocate a new query ID here. - queryIds.push(qm.generateQueryId()); - } - if (process.env.NODE_ENV !== "production" && !queryIds.length) { - invariant.warn(`Unknown query name ${ - JSON.stringify(desc) - } passed to refetchQueries method in options.include array`); - } - return queryIds; -} diff --git a/src/core/types.ts b/src/core/types.ts index a6907179a34..68fcf4d2f5f 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -23,7 +23,7 @@ export type OnQueryUpdated = ( ) => boolean | TResult; export type RefetchQueryDescriptor = string | DocumentNode | PureQueryOptions; -export type RefetchQueryDescription = RefetchQueryDescriptor[]; +export type RefetchQueryDescription = RefetchQueryDescriptor[] | "all" | "active"; // Used by ApolloClient["refetchQueries"] // TODO Improve documentation comments for this public type. From 5baa91807efcac312d139cba2659fabc15632757 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Wed, 2 Jun 2021 17:10:12 -0400 Subject: [PATCH 213/380] Reenable warning about included-but-unused query names/documents. --- src/core/QueryManager.ts | 33 +++++++++++++++++------- src/core/__tests__/QueryManager/index.ts | 6 ++--- 2 files changed, 26 insertions(+), 13 deletions(-) diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index 9933501f43a..07667dd5f7e 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -737,16 +737,13 @@ export class QueryManager { include: RefetchQueryDescription = "active", ) { const queries = new Map>(); - const queryNames = new Set(); - const queryDocs = new Set(); + const queryNamesAndDocs = new Map(); const legacyQueryOptions = new Set(); if (Array.isArray(include)) { include.forEach(desc => { - if (typeof desc === "string") { - queryNames.add(desc); - } else if (isDocumentNode(desc)) { - queryDocs.add(desc); + if (typeof desc === "string" || isDocumentNode(desc)) { + queryNamesAndDocs.set(desc, false); } else if (isNonNullObject(desc) && desc.query) { legacyQueryOptions.add(desc); } @@ -760,7 +757,11 @@ export class QueryManager { return; } - const { fetchPolicy } = oq.options; + const { + queryName, + options: { fetchPolicy }, + } = oq; + if (fetchPolicy === "cache-only" || fetchPolicy === "standby" || !oq.hasObservers()) { @@ -770,10 +771,12 @@ export class QueryManager { if ( include === "active" || - queryNames.has(oq.queryName!) || - (document && queryDocs.has(document)) + (queryName && queryNamesAndDocs.has(queryName)) || + (document && queryNamesAndDocs.has(document)) ) { queries.set(queryId, oq); + if (queryName) queryNamesAndDocs.set(queryName, true); + if (document) queryNamesAndDocs.set(document, true); } } }); @@ -800,6 +803,18 @@ export class QueryManager { }); } + if (process.env.NODE_ENV !== "production" && queryNamesAndDocs.size) { + queryNamesAndDocs.forEach((included, nameOrDoc) => { + if (!included) { + invariant.warn(`Unknown query ${ + typeof nameOrDoc === "string" ? "named " : "" + }${ + JSON.stringify(nameOrDoc, null, 2) + } requested in refetchQueries options.include array`); + } + }); + } + return queries; } diff --git a/src/core/__tests__/QueryManager/index.ts b/src/core/__tests__/QueryManager/index.ts index 56b7bc397c1..5af44167783 100644 --- a/src/core/__tests__/QueryManager/index.ts +++ b/src/core/__tests__/QueryManager/index.ts @@ -4774,8 +4774,7 @@ describe('QueryManager', () => { result => { expect(stripSymbols(result.data)).toEqual(secondReqData); expect(consoleWarnSpy).toHaveBeenLastCalledWith( - 'Unknown query name "fakeQuery" passed to refetchQueries method ' + - "in options.include array" + 'Unknown query named "fakeQuery" requested in refetchQueries options.include array' ); }, ).then(resolve, reject); @@ -4843,8 +4842,7 @@ describe('QueryManager', () => { }); }).then(() => { expect(consoleWarnSpy).toHaveBeenLastCalledWith( - 'Unknown query name "getAuthors" passed to refetchQueries method ' + - "in options.include array" + 'Unknown query named "getAuthors" requested in refetchQueries options.include array' ); }).then(resolve, reject); }); From 748c90c6f1c02b7946dbb3a09293d890d0fa9edb Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Wed, 2 Jun 2021 17:54:41 -0400 Subject: [PATCH 214/380] Clean up [Internal]RefetchQueriesInclude and related types. --- src/core/ApolloClient.ts | 19 ++++++++-------- src/core/QueryManager.ts | 20 ++++++++--------- src/core/types.ts | 41 +++++++++++++++++++++-------------- src/core/watchQueryOptions.ts | 6 ++--- src/react/types/types.ts | 4 ++-- 5 files changed, 49 insertions(+), 41 deletions(-) diff --git a/src/core/ApolloClient.ts b/src/core/ApolloClient.ts index 58297858ca0..9c63094c9bd 100644 --- a/src/core/ApolloClient.ts +++ b/src/core/ApolloClient.ts @@ -18,7 +18,7 @@ import { RefetchQueriesOptions, RefetchQueriesResult, InternalRefetchQueriesResult, - RefetchQueryDescription, + RefetchQueriesInclude, } from './types'; import { @@ -566,17 +566,16 @@ export class ApolloClient implements DataProxy { } /** - * Get all currently active ObservableQuery objects, in a Map keyed by query - * ID strings. An "active" query is one that has observers and a fetchPolicy - * other than "standby" or "cache-only". You can include all ObservableQuery - * objects (including the inactive ones) by passing "all" instead of "active", - * or you can include just a subset of active queries by passing an array of - * query names or DocumentNode objects. This method is used internally by - * `client.refetchQueries` to handle its `include` option. + * Get all currently active `ObservableQuery` objects, in a `Map` keyed by + * query ID strings. An "active" query is one that has observers and a + * `fetchPolicy` other than "standby" or "cache-only". You can include all + * `ObservableQuery` objects (including the inactive ones) by passing "all" + * instead of "active", or you can include just a subset of active queries by + * passing an array of query names or DocumentNode objects. */ public getObservableQueries( - include: RefetchQueryDescription = "active", - ) { + include: RefetchQueriesInclude = "active", + ): Map> { return this.queryManager.getObservableQueries(include); } diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index 07667dd5f7e..b6001129fbf 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -39,7 +39,7 @@ import { OperationVariables, MutationUpdaterFunction, OnQueryUpdated, - RefetchQueryDescription, + InternalRefetchQueriesInclude, InternalRefetchQueriesOptions, InternalRefetchQueriesResult, InternalRefetchQueriesMap, @@ -327,7 +327,7 @@ export class QueryManager { updateQueries: UpdateQueries; update?: MutationUpdaterFunction; awaitRefetchQueries?: boolean; - refetchQueries?: RefetchQueryDescription; + refetchQueries?: InternalRefetchQueriesInclude; removeOptimistic?: string; onQueryUpdated?: OnQueryUpdated; keepRootFields?: boolean; @@ -734,7 +734,7 @@ export class QueryManager { } public getObservableQueries( - include: RefetchQueryDescription = "active", + include: InternalRefetchQueriesInclude = "active", ) { const queries = new Map>(); const queryNamesAndDocs = new Map(); @@ -1180,7 +1180,7 @@ export class QueryManager { onQueryUpdated, }: InternalRefetchQueriesOptions, TResult> ): InternalRefetchQueriesMap { - const includedQueries = new Map; lastDiff?: Cache.DiffResult; diff?: Cache.DiffResult; @@ -1188,7 +1188,7 @@ export class QueryManager { if (include) { this.getObservableQueries(include).forEach((oq, queryId) => { - includedQueries.set(queryId, { + includedQueriesById.set(queryId, { oq, lastDiff: this.getQuery(queryId).getDiff(), }); @@ -1251,7 +1251,7 @@ export class QueryManager { // Since we're about to handle this query now, remove it from // includedQueriesById, in case it was added earlier because of // options.include. - includedQueries.delete(oq.queryId); + includedQueriesById.delete(oq.queryId); let result: boolean | InternalRefetchQueriesResult = onQueryUpdated(oq, diff, lastDiff); @@ -1277,19 +1277,19 @@ export class QueryManager { // If we don't have an onQueryUpdated function, and onQueryUpdated // was not disabled by passing null, make sure this query is // "included" like any other options.include-specified query. - includedQueries.set(oq.queryId, { oq, lastDiff, diff }); + includedQueriesById.set(oq.queryId, { oq, lastDiff, diff }); } } }, }); } - if (includedQueries.size) { - includedQueries.forEach(({ oq, lastDiff, diff }, queryId) => { + if (includedQueriesById.size) { + includedQueriesById.forEach(({ oq, lastDiff, diff }, queryId) => { let result: undefined | boolean | InternalRefetchQueriesResult; // If onQueryUpdated is provided, we want to use it for all included - // queries, even the PureQueryOptions ones. + // queries, even the QueryOptions ones. if (onQueryUpdated) { if (!diff) { const info = oq["queryInfo"]; diff --git a/src/core/types.ts b/src/core/types.ts index 68fcf4d2f5f..83e0963cb2b 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -7,6 +7,7 @@ import { QueryInfo } from './QueryInfo'; import { NetworkStatus } from './networkStatus'; import { Resolver } from './LocalState'; import { ObservableQuery } from './ObservableQuery'; +import { QueryOptions } from './watchQueryOptions'; import { Cache } from '../cache'; import { IsStrictlyAny } from '../utilities'; @@ -22,8 +23,18 @@ export type OnQueryUpdated = ( lastDiff: Cache.DiffResult | undefined, ) => boolean | TResult; -export type RefetchQueryDescriptor = string | DocumentNode | PureQueryOptions; -export type RefetchQueryDescription = RefetchQueryDescriptor[] | "all" | "active"; +export type RefetchQueryDescriptor = string | DocumentNode; +export type InternalRefetchQueryDescriptor = RefetchQueryDescriptor | QueryOptions; + +type RefetchQueriesIncludeShorthand = "all" | "active"; + +export type RefetchQueriesInclude = + | RefetchQueryDescriptor[] + | RefetchQueriesIncludeShorthand; + +export type InternalRefetchQueriesInclude = + | InternalRefetchQueryDescriptor[] + | RefetchQueriesIncludeShorthand; // Used by ApolloClient["refetchQueries"] // TODO Improve documentation comments for this public type. @@ -32,11 +43,11 @@ export interface RefetchQueriesOptions< TResult, > { updateCache?: (cache: TCache) => void; - // Although you can pass PureQueryOptions objects in addition to strings in - // the refetchQueries array for a mutation, the client.refetchQueries method - // deliberately discourages passing PureQueryOptions, by restricting the - // public type of the options.include array to string[] (just query names). - include?: Exclude[]; + // The client.refetchQueries method discourages passing QueryOptions, by + // restricting the public type of options.include to exclude QueryOptions as + // an available array element type (see InternalRefetchQueriesInclude for a + // version of RefetchQueriesInclude that allows legacy QueryOptions objects). + include?: RefetchQueriesInclude; optimistic?: boolean; // If no onQueryUpdated function is provided, any queries affected by the // updateCache function or included in the options.include array will be @@ -93,9 +104,10 @@ export interface InternalRefetchQueriesOptions< TCache extends ApolloCache, TResult, > extends Omit, "include"> { - // Just like the refetchQueries array for a mutation, allowing both strings - // and PureQueryOptions objects. - include?: RefetchQueryDescription; + // Just like the refetchQueries option for a mutation, an array of strings, + // DocumentNode objects, and/or QueryOptions objects, or one of the shorthand + // strings "all" or "active", to select every (active) query. + include?: InternalRefetchQueriesInclude; // This part of the API is a (useful) implementation detail, but need not be // exposed in the public client.refetchQueries API (above). removeOptimistic?: string; @@ -108,13 +120,10 @@ export type InternalRefetchQueriesMap = Map, InternalRefetchQueriesResult>; -export type OperationVariables = Record; +// TODO Remove this unnecessary type in Apollo Client 4. +export type { QueryOptions as PureQueryOptions }; -export type PureQueryOptions = { - query: DocumentNode; - variables?: { [key: string]: any }; - context?: any; -}; +export type OperationVariables = Record; export type ApolloQueryResult = { data: T; diff --git a/src/core/watchQueryOptions.ts b/src/core/watchQueryOptions.ts index 69e77888279..d66d8850503 100644 --- a/src/core/watchQueryOptions.ts +++ b/src/core/watchQueryOptions.ts @@ -8,7 +8,7 @@ import { OperationVariables, MutationUpdaterFunction, OnQueryUpdated, - RefetchQueryDescription, + InternalRefetchQueriesInclude, } from './types'; import { ApolloCache } from '../cache'; @@ -221,8 +221,8 @@ export interface MutationBaseOptions< * once these queries return. */ refetchQueries?: - | ((result: FetchResult) => RefetchQueryDescription) - | RefetchQueryDescription; + | ((result: FetchResult) => InternalRefetchQueriesInclude) + | InternalRefetchQueriesInclude; /** * By default, `refetchQueries` does not wait for the refetched queries to diff --git a/src/react/types/types.ts b/src/react/types/types.ts index 12e2d7aa12a..cd2bae7271f 100644 --- a/src/react/types/types.ts +++ b/src/react/types/types.ts @@ -17,7 +17,7 @@ import { NetworkStatus, ObservableQuery, OperationVariables, - PureQueryOptions, + InternalRefetchQueriesInclude, WatchQueryOptions, } from '../../core'; @@ -137,7 +137,7 @@ export type QueryTuple = [ export type RefetchQueriesFunction = ( ...args: any[] -) => Array; +) => InternalRefetchQueriesInclude; export interface BaseMutationOptions< TData, From 4e022bcf1feea1df6bbf8c15f981b5f2531eca8a Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Wed, 2 Jun 2021 18:16:07 -0400 Subject: [PATCH 215/380] Tests of the new "all" and "active" values for options.include. --- src/__tests__/refetchQueries.ts | 165 ++++++++++++++++++++++++++++++++ 1 file changed, 165 insertions(+) diff --git a/src/__tests__/refetchQueries.ts b/src/__tests__/refetchQueries.ts index d5b5bd72236..88725bbd997 100644 --- a/src/__tests__/refetchQueries.ts +++ b/src/__tests__/refetchQueries.ts @@ -351,6 +351,171 @@ describe("client.refetchQueries", () => { resolve(); }); + itAsync('includes all queries when options.include === "all"', async (resolve, reject) => { + const client = makeClient(); + const [ + aObs, + bObs, + abObs, + ] = await setup(client); + + const ayyResults = await client.refetchQueries({ + include: "all", + + updateCache(cache) { + cache.writeQuery({ + query: aQuery, + data: { + a: "Ayy", + }, + }); + }, + + onQueryUpdated(obs, diff) { + if (obs === aObs) { + expect(diff.result).toEqual({ a: "Ayy" }); + } else if (obs === bObs) { + expect(diff.result).toEqual({ b: "B" }); + } else if (obs === abObs) { + expect(diff.result).toEqual({ a: "Ayy", b: "B" }); + } else { + reject(`unexpected ObservableQuery ${ + obs.queryId + } with name ${obs.queryName}`); + } + return Promise.resolve(diff.result); + }, + }); + + sortObjects(ayyResults); + + expect(ayyResults).toEqual([ + { a: "Ayy" }, + { a: "Ayy", b: "B" }, + { b: "B" }, + ]); + + const beeResults = await client.refetchQueries({ + include: "all", + + updateCache(cache) { + cache.writeQuery({ + query: bQuery, + data: { + b: "Bee", + }, + }); + }, + + onQueryUpdated(obs, diff) { + if (obs === aObs) { + expect(diff.result).toEqual({ a: "Ayy" }); + } else if (obs === bObs) { + expect(diff.result).toEqual({ b: "Bee" }); + } else if (obs === abObs) { + expect(diff.result).toEqual({ a: "Ayy", b: "Bee" }); + } else { + reject(`unexpected ObservableQuery ${ + obs.queryId + } with name ${obs.queryName}`); + } + return diff.result; + }, + }); + + sortObjects(beeResults); + + expect(beeResults).toEqual([ + { a: "Ayy" }, + { a: "Ayy", b: "Bee" }, + { b: "Bee" }, + ]); + + unsubscribe(); + resolve(); + }); + + itAsync('includes all active queries when options.include === "active"', async (resolve, reject) => { + const client = makeClient(); + const [ + aObs, + bObs, + abObs, + ] = await setup(client); + + const extraObs = client.watchQuery({ query: abQuery }); + expect(extraObs.hasObservers()).toBe(false); + + const activeResults = await client.refetchQueries({ + include: "active", + + onQueryUpdated(obs, diff) { + if (obs === aObs) { + expect(diff.result).toEqual({ a: "A" }); + } else if (obs === bObs) { + expect(diff.result).toEqual({ b: "B" }); + } else if (obs === abObs) { + expect(diff.result).toEqual({ a: "A", b: "B" }); + } else { + reject(`unexpected ObservableQuery ${ + obs.queryId + } with name ${obs.queryName}`); + } + return Promise.resolve(diff.result); + }, + }); + + sortObjects(activeResults); + + expect(activeResults).toEqual([ + { a: "A" }, + { a: "A", b: "B" }, + { b: "B" }, + ]); + + subs.push(extraObs.subscribe({ + next(result) { + expect(result).toEqual({ a: "A", b: "B" }); + }, + })); + expect(extraObs.hasObservers()).toBe(true); + + const resultsAfterSubscribe = await client.refetchQueries({ + include: "active", + + onQueryUpdated(obs, diff) { + if (obs === aObs) { + expect(diff.result).toEqual({ a: "A" }); + } else if (obs === bObs) { + expect(diff.result).toEqual({ b: "B" }); + } else if (obs === abObs) { + expect(diff.result).toEqual({ a: "A", b: "B" }); + } else if (obs === extraObs) { + expect(diff.result).toEqual({ a: "A", b: "B" }); + } else { + reject(`unexpected ObservableQuery ${ + obs.queryId + } with name ${obs.queryName}`); + } + return Promise.resolve(diff.result); + }, + }); + + sortObjects(resultsAfterSubscribe); + + expect(resultsAfterSubscribe).toEqual([ + { a: "A" }, + { a: "A", b: "B" }, + // Included thanks to extraObs this time. + { a: "A", b: "B" }, + // Sorted last by sortObjects. + { b: "B" }, + ]); + + unsubscribe(); + resolve(); + }); + itAsync("refetches watched queries if onQueryUpdated not provided", async (resolve, reject) => { const client = makeClient(); const [ From ba92852c521517abd9e802e4d2900decf621541d Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Wed, 2 Jun 2021 19:17:45 -0400 Subject: [PATCH 216/380] Test that both "all" and "active" ignore one-off client.query queries. --- src/__tests__/refetchQueries.ts | 107 +++++++++++++++++++++++++++++++- 1 file changed, 105 insertions(+), 2 deletions(-) diff --git a/src/__tests__/refetchQueries.ts b/src/__tests__/refetchQueries.ts index 88725bbd997..a18f8f5a861 100644 --- a/src/__tests__/refetchQueries.ts +++ b/src/__tests__/refetchQueries.ts @@ -50,8 +50,13 @@ describe("client.refetchQueries", () => { operation.operationName.split("").forEach(letter => { data[letter.toLowerCase()] = letter.toUpperCase(); }); - observer.next({ data }); - observer.complete(); + function finish() { + observer.next({ data }); + observer.complete(); + } + if (typeof operation.variables.delay === "number") { + setTimeout(finish, operation.variables.delay); + } else finish(); })), }); } @@ -516,6 +521,104 @@ describe("client.refetchQueries", () => { resolve(); }); + itAsync('should not include unwatched single queries', async (resolve, reject) => { + const client = makeClient(); + const [ + aObs, + bObs, + abObs, + ] = await setup(client); + + const delayedQuery = gql`query DELAYED { d e l a y e d }`; + + client.query({ + query: delayedQuery, + variables: { + // Delay this query by 10 seconds so it stays in-flight. + delay: 10000, + }, + }).catch(reject); + + const queries = client["queryManager"]["queries"]; + expect(queries.size).toBe(4); + + queries.forEach((queryInfo, queryId) => { + if ( + queryId === "1" || + queryId === "2" || + queryId === "3" + ) { + expect(queryInfo.observableQuery).toBeInstanceOf(ObservableQuery); + } else if (queryId === "4") { + // One-off client.query-style queries never get an ObservableQuery, so + // they should not be included by include: "active". + expect(queryInfo.observableQuery).toBe(null); + expect(queryInfo.document).toBe(delayedQuery); + } + }); + + const activeResults = await client.refetchQueries({ + include: "active", + + onQueryUpdated(obs, diff) { + if (obs === aObs) { + expect(diff.result).toEqual({ a: "A" }); + } else if (obs === bObs) { + expect(diff.result).toEqual({ b: "B" }); + } else if (obs === abObs) { + expect(diff.result).toEqual({ a: "A", b: "B" }); + } else { + reject(`unexpected ObservableQuery ${ + obs.queryId + } with name ${obs.queryName}`); + } + return Promise.resolve(diff.result); + }, + }); + + sortObjects(activeResults); + + expect(activeResults).toEqual([ + { a: "A" }, + { a: "A", b: "B" }, + { b: "B" }, + ]); + + const allResults = await client.refetchQueries({ + include: "all", + + onQueryUpdated(obs, diff) { + if (obs === aObs) { + expect(diff.result).toEqual({ a: "A" }); + } else if (obs === bObs) { + expect(diff.result).toEqual({ b: "B" }); + } else if (obs === abObs) { + expect(diff.result).toEqual({ a: "A", b: "B" }); + } else { + reject(`unexpected ObservableQuery ${ + obs.queryId + } with name ${obs.queryName}`); + } + return Promise.resolve(diff.result); + }, + }); + + sortObjects(allResults); + + expect(allResults).toEqual([ + { a: "A" }, + { a: "A", b: "B" }, + { b: "B" }, + ]); + + unsubscribe(); + client.stop(); + + expect(queries.size).toBe(0); + + resolve(); + }); + itAsync("refetches watched queries if onQueryUpdated not provided", async (resolve, reject) => { const client = makeClient(); const [ From 90b35ef2167accbaf9d597ca5c6b46530899ee2d Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Wed, 2 Jun 2021 17:22:31 -0400 Subject: [PATCH 217/380] delete debugger --- src/link/batch-http/__tests__/batchHttpLink.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/link/batch-http/__tests__/batchHttpLink.ts b/src/link/batch-http/__tests__/batchHttpLink.ts index b2563ddeb07..f461bca34d1 100644 --- a/src/link/batch-http/__tests__/batchHttpLink.ts +++ b/src/link/batch-http/__tests__/batchHttpLink.ts @@ -402,7 +402,6 @@ describe('SharedHttpTest', () => { }; it('passes all arguments to multiple fetch body including extensions', done => { - debugger; const link = createHttpLink({ uri: '/data', includeExtensions: true }); verifyRequest( link, From f7392140e64cc6d594cd28be65c7c58bcafa9705 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Wed, 2 Jun 2021 20:42:22 -0400 Subject: [PATCH 218/380] Add a failing test? --- src/react/hooks/__tests__/useQuery.test.tsx | 81 +++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/src/react/hooks/__tests__/useQuery.test.tsx b/src/react/hooks/__tests__/useQuery.test.tsx index 642335f6ecb..2cd7b5137b8 100644 --- a/src/react/hooks/__tests__/useQuery.test.tsx +++ b/src/react/hooks/__tests__/useQuery.test.tsx @@ -548,6 +548,87 @@ describe('useQuery Hook', () => { }).then(resolve, reject); }); + itAsync('should start polling when skip goes from true to false', (resolve, reject) => { + const query = gql` + query car { + car { + id + make + } + } + `; + + const data1 = { + car: { + id: 1, + make: 'Venturi', + __typename: 'Car', + } + }; + + const data2 = { + car: { + id: 2, + make: 'Wiesmann', + __typename: 'Car', + } + }; + + const mocks = [ + { request: { query }, result: { data: data1 } }, + { request: { query }, result: { data: data2 } } + ]; + + let renderCount = 0; + const Component = () => { + const [shouldSkip, setShouldSkip] = useState(false); + let { data, loading, stopPolling } = useQuery(query, { + pollInterval: 100, + skip: shouldSkip, + }); + + switch (++renderCount) { + case 1: + expect(loading).toBeTruthy(); + expect(data).toBeUndefined(); + break; + case 2: + expect(loading).toBeFalsy(); + expect(data).toEqual(data1); + setShouldSkip(true); + break; + case 3: + expect(loading).toBeFalsy(); + expect(data).toBeUndefined(); + setShouldSkip(false); + break; + case 4: + expect(loading).toBeFalsy(); + expect(data).toEqual(data1); + break; + case 5: + expect(loading).toBeFalsy(); + expect(data).toEqual(data2); + stopPolling(); + break; + default: + reject(new Error('too many updates')); + } + + return null; + }; + + render( + + + + ); + + return wait(() => { + expect(renderCount).toBe(5); + }).then(resolve, reject); + }); + itAsync('should stop polling when the component is unmounted', async (resolve, reject) => { const mocks = [ ...CAR_MOCKS, From 5a90c3c96fb1f39f2c8ac53fcb29a9a3b489e075 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Thu, 3 Jun 2021 11:20:02 -0400 Subject: [PATCH 219/380] fix polling when skip goes from true to false --- src/core/ObservableQuery.ts | 11 +++++------ src/core/QueryInfo.ts | 2 +- src/react/data/QueryData.ts | 15 +++++++++------ 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/src/core/ObservableQuery.ts b/src/core/ObservableQuery.ts index f84dcbf9a88..a99ad6e5d99 100644 --- a/src/core/ObservableQuery.ts +++ b/src/core/ObservableQuery.ts @@ -536,12 +536,11 @@ once, rather than every time you call fetchMore.`); // Initiate observation of this query if it hasn't been reported to // the QueryManager yet. if (first) { - this.reobserve().catch(_ => { - // Blindly catching here prevents unhandled promise rejections, - // and is safe because the ObservableQuery handles this error with - // this.observer.error, so we're not just swallowing the error by - // ignoring it here. - }); + // Blindly catching here prevents unhandled promise rejections, + // and is safe because the ObservableQuery handles this error with + // this.observer.error, so we're not just swallowing the error by + // ignoring it here. + this.reobserve().catch(() => {}); } return () => { diff --git a/src/core/QueryInfo.ts b/src/core/QueryInfo.ts index f70cf25a42a..bd1b4b3e02d 100644 --- a/src/core/QueryInfo.ts +++ b/src/core/QueryInfo.ts @@ -236,7 +236,7 @@ export class QueryInfo { // Cancel the pending notify timeout this.reset(); - + this.cancel(); // Revert back to the no-op version of cancel inherited from // QueryInfo.prototype. diff --git a/src/react/data/QueryData.ts b/src/react/data/QueryData.ts index 33c48ebc7c1..204109cc27f 100644 --- a/src/react/data/QueryData.ts +++ b/src/react/data/QueryData.ts @@ -197,6 +197,9 @@ export class QueryData extends OperationData< options.fetchPolicy === 'cache-and-network') ) { options.fetchPolicy = 'cache-first'; + } else if (options.nextFetchPolicy && this.currentObservable) { + // XXX: This is a hack to handle skipped queries with a nextFetchPolicy. + options.fetchPolicy = options.nextFetchPolicy; } return { @@ -243,18 +246,18 @@ export class QueryData extends OperationData< return; } - if (this.getOptions().skip) return; - const newObservableQueryOptions = { ...this.prepareObservableQueryOptions(), children: null }; + if (this.getOptions().skip) { + this.previous.observableQueryOptions = newObservableQueryOptions; + return; + } + if ( - !equal( - newObservableQueryOptions, - this.previous.observableQueryOptions - ) + !equal(newObservableQueryOptions, this.previous.observableQueryOptions) ) { this.previous.observableQueryOptions = newObservableQueryOptions; this.currentObservable From f6cf7f979265b3dc087def3988baeb6d04915cb2 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Fri, 4 Jun 2021 11:09:05 -0400 Subject: [PATCH 220/380] Make "active" exclude cache-only queries only for reFetchObservableQueries. Queries with `fetchPolicy: cache-only` are certainly not "inactive" (you just might not want to refetch them from the network), so excluding them is a choice for the reFetchObservableQueries method to make. Note: we are currently planning to deprecate reFetchObservableQueries and remove it in Apollo Client v4, so this logic will disappear from the codebase after that. The new client.refetchQueries API provides a much more flexible way to specify which queries you want to include, including the ability to filter/skip them in arbitrary ways in the onQueryUpdated function. --- src/core/QueryManager.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index b6001129fbf..177fdeb9774 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -762,9 +762,7 @@ export class QueryManager { options: { fetchPolicy }, } = oq; - if (fetchPolicy === "cache-only" || - fetchPolicy === "standby" || - !oq.hasObservers()) { + if (fetchPolicy === "standby" || !oq.hasObservers()) { // Skip inactive queries unless include === "all". return; } @@ -826,8 +824,13 @@ export class QueryManager { this.getObservableQueries( includeStandby ? "all" : "active" ).forEach((observableQuery, queryId) => { + const { fetchPolicy } = observableQuery.options; observableQuery.resetLastResults(); - observableQueryPromises.push(observableQuery.refetch()); + if (includeStandby || + (fetchPolicy !== "standby" && + fetchPolicy !== "cache-only")) { + observableQueryPromises.push(observableQuery.refetch()); + } this.getQuery(queryId).setDiff(null); }); From 1c6314c5a8c968efd072eca4d468c271873eb555 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Fri, 4 Jun 2021 11:43:54 -0400 Subject: [PATCH 221/380] Improve legacy refetchQueries test using subscribeAndCount. --- src/core/__tests__/QueryManager/index.ts | 39 +++++++++--------------- 1 file changed, 15 insertions(+), 24 deletions(-) diff --git a/src/core/__tests__/QueryManager/index.ts b/src/core/__tests__/QueryManager/index.ts index 5af44167783..30141e5d0c7 100644 --- a/src/core/__tests__/QueryManager/index.ts +++ b/src/core/__tests__/QueryManager/index.ts @@ -4904,31 +4904,22 @@ describe('QueryManager', () => { }, ); const observable = queryManager.watchQuery({ query, variables }); - let count = 0; - observable.subscribe({ - next: result => { - const resultData = stripSymbols(result.data); - if (count === 0) { - expect(resultData).toEqual(data); - queryManager.mutate({ - mutation, - variables: mutationVariables, - refetchQueries: [{ query, variables }], - }); - } - if (count === 1) { - setTimeout(() => { - expect(stripSymbols(observable.getCurrentResult().data)).toEqual( - secondReqData, - ); - resolve(); - }, 1); - - expect(resultData).toEqual(secondReqData); - } - count++; - }, + subscribeAndCount(reject, observable, (count, result) => { + if (count === 1) { + expect(result.data).toEqual(data); + queryManager.mutate({ + mutation, + variables: mutationVariables, + refetchQueries: [{ query, variables }], + }); + } else if (count === 2) { + expect(result.data).toEqual(secondReqData); + expect(observable.getCurrentResult().data).toEqual(secondReqData); + setTimeout(resolve, 10); + } else { + reject("too many results"); + } }); }); From 4a8e265016feaa4e5ac2e3820ae89a3ef145216e Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Fri, 4 Jun 2021 11:49:41 -0400 Subject: [PATCH 222/380] Clean up legacy one-time queries after refetching in refetchQueries. Although passing one-time { query, variables } QueryOptions in the options.include array is discouraged by the type system, it's still allowed for the refetchQueries option for mutations, so we should make sure to stop those temporary queries after they've been fetched. I didn't want to complicate the client.getObservableQueries API to return additional metadata about which queries are legacy/temporary, so I'm using query ID strings (the keys of the getObservableQueries Map) to convey that information. --- src/core/QueryManager.ts | 9 +++++++-- src/core/__tests__/QueryManager/index.ts | 9 ++++++++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index 177fdeb9774..8e56d630f5d 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -782,8 +782,9 @@ export class QueryManager { if (legacyQueryOptions.size) { legacyQueryOptions.forEach((options: QueryOptions) => { // We will be issuing a fresh network request for this query, so we - // pre-allocate a new query ID here. - const queryId = this.generateQueryId(); + // pre-allocate a new query ID here, using a special prefix to enable + // cleaning up these temporary queries later, after fetching. + const queryId = makeUniqueId("legacyOneTimeQuery"); const queryInfo = this.getQuery(queryId).init({ document: options.query, variables: options.variables, @@ -1310,6 +1311,10 @@ export class QueryManager { if (result !== false) { results.set(oq, result!); } + + if (queryId.indexOf("legacyOneTimeQuery") >= 0) { + this.stopQueryNoBroadcast(queryId); + } }); } diff --git a/src/core/__tests__/QueryManager/index.ts b/src/core/__tests__/QueryManager/index.ts index 30141e5d0c7..a474a3fd378 100644 --- a/src/core/__tests__/QueryManager/index.ts +++ b/src/core/__tests__/QueryManager/index.ts @@ -4916,7 +4916,14 @@ describe('QueryManager', () => { } else if (count === 2) { expect(result.data).toEqual(secondReqData); expect(observable.getCurrentResult().data).toEqual(secondReqData); - setTimeout(resolve, 10); + + return new Promise(res => setTimeout(res, 10)).then(() => { + // Make sure the QueryManager cleans up legacy one-time queries like + // the one we requested above using refetchQueries. + queryManager["queries"].forEach((queryInfo, queryId) => { + expect(queryId).not.toContain("legacyOneTimeQuery"); + }); + }).then(resolve, reject); } else { reject("too many results"); } From 91c9a1fd0508ee857812cfc88568662fdea1a217 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Fri, 4 Jun 2021 14:38:23 -0400 Subject: [PATCH 223/380] Update @wry/equality to version 0.5.0. Thanks to @ScottAwesome for https://github.com/benjamn/wryware/pull/155 --- package-lock.json | 13 +++---------- package.json | 2 +- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/package-lock.json b/package-lock.json index ce7569dd9e3..1607f7dff32 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1995,18 +1995,11 @@ } }, "@wry/equality": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@wry/equality/-/equality-0.4.0.tgz", - "integrity": "sha512-DxN/uawWfhRbgYE55zVCPOoe+jvsQ4m7PT1Wlxjyb/LCCLuU1UsucV2BbCxFAX8bjcSueFBbB5Qfj1Zfe8e7Fw==", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@wry/equality/-/equality-0.5.0.tgz", + "integrity": "sha512-VUDGi/88nL0sobgtMndb2Js1bTXn46zkjJOdCoP+i+kdZd1SWypwb9Gtzue6uDgnWmd7UEDyJzrdYNyghr5FLA==", "requires": { "tslib": "^2.1.0" - }, - "dependencies": { - "tslib": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.1.0.tgz", - "integrity": "sha512-hcVC3wYEziELGGmEEXue7D75zbwIIVUMWAVbHItGPx0ziyXxrOMQx4rQEVEV45Ut/1IotuEvwqPopzIOkDMf0A==" - } } }, "@wry/trie": { diff --git a/package.json b/package.json index e8eea8645bf..744e0a951a9 100644 --- a/package.json +++ b/package.json @@ -74,7 +74,7 @@ "dependencies": { "@graphql-typed-document-node/core": "^3.0.0", "@wry/context": "^0.6.0", - "@wry/equality": "^0.4.0", + "@wry/equality": "^0.5.0", "@wry/trie": "^0.3.0", "graphql-tag": "^2.12.3", "hoist-non-react-statics": "^3.3.2", From 1e5d4f85de55af61b4eb706123591f507d4cce60 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Fri, 4 Jun 2021 15:17:11 -0400 Subject: [PATCH 224/380] Export reusable applyNextFetchPolicy function from ObservableQuery.ts. --- src/__tests__/__snapshots__/exports.ts.snap | 2 ++ src/core/ObservableQuery.ts | 38 +++++++++++++++++++++ src/core/QueryManager.ts | 25 ++------------ src/core/index.ts | 1 + src/react/data/QueryData.ts | 6 ++-- 5 files changed, 46 insertions(+), 26 deletions(-) diff --git a/src/__tests__/__snapshots__/exports.ts.snap b/src/__tests__/__snapshots__/exports.ts.snap index 5f90346e03f..3f99582f461 100644 --- a/src/__tests__/__snapshots__/exports.ts.snap +++ b/src/__tests__/__snapshots__/exports.ts.snap @@ -16,6 +16,7 @@ Array [ "NetworkStatus", "Observable", "ObservableQuery", + "applyNextFetchPolicy", "checkFetcher", "concat", "createHttpLink", @@ -86,6 +87,7 @@ Array [ "NetworkStatus", "Observable", "ObservableQuery", + "applyNextFetchPolicy", "checkFetcher", "concat", "createHttpLink", diff --git a/src/core/ObservableQuery.ts b/src/core/ObservableQuery.ts index a99ad6e5d99..bb427ca6318 100644 --- a/src/core/ObservableQuery.ts +++ b/src/core/ObservableQuery.ts @@ -651,3 +651,41 @@ fixObservableSubclass(ObservableQuery); function defaultSubscriptionObserverErrorCallback(error: ApolloError) { invariant.error('Unhandled error', error.message, error.stack); } + +// Adopt options.nextFetchPolicy (if defined) as a replacement for +// options.fetchPolicy. Since this method also removes options.nextFetchPolicy +// from options, the adoption tends to be idempotent, unless nextFetchPolicy +// is a function that keeps setting options.nextFetchPolicy (uncommon). +export function applyNextFetchPolicy( + options: Pick< + WatchQueryOptions, + | "fetchPolicy" + | "nextFetchPolicy" + >, +) { + const { + fetchPolicy = "cache-first", + nextFetchPolicy, + } = options; + + if (nextFetchPolicy) { + // The options.nextFetchPolicy transition should happen only once, but it + // should also be possible (though uncommon) for a nextFetchPolicy function + // to set options.nextFetchPolicy to perform an additional transition. + options.nextFetchPolicy = void 0; + + // When someone chooses "cache-and-network" or "network-only" as their + // initial FetchPolicy, they often do not want future cache updates to + // trigger unconditional network requests, which is what repeatedly + // applying the "cache-and-network" or "network-only" policies would seem + // to imply. Instead, when the cache reports an update after the initial + // network request, it may be desirable for subsequent network requests to + // be triggered only if the cache result is incomplete. To that end, the + // options.nextFetchPolicy option provides an easy way to update + // options.fetchPolicy after the intial network request, without having to + // call observableQuery.setOptions. + options.fetchPolicy = typeof nextFetchPolicy === "function" + ? nextFetchPolicy.call(options, fetchPolicy) + : nextFetchPolicy; + } +} diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index fbb3be58b80..0873ba5fab2 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -29,7 +29,7 @@ import { WatchQueryFetchPolicy, ErrorPolicy, } from './watchQueryOptions'; -import { ObservableQuery } from './ObservableQuery'; +import { ObservableQuery, applyNextFetchPolicy } from './ObservableQuery'; import { NetworkStatus, isNetworkRequestInFlight } from './networkStatus'; import { ApolloQueryResult, @@ -1000,28 +1000,7 @@ export class QueryManager { concast.cleanup(() => { this.fetchCancelFns.delete(queryId); - - const { nextFetchPolicy } = options; - if (nextFetchPolicy) { - // The options.nextFetchPolicy transition should happen only once, - // but it should be possible for a nextFetchPolicy function to set - // this.nextFetchPolicy to perform an additional transition. - options.nextFetchPolicy = void 0; - - // When someone chooses cache-and-network or network-only as their - // initial FetchPolicy, they often do not want future cache updates to - // trigger unconditional network requests, which is what repeatedly - // applying the cache-and-network or network-only policies would seem - // to imply. Instead, when the cache reports an update after the - // initial network request, it may be desirable for subsequent network - // requests to be triggered only if the cache result is incomplete. - // The options.nextFetchPolicy option provides an easy way to update - // options.fetchPolicy after the intial network request, without - // having to call observableQuery.setOptions. - options.fetchPolicy = typeof nextFetchPolicy === "function" - ? nextFetchPolicy.call(options, options.fetchPolicy || "cache-first") - : nextFetchPolicy; - } + applyNextFetchPolicy(options); }); return concast; diff --git a/src/core/index.ts b/src/core/index.ts index 30dc1e00e1e..d6b22c4a3f9 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -10,6 +10,7 @@ export { ObservableQuery, FetchMoreOptions, UpdateQueryOptions, + applyNextFetchPolicy, } from './ObservableQuery'; export { QueryOptions, diff --git a/src/react/data/QueryData.ts b/src/react/data/QueryData.ts index 204109cc27f..0fc702a5602 100644 --- a/src/react/data/QueryData.ts +++ b/src/react/data/QueryData.ts @@ -8,10 +8,11 @@ import { FetchMoreQueryOptions, SubscribeToMoreOptions, ObservableQuery, + applyNextFetchPolicy, FetchMoreOptions, UpdateQueryOptions, DocumentNode, - TypedDocumentNode + TypedDocumentNode, } from '../../core'; import { @@ -198,8 +199,7 @@ export class QueryData extends OperationData< ) { options.fetchPolicy = 'cache-first'; } else if (options.nextFetchPolicy && this.currentObservable) { - // XXX: This is a hack to handle skipped queries with a nextFetchPolicy. - options.fetchPolicy = options.nextFetchPolicy; + applyNextFetchPolicy(options); } return { From 1f804a5d1c3f9fbe158fd900f893e8f9af86d54b Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Fri, 4 Jun 2021 16:35:02 -0400 Subject: [PATCH 225/380] Bump @apollo/client npm version to 3.4.0-rc.4. --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1607f7dff32..104873fa5f5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-rc.3", + "version": "3.4.0-rc.4", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 744e0a951a9..d60d2e2486a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-rc.3", + "version": "3.4.0-rc.4", "description": "A fully-featured caching GraphQL client.", "private": true, "keywords": [ From 944d2da46f0d4e08c7dd3a3d187eed95c0cf7b10 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Mon, 7 Jun 2021 13:46:32 -0400 Subject: [PATCH 226/380] Update CHANGELOG.md --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 250c686ddbd..57e680c690c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,8 @@ [@benjamn](https://github.com/benjamn) in [#7810](https://github.com/apollographql/apollo-client/pull/7810) - Make sure the `MockedResponse` `ResultFunction` type is re-exported.
[@hwillson](https://github.com/hwillson) in [#8315](https://github.com/apollographql/apollo-client/pull/8315) +- Fix polling when used with skip
+ [@brainkim](https://github.com/brainkim) in [#8346](https://github.com/apollographql/apollo-client/pull/8346) ### Potentially breaking changes From 2d0ebe714f8e71d32d9424e1573c3bd69e0d8dc8 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Mon, 7 Jun 2021 15:10:24 -0400 Subject: [PATCH 227/380] Bump @apollo/client npm version to 3.4.0-rc.5. --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9641a42a683..88375533832 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-rc.4", + "version": "3.4.0-rc.5", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index b360959aefc..cb3e4e8d8c0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-rc.4", + "version": "3.4.0-rc.5", "description": "A fully-featured caching GraphQL client.", "private": true, "keywords": [ From 9b522cfe6759071108e12491e0351134240582a4 Mon Sep 17 00:00:00 2001 From: Mark Neuburger Date: Mon, 7 Jun 2021 17:10:26 -0400 Subject: [PATCH 228/380] Add expected/received variables to 'No more mocked responses' error message (#8340) Co-authored-by: Ben Newman --- CHANGELOG.md | 12 +++-- .../mocking/__tests__/MockedProvider.test.tsx | 3 +- .../MockedProvider.test.tsx.snap | 26 +++++++-- src/utilities/testing/mocking/mockLink.ts | 53 ++++++++++++------- 4 files changed, 66 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 57e680c690c..737a239fa0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,9 +17,11 @@ }) ``` [@benjamn](https://github.com/benjamn) in [#7810](https://github.com/apollographql/apollo-client/pull/7810) -- Make sure the `MockedResponse` `ResultFunction` type is re-exported.
+ +- Make sure the `MockedResponse` `ResultFunction` type is re-exported.
[@hwillson](https://github.com/hwillson) in [#8315](https://github.com/apollographql/apollo-client/pull/8315) -- Fix polling when used with skip
+ +- Fix polling when used with `skip`.
[@brainkim](https://github.com/brainkim) in [#8346](https://github.com/apollographql/apollo-client/pull/8346) ### Potentially breaking changes @@ -74,7 +76,11 @@ - Fully remove result cache entries from LRU dependency system when the corresponding entities are removed from `InMemoryCache` by eviction, or by any other means.
[@sofianhn](https://github.com/sofianhn) and [@benjamn](https://github.com/benjamn) in [#8147](https://github.com/apollographql/apollo-client/pull/8147) -- Expose missing field errors in results.
[@brainkim](github.com/brainkim) in [#8262](https://github.com/apollographql/apollo-client/pull/8262) +- Expose missing field errors in results.
+ [@brainkim](github.com/brainkim) in [#8262](https://github.com/apollographql/apollo-client/pull/8262) + +- Add expected/received `variables` to `No more mocked responses...` error messages generated by `MockLink`.
+ [@markneub](github.com/markneub) in [#8340](https://github.com/apollographql/apollo-client/pull/8340) ### Documentation TBD diff --git a/src/utilities/testing/mocking/__tests__/MockedProvider.test.tsx b/src/utilities/testing/mocking/__tests__/MockedProvider.test.tsx index b8bd658edd1..17173541017 100644 --- a/src/utilities/testing/mocking/__tests__/MockedProvider.test.tsx +++ b/src/utilities/testing/mocking/__tests__/MockedProvider.test.tsx @@ -157,7 +157,8 @@ describe('General use', () => { } const variables2 = { - username: 'other_user' + username: 'other_user', + age: undefined }; render( diff --git a/src/utilities/testing/mocking/__tests__/__snapshots__/MockedProvider.test.tsx.snap b/src/utilities/testing/mocking/__tests__/__snapshots__/MockedProvider.test.tsx.snap index 89d4ffd6cef..f5819bd6f17 100644 --- a/src/utilities/testing/mocking/__tests__/__snapshots__/MockedProvider.test.tsx.snap +++ b/src/utilities/testing/mocking/__tests__/__snapshots__/MockedProvider.test.tsx.snap @@ -14,7 +14,9 @@ exports[`General use should error if the query in the mock and component do not __typename } } -, variables: {"username":"mock_username"}] + +Expected variables: {"username":"mock_username"} +] `; exports[`General use should error if the variables do not deep equal 1`] = ` @@ -24,7 +26,12 @@ exports[`General use should error if the variables do not deep equal 1`] = ` __typename } } -, variables: {"username":"some_user","age":42}] + +Expected variables: {"username":"some_user","age":42} + +Failed to match 1 mock for this query, which had the following variables: + {"age":13,"username":"some_user"} +] `; exports[`General use should error if the variables in the mock and component do not match 1`] = ` @@ -34,7 +41,12 @@ exports[`General use should error if the variables in the mock and component do __typename } } -, variables: {"username":"other_user"}] + +Expected variables: {"username":"other_user","age":} + +Failed to match 1 mock for this query, which had the following variables: + {"username":"mock_username"} +] `; exports[`General use should mock the data 1`] = ` @@ -64,7 +76,9 @@ exports[`General use should return "No more mocked responses" errors in response __typename } } -, variables: {}] + +Expected variables: {} +] `; exports[`General use should support custom error handling using setOnError 1`] = ` @@ -74,5 +88,7 @@ exports[`General use should support custom error handling using setOnError 1`] = __typename } } -, variables: {"username":"mock_username"}] + +Expected variables: {"username":"mock_username"} +] `; diff --git a/src/utilities/testing/mocking/mockLink.ts b/src/utilities/testing/mocking/mockLink.ts index a66e2a7f5a4..7b3a24b49ba 100644 --- a/src/utilities/testing/mocking/mockLink.ts +++ b/src/utilities/testing/mocking/mockLink.ts @@ -15,10 +15,18 @@ import { removeClientSetsFromDocument, removeConnectionDirectiveFromDocument, cloneDeep, + makeUniqueId, } from '../../../utilities'; export type ResultFunction = () => T; +function stringifyForDisplay(value: any): string { + const undefId = makeUniqueId("stringifyForDisplay"); + return JSON.stringify(value, (key, value) => { + return value === void 0 ? undefId : value; + }).split(JSON.stringify(undefId)).join(""); +} + export interface MockedResponse> { request: GraphQLRequest; result?: FetchResult | ResultFunction>; @@ -72,34 +80,41 @@ export class MockLink extends ApolloLink { public request(operation: Operation): Observable | null { this.operation = operation; const key = requestToKey(operation, this.addTypename); - let responseIndex: number = 0; - const response = (this.mockedResponsesByKey[key] || []).find( - (res, index) => { - const requestVariables = operation.variables || {}; - const mockedResponseVariables = res.request.variables || {}; - if (equal(requestVariables, mockedResponseVariables)) { - responseIndex = index; - return true; - } - return false; + const unmatchedVars: Array> = []; + const requestVariables = operation.variables || {}; + const mockedResponses = this.mockedResponsesByKey[key]; + const responseIndex = mockedResponses ? mockedResponses.findIndex((res, index) => { + const mockedResponseVars = res.request.variables || {}; + if (equal(requestVariables, mockedResponseVars)) { + return true; } - ); + unmatchedVars.push(mockedResponseVars); + return false; + }) : -1; + + const response = responseIndex >= 0 + ? mockedResponses[responseIndex] + : void 0; let configError: Error; - if (!response || typeof responseIndex === 'undefined') { + if (!response) { configError = new Error( - `No more mocked responses for the query: ${print( - operation.query - )}, variables: ${JSON.stringify(operation.variables)}` - ); +`No more mocked responses for the query: ${print(operation.query)} +Expected variables: ${stringifyForDisplay(operation.variables)} +${unmatchedVars.length > 0 ? ` +Failed to match ${unmatchedVars.length} mock${ + unmatchedVars.length === 1 ? "" : "s" +} for this query, which had the following variables: +${unmatchedVars.map(d => ` ${stringifyForDisplay(d)}`).join('\n')} +` : ""}`); } else { - this.mockedResponsesByKey[key].splice(responseIndex, 1); + mockedResponses.splice(responseIndex, 1); const { newData } = response; if (newData) { response.result = newData(); - this.mockedResponsesByKey[key].push(response); + mockedResponses.push(response); } if (!response.result && !response.error) { @@ -151,7 +166,7 @@ export class MockLink extends ApolloLink { ): MockedResponse { const newMockedResponse = cloneDeep(mockedResponse); const queryWithoutConnection = removeConnectionDirectiveFromDocument( - newMockedResponse.request.query + newMockedResponse.request.query ); invariant(queryWithoutConnection, "query is required"); newMockedResponse.request.query = queryWithoutConnection!; From d3fab0ac24368bc3dbd38fafbe45863488fcd351 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 8 Jun 2021 16:22:23 -0400 Subject: [PATCH 229/380] Reduce bundlesize limit to 24.3kB, reflecting current size. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index cb3e4e8d8c0..2a79eb95975 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ { "name": "apollo-client", "path": "./dist/apollo-client.cjs.min.js", - "maxSize": "26.62 kB" + "maxSize": "24.3 kB" } ], "peerDependencies": { From 175321b1aedbd21daa82f93ddddadfbd4fcaeb37 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 8 Jun 2021 16:25:26 -0400 Subject: [PATCH 230/380] Bump @apollo/client npm version to 3.4.0-rc.6. --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 88375533832..d9cd3883294 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-rc.5", + "version": "3.4.0-rc.6", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 2a79eb95975..ac0c9d9ad72 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-rc.5", + "version": "3.4.0-rc.6", "description": "A fully-featured caching GraphQL client.", "private": true, "keywords": [ From 6ffbc8ee536a0548aa3bf30742f2b6915eb339b1 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Mon, 14 Jun 2021 15:04:31 -0400 Subject: [PATCH 231/380] Merge all fragment fields before running `merge` functions and updating store (#8372) --- CHANGELOG.md | 3 + package.json | 2 +- .../__snapshots__/writeToStore.ts.snap | 64 +++++ src/cache/inmemory/__tests__/writeToStore.ts | 139 +++++++++++ src/cache/inmemory/writeToStore.ts | 231 +++++++++++++----- 5 files changed, 376 insertions(+), 63 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ece566bd363..9a0780f862e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,9 @@ - Fix polling when used with `skip`.
[@brainkim](https://github.com/brainkim) in [#8346](https://github.com/apollographql/apollo-client/pull/8346) +- `InMemoryCache` now coalesces `EntityStore` updates to guarantee only one `store.merge(id, fields)` call per `id` per cache write.
+ [@benjamn](https://github.com/benjamn) in [#8372](https://github.com/apollographql/apollo-client/pull/8372) + ### Potentially breaking changes - To avoid retaining sensitive information from mutation root field arguments, Apollo Client v3.4 automatically clears any `ROOT_MUTATION` fields from the cache after each mutation finishes. If you need this information to remain in the cache, you can prevent the removal by passing the `keepRootFields: true` option to `client.mutate`. `ROOT_MUTATION` result data are also passed to the mutation `update` function, so we recommend obtaining the results that way, rather than using `keepRootFields: true`, if possible.
diff --git a/package.json b/package.json index ac0c9d9ad72..6fbe06b1f31 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ { "name": "apollo-client", "path": "./dist/apollo-client.cjs.min.js", - "maxSize": "24.3 kB" + "maxSize": "24.5 kB" } ], "peerDependencies": { diff --git a/src/cache/inmemory/__tests__/__snapshots__/writeToStore.ts.snap b/src/cache/inmemory/__tests__/__snapshots__/writeToStore.ts.snap index 894a3eab600..caa466e21f8 100644 --- a/src/cache/inmemory/__tests__/__snapshots__/writeToStore.ts.snap +++ b/src/cache/inmemory/__tests__/__snapshots__/writeToStore.ts.snap @@ -34,6 +34,70 @@ Object { } `; +exports[`writing to the store correctly merges fragment fields along multiple paths 1`] = ` +Object { + "Item:0f47f85d-8081-466e-9121-c94069a77c3e": Object { + "__typename": "Item", + "id": "0f47f85d-8081-466e-9121-c94069a77c3e", + "value": Object { + "__typename": "Container", + "value": Object { + "__typename": "Value", + "item": Object { + "__ref": "Item:6dc3530b-6731-435e-b12a-0089d0ae05ac", + }, + }, + }, + }, + "Item:6dc3530b-6731-435e-b12a-0089d0ae05ac": Object { + "__typename": "Item", + "id": "6dc3530b-6731-435e-b12a-0089d0ae05ac", + "value": Object { + "__typename": "Container", + "text": "Hello World", + "value": Object { + "__typename": "Value", + }, + }, + }, + "ROOT_QUERY": Object { + "__typename": "Query", + "item({\\"id\\":\\"123\\"})": Object { + "__ref": "Item:0f47f85d-8081-466e-9121-c94069a77c3e", + }, + }, +} +`; + +exports[`writing to the store should respect id fields added by fragments 1`] = ` +Object { + "AType:a-id": Object { + "__typename": "AType", + "b": Array [ + Object { + "__ref": "BType:b-id", + }, + ], + "id": "a-id", + }, + "BType:b-id": Object { + "__typename": "BType", + "c": Object { + "__typename": "CType", + "title": "Your experience", + "titleSize": null, + }, + "id": "b-id", + }, + "ROOT_QUERY": Object { + "__typename": "Query", + "a": Object { + "__ref": "AType:a-id", + }, + }, +} +`; + exports[`writing to the store user objects should be able to have { __typename: "Mutation" } 1`] = ` Object { "Gene:{\\"id\\":\\"SLC45A2\\"}": Object { diff --git a/src/cache/inmemory/__tests__/writeToStore.ts b/src/cache/inmemory/__tests__/writeToStore.ts index 407daa25d64..ee346b96b18 100644 --- a/src/cache/inmemory/__tests__/writeToStore.ts +++ b/src/cache/inmemory/__tests__/writeToStore.ts @@ -1402,6 +1402,145 @@ describe('writing to the store', () => { }); }); + it('correctly merges fragment fields along multiple paths', () => { + const cache = new InMemoryCache({ + typePolicies: { + Container: { + // Uncommenting this line fixes the test, but should not be necessary, + // since the Container response object in question has the same + // identity along both paths. + // merge: true, + }, + }, + }); + + const query = gql` + query Query { + item(id: "123") { + id + value { + ...ContainerFragment + } + } + } + + fragment ContainerFragment on Container { + value { + ...ValueFragment + item { + id + value { + text + } + } + } + } + + fragment ValueFragment on Value { + item { + ...ItemFragment + } + } + + fragment ItemFragment on Item { + value { + value { + __typename + } + } + } + `; + + const data = { + item: { + __typename: "Item", + id: "0f47f85d-8081-466e-9121-c94069a77c3e", + value: { + __typename: "Container", + value: { + __typename: "Value", + item: { + __typename: "Item", + id: "6dc3530b-6731-435e-b12a-0089d0ae05ac", + value: { + __typename: "Container", + text: "Hello World", + value: { + __typename: "Value" + }, + }, + }, + }, + }, + }, + }; + + cache.writeQuery({ + query, + data, + }); + + expect(cache.readQuery({ query })).toEqual(data); + expect(cache.extract()).toMatchSnapshot(); + }); + + it('should respect id fields added by fragments', () => { + const query = gql` + query ABCQuery { + __typename + a { + __typename + id + ...SharedFragment + b { + __typename + c { + __typename + title + titleSize + } + } + } + } + fragment SharedFragment on AShared { + __typename + b { + __typename + id + c { + __typename + } + } + } + `; + + const data = { + __typename: "Query", + a: { + __typename: "AType", + id: "a-id", + b: [{ + __typename: "BType", + id: "b-id", + c: { + __typename: "CType", + title: "Your experience", + titleSize: null + }, + }], + }, + }; + + const cache = new InMemoryCache({ + possibleTypes: { AShared: ["AType"] } + }); + + cache.writeQuery({ query, data }); + expect(cache.readQuery({ query })).toEqual(data); + + expect(cache.extract()).toMatchSnapshot(); + }); + it('should allow a union of objects of a different type, when overwriting a generated id with a real id', () => { const dataWithPlaceholder = { author: { diff --git a/src/cache/inmemory/writeToStore.ts b/src/cache/inmemory/writeToStore.ts index d50f54ecdec..eae95c1ff70 100644 --- a/src/cache/inmemory/writeToStore.ts +++ b/src/cache/inmemory/writeToStore.ts @@ -1,4 +1,4 @@ -import { SelectionSetNode, FieldNode } from 'graphql'; +import { SelectionSetNode, FieldNode, SelectionNode } from 'graphql'; import { invariant, InvariantError } from 'ts-invariant'; import { equal } from '@wry/equality'; @@ -39,6 +39,11 @@ export interface WriteContext extends ReadMergeModifyContext { merge(existing: T, incoming: T): T; // If true, merge functions will be called with undefined existing data. overwrite: boolean; + incomingById: Map; + }>; }; interface ProcessSelectionSetOptions { @@ -70,28 +75,75 @@ export class StoreWriter { ...variables!, }; + const context: WriteContext = { + store, + written: Object.create(null), + merge(existing: T, incoming: T) { + return merger.merge(existing, incoming) as T; + }, + variables, + varString: canonicalStringify(variables), + fragmentMap: createFragmentMap(getFragmentDefinitions(query)), + overwrite: !!overwrite, + incomingById: new Map, + }; + const ref = this.processSelectionSet({ result: result || Object.create(null), dataId, selectionSet: operationDefinition.selectionSet, mergeTree: { map: new Map }, - context: { - store, - written: Object.create(null), - merge(existing: T, incoming: T) { - return merger.merge(existing, incoming) as T; - }, - variables, - varString: canonicalStringify(variables), - fragmentMap: createFragmentMap(getFragmentDefinitions(query)), - overwrite: !!overwrite, - }, + context, }); if (!isReference(ref)) { throw new InvariantError(`Could not identify object ${JSON.stringify(result)}`); } + // So far, the store has not been modified, so now it's time to process + // context.incomingById and merge those incoming fields into context.store. + context.incomingById.forEach(({ fields, mergeTree, selections }, dataId) => { + const entityRef = makeReference(dataId); + + if (mergeTree.map.size) { + fields = this.applyMerges(mergeTree, entityRef, fields, context); + } + + if (process.env.NODE_ENV !== "production" && !context.overwrite) { + const hasSelectionSet = (storeFieldName: string) => + fieldsWithSelectionSets.has(fieldNameFromStoreName(storeFieldName)); + const fieldsWithSelectionSets = new Set(); + selections.forEach(selection => { + if (isField(selection) && selection.selectionSet) { + fieldsWithSelectionSets.add(selection.name.value); + } + }); + + const hasMergeFunction = (storeFieldName: string) => { + const childTree = mergeTree.map.get(storeFieldName); + return Boolean(childTree && childTree.info && childTree.info.merge); + }; + + Object.keys(fields).forEach(storeFieldName => { + // If a merge function was defined for this field, trust that it + // did the right thing about (not) clobbering data. If the field + // has no selection set, it's a scalar field, so it doesn't need + // a merge function (even if it's an object, like JSON data). + if (hasSelectionSet(storeFieldName) && + !hasMergeFunction(storeFieldName)) { + warnAboutDataLoss( + entityRef, + fields, + storeFieldName, + context.store, + ); + } + }); + } + + store.merge(dataId, fields); + }); + // Any IDs written explicitly to the cache will be retained as // reachable root IDs for garbage collection purposes. Although this // logic includes root IDs like ROOT_QUERY and ROOT_MUTATION, their @@ -170,9 +222,9 @@ export class StoreWriter { incomingFields.__typename = typename; } - const workSet = new Set(selectionSet.selections); + const selections = new Set(selectionSet.selections); - workSet.forEach(selection => { + selections.forEach(selection => { if (!shouldInclude(selection, context.variables)) return; if (isField(selection)) { @@ -192,9 +244,41 @@ export class StoreWriter { let incomingValue = this.processFieldValue(value, selection, context, childTree); - const childTypename = selection.selectionSet - && context.store.getFieldValue(incomingValue as StoreObject, "__typename") - || void 0; + // To determine if this field holds a child object with a merge + // function defined in its type policy (see PR #7070), we need to + // figure out the child object's __typename. + let childTypename: string | undefined; + + // The field's value can be an object that has a __typename only if + // the field has a selection set. Otherwise incomingValue is scalar. + if (selection.selectionSet) { + // We attempt to find the child __typename first in context.store, + // but the child object may not exist in the store yet, likely + // because it's being written for the first time, during this very + // call to writeToStore. Note: if incomingValue is a non-normalized + // StoreObject (not a Reference), getFieldValue will read from that + // object's properties to find its __typename. + childTypename = context.store.getFieldValue( + incomingValue as StoreObject | Reference, + "__typename", + ); + + // If the child object is being written for the first time, but + // incomingValue is a Reference, then the entity that Reference + // identifies should have an entry in context.incomingById, which + // likely contains a __typename field we can use. After all, how + // could we know the object's ID if it had no __typename? If we + // wrote data into context.store as each processSelectionSet call + // finished processing an entity object, the child object would + // already be in context.store, so we wouldn't need this extra + // check, but holding all context.store.merge calls until after + // we've finished all processSelectionSet work is cleaner and solves + // other problems, such as issue #8370. + if (!childTypename && isReference(incomingValue)) { + const info = context.incomingById.get(incomingValue.__ref); + childTypename = info && info.fields.__typename; + } + } const merge = policies.getMergeFunction( typename, @@ -257,53 +341,29 @@ export class StoreWriter { // __typename strings produced by server/schema changes, which // would otherwise be breaking changes. policies.fragmentMatches(fragment, typename, result, context.variables)) { - fragment.selectionSet.selections.forEach(workSet.add, workSet); + fragment.selectionSet.selections.forEach(selections.add, selections); } } }); if ("string" === typeof dataId) { - const entityRef = makeReference(dataId); - - if (mergeTree.map.size) { - incomingFields = this.applyMerges(mergeTree, entityRef, incomingFields, context); - } - - if (process.env.NODE_ENV !== "production" && !context.overwrite) { - const hasSelectionSet = (storeFieldName: string) => - fieldsWithSelectionSets.has(fieldNameFromStoreName(storeFieldName)); - const fieldsWithSelectionSets = new Set(); - workSet.forEach(selection => { - if (isField(selection) && selection.selectionSet) { - fieldsWithSelectionSets.add(selection.name.value); - } - }); - - const hasMergeFunction = (storeFieldName: string) => { - const childTree = mergeTree.map.get(storeFieldName); - return Boolean(childTree && childTree.info && childTree.info.merge); - }; - - Object.keys(incomingFields).forEach(storeFieldName => { - // If a merge function was defined for this field, trust that it - // did the right thing about (not) clobbering data. If the field - // has no selection set, it's a scalar field, so it doesn't need - // a merge function (even if it's an object, like JSON data). - if (hasSelectionSet(storeFieldName) && - !hasMergeFunction(storeFieldName)) { - warnAboutDataLoss( - entityRef, - incomingFields, - storeFieldName, - context.store, - ); - } + const previous = context.incomingById.get(dataId); + if (previous) { + previous.fields = context.merge(previous.fields, incomingFields); + previous.mergeTree = mergeMergeTrees(previous.mergeTree, mergeTree); + // Add all previous SelectionNode objects, rather than creating a new + // Set, since the original unmerged selections Set is not going to be + // needed again (only the merged Set). + previous.selections.forEach(selections.add, selections); + previous.selections = selections; + } else { + context.incomingById.set(dataId, { + fields: incomingFields, + mergeTree, + selections, }); } - - context.store.merge(dataId, incomingFields); - - return entityRef; + return makeReference(dataId); } return incomingFields; @@ -388,11 +448,13 @@ export class StoreWriter { }; mergeTree.map.forEach((childTree, storeFieldName) => { + const eVal = getValue(e, storeFieldName); + const iVal = getValue(i, storeFieldName); + // If we have no incoming data, leave any existing data untouched. + if (void 0 === iVal) return; if (getStorageArgs) { getStorageArgs.push(storeFieldName); } - const eVal = getValue(e, storeFieldName); - const iVal = getValue(i, storeFieldName); const aVal = this.applyMerges( childTree, eVal, @@ -444,14 +506,59 @@ function getChildMergeTree( return map.get(name)!; } +function mergeMergeTrees( + left: MergeTree | undefined, + right: MergeTree | undefined, +): MergeTree { + if (left === right || !right || mergeTreeIsEmpty(right)) return left!; + if (!left || mergeTreeIsEmpty(left)) return right; + + const info = left.info && right.info ? { + ...left.info, + ...right.info, + } : left.info || right.info; + + const needToMergeMaps = left.map.size && right.map.size; + const map = needToMergeMaps ? new Map : + left.map.size ? left.map : right.map; + + const merged = { info, map }; + + if (needToMergeMaps) { + const remainingRightKeys = new Set(right.map.keys()); + + left.map.forEach((leftTree, key) => { + merged.map.set( + key, + mergeMergeTrees(leftTree, right.map.get(key)), + ); + remainingRightKeys.delete(key); + }); + + remainingRightKeys.forEach(key => { + merged.map.set( + key, + mergeMergeTrees( + right.map.get(key), + left.map.get(key), + ), + ); + }); + } + + return merged; +} + +function mergeTreeIsEmpty(tree: MergeTree | undefined): boolean { + return !tree || !(tree.info || tree.map.size); +} + function maybeRecycleChildMergeTree( { map }: MergeTree, name: string | number, ) { const childTree = map.get(name); - if (childTree && - !childTree.info && - !childTree.map.size) { + if (childTree && mergeTreeIsEmpty(childTree)) { emptyMergeTreePool.push(childTree); map.delete(name); } From 51f2e11a798f13bba079ba4365047b7916e74058 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Mon, 14 Jun 2021 19:44:10 -0400 Subject: [PATCH 232/380] Fix optimistic mutation update test. --- src/__tests__/optimistic.ts | 55 ++++++++++++++++++++++++++++++------- 1 file changed, 45 insertions(+), 10 deletions(-) diff --git a/src/__tests__/optimistic.ts b/src/__tests__/optimistic.ts index 93d6fe6bfcf..5d4d049ff6a 100644 --- a/src/__tests__/optimistic.ts +++ b/src/__tests__/optimistic.ts @@ -1869,7 +1869,9 @@ describe('optimistic mutation results', () => { cache, link: new ApolloLink(operation => new Observable(observer => { observer.next({ - data: operation.variables.item, + data: { + addItem: operation.variables.item, + }, }); observer.complete(); })), @@ -1974,14 +1976,33 @@ describe('optimistic mutation results', () => { const optimisticItem = makeItem("optimistic"); const mutationItem = makeItem("mutation"); + const wrapReject = ( + fn: (...args: TArgs) => TResult, + ): typeof fn => { + return function () { + try { + return fn.apply(this, arguments); + } catch (e) { + reject(e); + } + }; + }; + return client.mutate({ mutation, - optimisticResponse: optimisticItem, - update(cache, mutationResult) { + optimisticResponse: { + addItem: optimisticItem, + }, + variables: { + item: mutationItem, + }, + update: wrapReject((cache, mutationResult) => { ++updateCount; if (updateCount === 1) { expect(mutationResult).toEqual({ - data: optimisticItem, + data: { + addItem: optimisticItem, + }, }); append(cache, optimisticItem); @@ -1997,6 +2018,13 @@ describe('optimistic mutation results', () => { }, ROOT_MUTATION: { __typename: "Mutation", + // Although ROOT_MUTATION field data gets removed immediately + // after the mutation finishes, it is still temporarily visible + // to the update function. + 'addItem({"item":{"__typename":"Item","text":"mutation 4"}})': { + __typename: "Item", + text: "optimistic 3", + }, }, }; @@ -2007,7 +2035,9 @@ describe('optimistic mutation results', () => { } else if (updateCount === 2) { expect(mutationResult).toEqual({ - data: mutationItem, + data: { + addItem: mutationItem, + }, }); append(cache, mutationItem); @@ -2021,6 +2051,10 @@ describe('optimistic mutation results', () => { }, ROOT_MUTATION: { __typename: "Mutation", + 'addItem({"item":{"__typename":"Item","text":"mutation 4"}})': { + __typename: "Item", + text: "mutation 4", + }, }, }; @@ -2033,12 +2067,13 @@ describe('optimistic mutation results', () => { } else { throw new Error("too many updates"); } - }, - variables: { - item: mutationItem, - }, + }), }).then(result => { - expect(result).toEqual({ data: mutationItem }); + expect(result).toEqual({ + data: { + addItem: mutationItem, + }, + }); // Only the final update function ever touched non-optimistic // cache data. From b9719a66f12482e4d4d64c92712c25d3b40f0ab9 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Mon, 14 Jun 2021 18:19:55 -0400 Subject: [PATCH 233/380] Use Object.assign for addTypenameToDocument.added. Another opportunity to apply @brainkim's suggestion from this comment: https://github.com/apollographql/apollo-client/pull/8222#discussion_r633847490 --- src/utilities/graphql/transform.ts | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/src/utilities/graphql/transform.ts b/src/utilities/graphql/transform.ts index 3b50c6e8b54..aa4649be686 100644 --- a/src/utilities/graphql/transform.ts +++ b/src/utilities/graphql/transform.ts @@ -209,7 +209,9 @@ export function removeDirectivesFromDocument( return modifiedDoc; } -export function addTypenameToDocument(doc: DocumentNode): DocumentNode { +export const addTypenameToDocument = Object.assign(function ( + doc: DocumentNode +): DocumentNode { return visit(checkDocument(doc), { SelectionSet: { enter(node, _key, parent) { @@ -259,14 +261,11 @@ export function addTypenameToDocument(doc: DocumentNode): DocumentNode { }, }, }); -} - -export interface addTypenameToDocument { - added(field: FieldNode): boolean; -} -addTypenameToDocument.added = function (field: FieldNode) { - return field === TYPENAME_FIELD; -}; +}, { + added(field: FieldNode): boolean { + return field === TYPENAME_FIELD; + }, +}); const connectionRemoveConfig = { test: (directive: DirectiveNode) => { From 44a19189bdeaf4f637f8716666d54c1496db67f0 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Thu, 27 May 2021 17:13:04 -0400 Subject: [PATCH 234/380] Experiment: use __DEV__ instead of process.env.NODE_ENV. See PR #8347 for full explanation. --- CHANGELOG.md | 5 +- config/processInvariants.ts | 45 ++++++-------- config/rollup.config.js | 23 +------- src/__tests__/__snapshots__/exports.ts.snap | 5 ++ src/cache/index.ts | 2 + src/cache/inmemory/__tests__/roundtrip.ts | 2 +- src/cache/inmemory/object-canon.ts | 4 +- src/cache/inmemory/readFromStore.ts | 2 +- src/cache/inmemory/writeToStore.ts | 4 +- src/core/ApolloClient.ts | 4 +- src/core/ObservableQuery.ts | 2 +- src/core/QueryManager.ts | 4 +- src/core/index.ts | 2 + src/react/index.ts | 2 + src/utilities/common/__tests__/environment.ts | 58 ------------------- src/utilities/common/environment.ts | 20 ------- src/utilities/common/global.ts | 15 +++++ src/utilities/common/maybe.ts | 3 + src/utilities/common/maybeDeepFreeze.ts | 4 +- src/utilities/globals/__DEV__.ts | 21 +++++++ src/utilities/globals/index.ts | 1 + src/utilities/index.ts | 2 + 22 files changed, 88 insertions(+), 142 deletions(-) delete mode 100644 src/utilities/common/__tests__/environment.ts delete mode 100644 src/utilities/common/environment.ts create mode 100644 src/utilities/common/global.ts create mode 100644 src/utilities/common/maybe.ts create mode 100644 src/utilities/globals/__DEV__.ts create mode 100644 src/utilities/globals/index.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a0780f862e..5c4907269e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,11 +27,14 @@ - `InMemoryCache` now coalesces `EntityStore` updates to guarantee only one `store.merge(id, fields)` call per `id` per cache write.
[@benjamn](https://github.com/benjamn) in [#8372](https://github.com/apollographql/apollo-client/pull/8372) -### Potentially breaking changes +### Potentially disruptive changes - To avoid retaining sensitive information from mutation root field arguments, Apollo Client v3.4 automatically clears any `ROOT_MUTATION` fields from the cache after each mutation finishes. If you need this information to remain in the cache, you can prevent the removal by passing the `keepRootFields: true` option to `client.mutate`. `ROOT_MUTATION` result data are also passed to the mutation `update` function, so we recommend obtaining the results that way, rather than using `keepRootFields: true`, if possible.
[@benjamn](https://github.com/benjamn) in [#8280](https://github.com/apollographql/apollo-client/pull/8280) +- Internally, Apollo Client now controls the execution of development-only code using the `__DEV__` global variable, rather than `process.env.NODE_ENV`. While this change should not cause any visible differences in behavior, it will increase your minified+gzip bundle size by more than 3.5kB, unless you configure your minifier to replace `__DEV__` with a `true` or `false` constant, the same way you already replace `process.env.NODE_ENV` with a string literal like `"development"` or `"production"`. For an example of configuring a Create React App project without ejecting, see this pull request for our [React Apollo reproduction template](https://github.com/apollographql/react-apollo-error-template/pull/51).
+ [@benjamn](https://github.com/benjamn) in [#8347](https://github.com/apollographql/apollo-client/pull/8347) + - Internally, Apollo Client now uses namespace syntax (e.g. `import * as React from "react"`) for imports whose types are re-exported (and thus may appear in `.d.ts` files). This change should remove any need to configure `esModuleInterop` or `allowSyntheticDefaultImports` in `tsconfig.json`, but might require updating bundler configurations that specify named exports of the `react` and `prop-types` packages, to include exports like `createContext` and `createElement` ([example](https://github.com/apollographql/apollo-client/commit/16b08e1af9ba9934041298496e167aafb128c15d)).
[@devrelm](https://github.com/devrelm) in [#7742](https://github.com/apollographql/apollo-client/pull/7742) diff --git a/config/processInvariants.ts b/config/processInvariants.ts index c719d5b71bf..0a26a01e9ee 100644 --- a/config/processInvariants.ts +++ b/config/processInvariants.ts @@ -71,7 +71,7 @@ function transform(code: string, file: string) { const node = path.node; if (isCallWithLength(node, "invariant", 1)) { - if (isNodeEnvConditional(path.parent.node)) { + if (isDEVConditional(path.parent.node)) { return; } @@ -79,22 +79,22 @@ function transform(code: string, file: string) { newArgs.push(getErrorCode(file, node)); return b.conditionalExpression( - makeNodeEnvTest(), + makeDEVExpr(), + node, b.callExpression.from({ ...node, arguments: newArgs, }), - node, ); } if (node.callee.type === "MemberExpression" && isIdWithName(node.callee.object, "invariant") && isIdWithName(node.callee.property, "log", "warn", "error")) { - if (isNodeEnvLogicalOr(path.parent.node)) { + if (isDEVLogicalAnd(path.parent.node)) { return; } - return b.logicalExpression("||", makeNodeEnvTest(), node); + return b.logicalExpression("&&", makeDEVExpr(), node); } }, @@ -102,19 +102,19 @@ function transform(code: string, file: string) { this.traverse(path); const node = path.node; if (isCallWithLength(node, "InvariantError", 0)) { - if (isNodeEnvConditional(path.parent.node)) { + if (isDEVConditional(path.parent.node)) { return; } const newArgs = [getErrorCode(file, node)]; return b.conditionalExpression( - makeNodeEnvTest(), + makeDEVExpr(), + node, b.newExpression.from({ ...node, arguments: newArgs, }), - node, ); } } @@ -137,32 +137,21 @@ function isCallWithLength( node.arguments.length > length; } -function isNodeEnvConditional(node: Node) { +function isDEVConditional(node: Node) { return n.ConditionalExpression.check(node) && - isNodeEnvExpr(node.test); + isDEVExpr(node.test); } -function isNodeEnvLogicalOr(node: Node) { +function isDEVLogicalAnd(node: Node) { return n.LogicalExpression.check(node) && - node.operator === "||" && - isNodeEnvExpr(node.left); + node.operator === "&&" && + isDEVExpr(node.left); } -function makeNodeEnvTest() { - return b.binaryExpression( - "===", - b.memberExpression( - b.memberExpression( - b.identifier("process"), - b.identifier("env") - ), - b.identifier("NODE_ENV"), - ), - b.stringLiteral("production"), - ); +function makeDEVExpr() { + return b.identifier("__DEV__"); } -const referenceNodeEnvExpr = makeNodeEnvTest(); -function isNodeEnvExpr(node: Node) { - return recast.types.astNodesAreEquivalent(node, referenceNodeEnvExpr); +function isDEVExpr(node: Node) { + return isIdWithName(node, "__DEV__"); } diff --git a/config/rollup.config.js b/config/rollup.config.js index abe73fabcd2..511c832acfc 100644 --- a/config/rollup.config.js +++ b/config/rollup.config.js @@ -71,7 +71,7 @@ function prepareCJSMinified(input) { compress: { toplevel: true, global_defs: { - '@process.env.NODE_ENV': JSON.stringify('production'), + '@__DEV__': 'false', }, }, }), @@ -97,27 +97,6 @@ function prepareBundle({ exports: 'named', interop: 'esModule', externalLiveBindings: false, - // In Node.js, where these CommonJS bundles are most commonly used, - // the expression process.env.NODE_ENV can be very expensive to - // evaluate, because process.env is a wrapper for the actual OS - // environment, and lookups are not cached. We need to preserve the - // syntax of process.env.NODE_ENV expressions for dead code - // elimination to work properly, but we can apply our own caching by - // shadowing the global process variable with a stripped-down object - // that saves a snapshot of process.env.NODE_ENV when the bundle is - // first evaluated. If we ever need other process properties, we can - // add more stubs here. - intro: '!(function (process) {', - outro: [ - '}).call(this, {', - ' env: {', - ' NODE_ENV: typeof process === "object"', - ' && process.env', - ' && process.env.NODE_ENV', - ' || "development"', - ' }', - '});', - ].join('\n'), }, plugins: [ extensions ? nodeResolve({ extensions }) : nodeResolve(), diff --git a/src/__tests__/__snapshots__/exports.ts.snap b/src/__tests__/__snapshots__/exports.ts.snap index 6e7de8a84be..2d668f1709a 100644 --- a/src/__tests__/__snapshots__/exports.ts.snap +++ b/src/__tests__/__snapshots__/exports.ts.snap @@ -16,6 +16,7 @@ Array [ "NetworkStatus", "Observable", "ObservableQuery", + "__DEV__", "applyNextFetchPolicy", "checkFetcher", "concat", @@ -68,6 +69,7 @@ Array [ "InMemoryCache", "MissingFieldError", "Policies", + "__DEV__", "cacheSlot", "canonicalStringify", "defaultDataIdFromObject", @@ -91,6 +93,7 @@ Array [ "NetworkStatus", "Observable", "ObservableQuery", + "__DEV__", "applyNextFetchPolicy", "checkFetcher", "concat", @@ -226,6 +229,7 @@ Array [ "ApolloConsumer", "ApolloProvider", "DocumentType", + "__DEV__", "getApolloContext", "operationName", "parser", @@ -322,6 +326,7 @@ Array [ "Concast", "DeepMerger", "Observable", + "__DEV__", "addTypenameToDocument", "argumentsObjectFromField", "asyncMap", diff --git a/src/cache/index.ts b/src/cache/index.ts index 5e3f221a44f..220bfdf378a 100644 --- a/src/cache/index.ts +++ b/src/cache/index.ts @@ -1,3 +1,5 @@ +export { __DEV__ } from "../utilities"; + export { Transaction, ApolloCache } from './core/cache'; export { Cache } from './core/types/Cache'; export { DataProxy } from './core/types/DataProxy'; diff --git a/src/cache/inmemory/__tests__/roundtrip.ts b/src/cache/inmemory/__tests__/roundtrip.ts index 8facc6cbbf5..8604a09a883 100644 --- a/src/cache/inmemory/__tests__/roundtrip.ts +++ b/src/cache/inmemory/__tests__/roundtrip.ts @@ -57,7 +57,7 @@ function storeRoundtrip(query: DocumentNode, result: any, variables = {}) { const immutableResult = readQueryFromStore(reader, readOptions); expect(immutableResult).toEqual(reconstructedResult); expect(readQueryFromStore(reader, readOptions)).toBe(immutableResult); - if (process.env.NODE_ENV !== 'production') { + if (__DEV__) { try { // Note: this illegal assignment will only throw in strict mode, but that's // safe to assume because this test file is a module. diff --git a/src/cache/inmemory/object-canon.ts b/src/cache/inmemory/object-canon.ts index e436d74d7dd..22f39b4243d 100644 --- a/src/cache/inmemory/object-canon.ts +++ b/src/cache/inmemory/object-canon.ts @@ -118,7 +118,7 @@ export class ObjectCanon { // Since canonical arrays may be shared widely between // unrelated consumers, it's important to regard them as // immutable, even if they are not frozen in production. - if (process.env.NODE_ENV !== "production") { + if (__DEV__) { Object.freeze(array); } } @@ -154,7 +154,7 @@ export class ObjectCanon { // Since canonical objects may be shared widely between // unrelated consumers, it's important to regard them as // immutable, even if they are not frozen in production. - if (process.env.NODE_ENV !== "production") { + if (__DEV__) { Object.freeze(obj); } } diff --git a/src/cache/inmemory/readFromStore.ts b/src/cache/inmemory/readFromStore.ts index dbce333b190..7fcfa59b343 100644 --- a/src/cache/inmemory/readFromStore.ts +++ b/src/cache/inmemory/readFromStore.ts @@ -477,7 +477,7 @@ export class StoreReader { }), i); } - if (process.env.NODE_ENV !== 'production') { + if (__DEV__) { assertSelectionSetForIdValue(context.store, field, item); } diff --git a/src/cache/inmemory/writeToStore.ts b/src/cache/inmemory/writeToStore.ts index eae95c1ff70..83d94f4ce7e 100644 --- a/src/cache/inmemory/writeToStore.ts +++ b/src/cache/inmemory/writeToStore.ts @@ -109,7 +109,7 @@ export class StoreWriter { fields = this.applyMerges(mergeTree, entityRef, fields, context); } - if (process.env.NODE_ENV !== "production" && !context.overwrite) { + if (__DEV__ && !context.overwrite) { const hasSelectionSet = (storeFieldName: string) => fieldsWithSelectionSets.has(fieldNameFromStoreName(storeFieldName)); const fieldsWithSelectionSets = new Set(); @@ -379,7 +379,7 @@ export class StoreWriter { // In development, we need to clone scalar values so that they can be // safely frozen with maybeDeepFreeze in readFromStore.ts. In production, // it's cheaper to store the scalar values directly in the cache. - return process.env.NODE_ENV === 'production' ? value : cloneDeep(value); + return __DEV__ ? cloneDeep(value) : value; } if (Array.isArray(value)) { diff --git a/src/core/ApolloClient.ts b/src/core/ApolloClient.ts index 9c63094c9bd..17e068ed8b2 100644 --- a/src/core/ApolloClient.ts +++ b/src/core/ApolloClient.ts @@ -151,7 +151,7 @@ export class ApolloClient implements DataProxy { // devtools, but disable them by default in production. typeof window === 'object' && !(window as any).__APOLLO_CLIENT__ && - process.env.NODE_ENV !== 'production', + __DEV__, queryDeduplication = true, defaultOptions, assumeImmutableResults = false, @@ -205,7 +205,7 @@ export class ApolloClient implements DataProxy { /** * Suggest installing the devtools for developers who don't have them */ - if (!hasSuggestedDevtools && process.env.NODE_ENV !== 'production') { + if (!hasSuggestedDevtools && __DEV__) { hasSuggestedDevtools = true; if ( typeof window !== 'undefined' && diff --git a/src/core/ObservableQuery.ts b/src/core/ObservableQuery.ts index 4cde4a5f42d..36ad810e288 100644 --- a/src/core/ObservableQuery.ts +++ b/src/core/ObservableQuery.ts @@ -306,7 +306,7 @@ export class ObservableQuery< const { updateQuery } = fetchMoreOptions; if (updateQuery) { - if (process.env.NODE_ENV !== "production" && + if (__DEV__ && !warnedAboutUpdateQuery) { invariant.warn( `The updateQuery callback for fetchMore is deprecated, and will be removed diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index 47769b97acf..7ccc02fc1e4 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -802,7 +802,7 @@ export class QueryManager { }); } - if (process.env.NODE_ENV !== "production" && queryNamesAndDocs.size) { + if (__DEV__ && queryNamesAndDocs.size) { queryNamesAndDocs.forEach((included, nameOrDoc) => { if (!included) { invariant.warn(`Unknown query ${ @@ -1343,7 +1343,7 @@ export class QueryManager { ) => { const data = diff.result; - if (process.env.NODE_ENV !== 'production' && + if (__DEV__ && isNonEmptyArray(diff.missing) && !equal(data, {}) && !returnPartialData) { diff --git a/src/core/index.ts b/src/core/index.ts index 11cfea4e100..4ea4873e0d1 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -1,5 +1,7 @@ /* Core */ +export { __DEV__ } from "../utilities"; + export { ApolloClient, ApolloClientOptions, diff --git a/src/react/index.ts b/src/react/index.ts index a423c3c75a1..3d9c5583b9e 100644 --- a/src/react/index.ts +++ b/src/react/index.ts @@ -1,3 +1,5 @@ +export { __DEV__ } from "../utilities"; + export { ApolloProvider, ApolloConsumer, diff --git a/src/utilities/common/__tests__/environment.ts b/src/utilities/common/__tests__/environment.ts deleted file mode 100644 index f9fe25362db..00000000000 --- a/src/utilities/common/__tests__/environment.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { isEnv, isDevelopment, isTest } from '../environment'; - -describe('environment', () => { - let keepEnv: string | undefined; - - beforeEach(() => { - // save the NODE_ENV - keepEnv = process.env.NODE_ENV; - }); - - afterEach(() => { - // restore the NODE_ENV - process.env.NODE_ENV = keepEnv; - }); - - describe('isEnv', () => { - it(`should match when there's a value`, () => { - ['production', 'development', 'test'].forEach(env => { - process.env.NODE_ENV = env; - expect(isEnv(env)).toBe(true); - }); - }); - - it(`should treat no proces.env.NODE_ENV as it'd be in development`, () => { - delete process.env.NODE_ENV; - expect(isEnv('development')).toBe(true); - }); - }); - - describe('isTest', () => { - it('should return true if in test', () => { - process.env.NODE_ENV = 'test'; - expect(isTest()).toBe(true); - }); - - it('should return true if not in test', () => { - process.env.NODE_ENV = 'development'; - expect(!isTest()).toBe(true); - }); - }); - - describe('isDevelopment', () => { - it('should return true if in development', () => { - process.env.NODE_ENV = 'development'; - expect(isDevelopment()).toBe(true); - }); - - it('should return true if not in development and environment is defined', () => { - process.env.NODE_ENV = 'test'; - expect(!isDevelopment()).toBe(true); - }); - - it('should make development as the default environment', () => { - delete process.env.NODE_ENV; - expect(isDevelopment()).toBe(true); - }); - }); -}); diff --git a/src/utilities/common/environment.ts b/src/utilities/common/environment.ts deleted file mode 100644 index 21d61f5f122..00000000000 --- a/src/utilities/common/environment.ts +++ /dev/null @@ -1,20 +0,0 @@ -export function getEnv(): string | undefined { - if (typeof process !== 'undefined' && process.env.NODE_ENV) { - return process.env.NODE_ENV; - } - - // default environment - return 'development'; -} - -export function isEnv(env: string): boolean { - return getEnv() === env; -} - -export function isDevelopment(): boolean { - return isEnv('development') === true; -} - -export function isTest(): boolean { - return isEnv('test') === true; -} diff --git a/src/utilities/common/global.ts b/src/utilities/common/global.ts new file mode 100644 index 00000000000..6464e27a3ca --- /dev/null +++ b/src/utilities/common/global.ts @@ -0,0 +1,15 @@ +import { maybe } from "./maybe"; + +declare global { + const __DEV__: boolean | undefined; +} + +export default ( + maybe(() => globalThis) || + maybe(() => window) || + maybe(() => self) || + maybe(() => global) || + maybe(() => Function("return this")()) +) as typeof globalThis & { + __DEV__: typeof __DEV__; +}; diff --git a/src/utilities/common/maybe.ts b/src/utilities/common/maybe.ts new file mode 100644 index 00000000000..dd9d4c90d80 --- /dev/null +++ b/src/utilities/common/maybe.ts @@ -0,0 +1,3 @@ +export function maybe(thunk: () => T): T | undefined { + try { return thunk() } catch {} +} diff --git a/src/utilities/common/maybeDeepFreeze.ts b/src/utilities/common/maybeDeepFreeze.ts index f8e9d483d1b..cf9a948b84b 100644 --- a/src/utilities/common/maybeDeepFreeze.ts +++ b/src/utilities/common/maybeDeepFreeze.ts @@ -1,4 +1,4 @@ -import { isDevelopment, isTest } from './environment'; +import '../globals'; // For __DEV__ import { isNonNullObject } from './objects'; function deepFreeze(value: any) { @@ -15,7 +15,7 @@ function deepFreeze(value: any) { } export function maybeDeepFreeze(obj: T): T { - if (process.env.NODE_ENV !== "production" && (isDevelopment() || isTest())) { + if (__DEV__) { deepFreeze(obj); } return obj; diff --git a/src/utilities/globals/__DEV__.ts b/src/utilities/globals/__DEV__.ts new file mode 100644 index 00000000000..2ff0f7aedfc --- /dev/null +++ b/src/utilities/globals/__DEV__.ts @@ -0,0 +1,21 @@ +import global from "../common/global"; +import { maybe } from "../common/maybe"; + +function getDEV() { + try { + return Boolean(__DEV__); + } catch { + Object.defineProperty(global, "__DEV__", { + // In a buildless browser environment, maybe(() => process.env.NODE_ENV) + // evaluates as undefined, so __DEV__ becomes true by default, but can be + // initialized to false instead by a script/module that runs earlier. + value: maybe(() => process.env.NODE_ENV) !== "production", + enumerable: false, + configurable: true, + writable: true, + }); + return global.__DEV__; + } +} + +export default getDEV(); diff --git a/src/utilities/globals/index.ts b/src/utilities/globals/index.ts new file mode 100644 index 00000000000..e7d518c3748 --- /dev/null +++ b/src/utilities/globals/index.ts @@ -0,0 +1 @@ +export { default as __DEV__ } from "./__DEV__"; diff --git a/src/utilities/index.ts b/src/utilities/index.ts index b5d8b3a51db..419717c7d43 100644 --- a/src/utilities/index.ts +++ b/src/utilities/index.ts @@ -1,3 +1,5 @@ +export { __DEV__ } from "./globals"; + export { DirectiveInfo, InclusionDirectives, From fcb2c7d34f0da8c5a41da162ee0560800cf4eb66 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 15 Jun 2021 10:29:14 -0400 Subject: [PATCH 235/380] Temporarily polyfill global.process while importing graphql package. As explained in https://github.com/apollographql/apollo-client/pull/8347#issue-661254832 We can revert this commit when no supported version of the `graphql` package still uses process.env.NODE_ENV. --- src/utilities/globals/graphql.ts | 14 +++++++++ src/utilities/globals/index.ts | 13 ++++++++- src/utilities/globals/process.ts | 49 ++++++++++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 src/utilities/globals/graphql.ts create mode 100644 src/utilities/globals/process.ts diff --git a/src/utilities/globals/graphql.ts b/src/utilities/globals/graphql.ts new file mode 100644 index 00000000000..14acd0d021d --- /dev/null +++ b/src/utilities/globals/graphql.ts @@ -0,0 +1,14 @@ +// The ordering of these imports is important, because it ensures the temporary +// process.env.NODE_ENV polyfill is defined globally (if necessary) before we +// import { isType } from 'graphql'. The instanceOf function that we really care +// about (the one that uses process.env.NODE_ENV) is not exported from the +// top-level graphql package, but isType uses instanceOf, and is exported. +import { undo } from './process'; +import { isType } from 'graphql'; + +export function applyFixes() { + // Calling isType here just to make sure it won't be tree-shaken away, + // provided applyFixes is called elsewhere. + isType(null); + return undo(); +} diff --git a/src/utilities/globals/index.ts b/src/utilities/globals/index.ts index e7d518c3748..426dfd68a9f 100644 --- a/src/utilities/globals/index.ts +++ b/src/utilities/globals/index.ts @@ -1 +1,12 @@ -export { default as __DEV__ } from "./__DEV__"; +// Just in case the graphql package switches from process.env.NODE_ENV to +// __DEV__, make sure __DEV__ is polyfilled before importing graphql. +import { default as __DEV__ } from "./__DEV__"; +export { __DEV__ } + +// Import graphql/jsutils/instanceOf safely, working around its unchecked usage +// of process.env.NODE_ENV and https://github.com/graphql/graphql-js/pull/2894. +import { applyFixes } from "./graphql"; + +// Synchronously undo the global process.env.NODE_ENV polyfill that we created +// temporarily while importing the offending graphql/jsutils/instanceOf module. +applyFixes(); diff --git a/src/utilities/globals/process.ts b/src/utilities/globals/process.ts new file mode 100644 index 00000000000..765ef06ed89 --- /dev/null +++ b/src/utilities/globals/process.ts @@ -0,0 +1,49 @@ +import { maybe } from "../common/maybe"; +import global from "../common/global"; + +const originalProcessDescriptor = global && + Object.getOwnPropertyDescriptor(global, "process"); + +let needToUndo = false; + +// The process.env.NODE_ENV expression can be provided in two different ways: +// either process.env.NODE_ENV works because a literal globalThis.process object +// exists, or it works because a build step replaces the process.env.NODE_ENV +// expression with a string literal like "production" or "development" at build +// time. This maybe(() => process.env.NODE_ENV) expression works in either case, +// because it preserves the syntax of the process.env.NODE_ENV expression, +// allowing the replacement strategy to produce maybe(() => "production"), and +// defaulting to undefined if NODE_ENV is unavailable for any reason. +if (!maybe(() => process.env.NODE_ENV) && + // Don't try to define global.process unless it will succeed. + (!originalProcessDescriptor || originalProcessDescriptor.configurable)) { + Object.defineProperty(global, "process", { + value: { + env: { + // This default needs to be "production" instead of "development", to + // avoid the problem https://github.com/graphql/graphql-js/pull/2894 + // will eventually solve, once merged and released. + NODE_ENV: "production", + } + }, + // Let anyone else change global.process as they see fit, but hide it from + // Object.keys(global) enumeration. + configurable: true, + enumerable: false, + writable: true, + }); + + // We expect this to be true now. + needToUndo = "process" in global; +} + +export function undo() { + if (needToUndo) { + if (originalProcessDescriptor) { + Object.defineProperty(global, "process", originalProcessDescriptor); + } else { + delete (global as any).process; + } + needToUndo = false; + } +} From 8f687628e6e53a63e310275bc83512ff3726075e Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Mon, 7 Jun 2021 17:43:05 -0400 Subject: [PATCH 236/380] Warn about usage of __DEV__ polyfill. https://github.com/apollographql/apollo-client/pull/8347#issuecomment-855235937 In Node.js, process.env is a reliable global API, and bundle sizes are less relevant than for browser applications, so it's not as helpful to warn about __DEV__ minification when running in Node.js. --- src/utilities/globals/__DEV__.ts | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/src/utilities/globals/__DEV__.ts b/src/utilities/globals/__DEV__.ts index 2ff0f7aedfc..307d39550cd 100644 --- a/src/utilities/globals/__DEV__.ts +++ b/src/utilities/globals/__DEV__.ts @@ -1,3 +1,5 @@ +import { invariant } from "ts-invariant"; + import global from "../common/global"; import { maybe } from "../common/maybe"; @@ -5,15 +7,37 @@ function getDEV() { try { return Boolean(__DEV__); } catch { + const maybeNodeEnv = maybe(() => process.env.NODE_ENV); + const runningInNode = !!maybe(() => process.versions.node); + Object.defineProperty(global, "__DEV__", { // In a buildless browser environment, maybe(() => process.env.NODE_ENV) // evaluates as undefined, so __DEV__ becomes true by default, but can be // initialized to false instead by a script/module that runs earlier. - value: maybe(() => process.env.NODE_ENV) !== "production", + value: maybeNodeEnv !== "production", enumerable: false, configurable: true, writable: true, }); + + if (maybeNodeEnv && !runningInNode) { + // Issue this warning only if the developer has some sort of configuration + // that provides process.env.NODE_ENV already (and we're not running in + // Node.js), since they probably have an existing minifier configuration + // (e.g. using terser's global_defs option or webpack's DefinePlugin) that + // can be adapted to replace __DEV__ with false, similar to replacing + // process.env.NODE_ENV with a string literal. + invariant.log([ + "Apollo Client has provided a default value for the __DEV__ constant " + + `(${global.__DEV__}), but you may be able to reduce production bundle ` + + "sizes by configuring your JavaScript minifier to replace __DEV__ with " + + "false, allowing development-only code to be stripped from the bundle.", + "", + "For more information about the switch to __DEV__, see " + + "https://github.com/apollographql/apollo-client/pull/8347.", + ].join("\n")); + } + return global.__DEV__; } } From 56b1cb57a445181d3d6b3235bf7d90512f58fbb6 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 15 Jun 2021 10:13:36 -0400 Subject: [PATCH 237/380] Increase bundlesize limit from 24.5kB to 24.7kB. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 52603d953ff..9ef5c499239 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ { "name": "apollo-client", "path": "./dist/apollo-client.cjs.min.js", - "maxSize": "24.5 kB" + "maxSize": "24.7 kB" } ], "peerDependencies": { From 5de6edbf9aedac92c500b3693a69237b88b6f58f Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 15 Jun 2021 15:10:49 -0400 Subject: [PATCH 238/380] Bump @apollo/client npm version to 3.4.0-rc.7. --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6afac887719..12a554f284b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-rc.6", + "version": "3.4.0-rc.7", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 9ef5c499239..804c66a6551 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-rc.6", + "version": "3.4.0-rc.7", "description": "A fully-featured caching GraphQL client.", "private": true, "keywords": [ From f01d48d2eea47b1073ef20edb46e315b284e8889 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 15 Jun 2021 15:46:46 -0400 Subject: [PATCH 239/380] Use ts-invariant/process to polyfill global.process temporarily. This partially reverts commit fcb2c7d34f0da8c5a41da162ee0560800cf4eb66, which did not achieve its goals, because ECMAScript module CDNs like https://esm.run take the liberty of hoisting package imports to the very beginning of the bundle, before any polyfill set-up code has a chance to run: https://cdn.jsdelivr.net/npm/@apollo/client@3.4.0-rc.7/core/+esm In this context, it's important that `ts-invariant/process` is a separate package, rather than a local module like `./process,` so bundlers will preserve the relative ordering of imports from `ts-invariant/process` (which needs to load first) and `graphql` (which needs to load with a usable `process.env.NODE_ENV` polyfill, provided temporarily by `ts-invariant/process`). --- package-lock.json | 13 ++------- package.json | 2 +- src/utilities/globals/graphql.ts | 6 ++-- src/utilities/globals/index.ts | 5 ++-- src/utilities/globals/process.ts | 49 -------------------------------- src/utilities/index.ts | 3 +- 6 files changed, 12 insertions(+), 66 deletions(-) delete mode 100644 src/utilities/globals/process.ts diff --git a/package-lock.json b/package-lock.json index 12a554f284b..d2df52a27b9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9090,18 +9090,11 @@ } }, "ts-invariant": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/ts-invariant/-/ts-invariant-0.7.3.tgz", - "integrity": "sha512-UWDDeovyUTIMWj+45g5nhnl+8oo+GhxL5leTaHn5c8FkQWfh8v66gccLd2/YzVmV5hoQUjCEjhrXnQqVDJdvKA==", + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/ts-invariant/-/ts-invariant-0.8.0.tgz", + "integrity": "sha512-MiwWNUhiYX1zb5eYpmdAd2BwxhjLxqOjbdbR7t1P0N9iaj7SPPPUOQq6nmqT0+8DryIP0e/8racDANjWFCpaxQ==", "requires": { "tslib": "^2.1.0" - }, - "dependencies": { - "tslib": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.1.0.tgz", - "integrity": "sha512-hcVC3wYEziELGGmEEXue7D75zbwIIVUMWAVbHItGPx0ziyXxrOMQx4rQEVEV45Ut/1IotuEvwqPopzIOkDMf0A==" - } } }, "ts-jest": { diff --git a/package.json b/package.json index 804c66a6551..6633101b2c3 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,7 @@ "optimism": "^0.16.1", "prop-types": "^15.7.2", "symbol-observable": "^4.0.0", - "ts-invariant": "^0.7.3", + "ts-invariant": "^0.8.0", "tslib": "^2.1.0", "zen-observable-ts": "^1.0.0" }, diff --git a/src/utilities/globals/graphql.ts b/src/utilities/globals/graphql.ts index 14acd0d021d..c6ab391797f 100644 --- a/src/utilities/globals/graphql.ts +++ b/src/utilities/globals/graphql.ts @@ -3,12 +3,12 @@ // import { isType } from 'graphql'. The instanceOf function that we really care // about (the one that uses process.env.NODE_ENV) is not exported from the // top-level graphql package, but isType uses instanceOf, and is exported. -import { undo } from './process'; +import { remove } from 'ts-invariant/process'; import { isType } from 'graphql'; -export function applyFixes() { +export function removeTemporaryGlobals() { // Calling isType here just to make sure it won't be tree-shaken away, // provided applyFixes is called elsewhere. isType(null); - return undo(); + return remove(); } diff --git a/src/utilities/globals/index.ts b/src/utilities/globals/index.ts index 426dfd68a9f..0b1cd926db7 100644 --- a/src/utilities/globals/index.ts +++ b/src/utilities/globals/index.ts @@ -5,8 +5,9 @@ export { __DEV__ } // Import graphql/jsutils/instanceOf safely, working around its unchecked usage // of process.env.NODE_ENV and https://github.com/graphql/graphql-js/pull/2894. -import { applyFixes } from "./graphql"; +import { removeTemporaryGlobals } from "./graphql"; +export { removeTemporaryGlobals } // Synchronously undo the global process.env.NODE_ENV polyfill that we created // temporarily while importing the offending graphql/jsutils/instanceOf module. -applyFixes(); +removeTemporaryGlobals(); diff --git a/src/utilities/globals/process.ts b/src/utilities/globals/process.ts deleted file mode 100644 index 765ef06ed89..00000000000 --- a/src/utilities/globals/process.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { maybe } from "../common/maybe"; -import global from "../common/global"; - -const originalProcessDescriptor = global && - Object.getOwnPropertyDescriptor(global, "process"); - -let needToUndo = false; - -// The process.env.NODE_ENV expression can be provided in two different ways: -// either process.env.NODE_ENV works because a literal globalThis.process object -// exists, or it works because a build step replaces the process.env.NODE_ENV -// expression with a string literal like "production" or "development" at build -// time. This maybe(() => process.env.NODE_ENV) expression works in either case, -// because it preserves the syntax of the process.env.NODE_ENV expression, -// allowing the replacement strategy to produce maybe(() => "production"), and -// defaulting to undefined if NODE_ENV is unavailable for any reason. -if (!maybe(() => process.env.NODE_ENV) && - // Don't try to define global.process unless it will succeed. - (!originalProcessDescriptor || originalProcessDescriptor.configurable)) { - Object.defineProperty(global, "process", { - value: { - env: { - // This default needs to be "production" instead of "development", to - // avoid the problem https://github.com/graphql/graphql-js/pull/2894 - // will eventually solve, once merged and released. - NODE_ENV: "production", - } - }, - // Let anyone else change global.process as they see fit, but hide it from - // Object.keys(global) enumeration. - configurable: true, - enumerable: false, - writable: true, - }); - - // We expect this to be true now. - needToUndo = "process" in global; -} - -export function undo() { - if (needToUndo) { - if (originalProcessDescriptor) { - Object.defineProperty(global, "process", originalProcessDescriptor); - } else { - delete (global as any).process; - } - needToUndo = false; - } -} diff --git a/src/utilities/index.ts b/src/utilities/index.ts index 419717c7d43..06bb6fcce4f 100644 --- a/src/utilities/index.ts +++ b/src/utilities/index.ts @@ -1,4 +1,5 @@ -export { __DEV__ } from "./globals"; +import { __DEV__ } from "./globals"; +export { __DEV__ } export { DirectiveInfo, From b24258c9c62bcea795bf0dcd130581b6f3f1bf62 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Wed, 16 Jun 2021 12:39:53 -0400 Subject: [PATCH 240/380] Remove annoying/imperfect warning about __DEV__ minification. https://github.com/apollographql/apollo-client/pull/8347#discussion_r652865271 --- src/utilities/globals/__DEV__.ts | 26 +------------------------- 1 file changed, 1 insertion(+), 25 deletions(-) diff --git a/src/utilities/globals/__DEV__.ts b/src/utilities/globals/__DEV__.ts index 307d39550cd..2ff0f7aedfc 100644 --- a/src/utilities/globals/__DEV__.ts +++ b/src/utilities/globals/__DEV__.ts @@ -1,5 +1,3 @@ -import { invariant } from "ts-invariant"; - import global from "../common/global"; import { maybe } from "../common/maybe"; @@ -7,37 +5,15 @@ function getDEV() { try { return Boolean(__DEV__); } catch { - const maybeNodeEnv = maybe(() => process.env.NODE_ENV); - const runningInNode = !!maybe(() => process.versions.node); - Object.defineProperty(global, "__DEV__", { // In a buildless browser environment, maybe(() => process.env.NODE_ENV) // evaluates as undefined, so __DEV__ becomes true by default, but can be // initialized to false instead by a script/module that runs earlier. - value: maybeNodeEnv !== "production", + value: maybe(() => process.env.NODE_ENV) !== "production", enumerable: false, configurable: true, writable: true, }); - - if (maybeNodeEnv && !runningInNode) { - // Issue this warning only if the developer has some sort of configuration - // that provides process.env.NODE_ENV already (and we're not running in - // Node.js), since they probably have an existing minifier configuration - // (e.g. using terser's global_defs option or webpack's DefinePlugin) that - // can be adapted to replace __DEV__ with false, similar to replacing - // process.env.NODE_ENV with a string literal. - invariant.log([ - "Apollo Client has provided a default value for the __DEV__ constant " + - `(${global.__DEV__}), but you may be able to reduce production bundle ` + - "sizes by configuring your JavaScript minifier to replace __DEV__ with " + - "false, allowing development-only code to be stripped from the bundle.", - "", - "For more information about the switch to __DEV__, see " + - "https://github.com/apollographql/apollo-client/pull/8347.", - ].join("\n")); - } - return global.__DEV__; } } From 54528fad347d22de36abcd1052a8fe3ea05b7aec Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Wed, 16 Jun 2021 12:47:39 -0400 Subject: [PATCH 241/380] Bump @apollo/client npm version to 3.4.0-rc.8. --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index d2df52a27b9..c3360a55bfb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-rc.7", + "version": "3.4.0-rc.8", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 6633101b2c3..e510d158080 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-rc.7", + "version": "3.4.0-rc.8", "description": "A fully-featured caching GraphQL client.", "private": true, "keywords": [ From 57ead4ce6bb20a52e2104eb40dd4025cb34af3ae Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Wed, 16 Jun 2021 13:34:28 -0400 Subject: [PATCH 242/380] Bump ts-invariant to version 0.8.1. https://github.com/apollographql/invariant-packages/commit/7df55dd233645730481f76e3c1ba0f29f3ab7d3b --- package-lock.json | 6 +++--- package.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index c3360a55bfb..d70f4b15e04 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9090,9 +9090,9 @@ } }, "ts-invariant": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/ts-invariant/-/ts-invariant-0.8.0.tgz", - "integrity": "sha512-MiwWNUhiYX1zb5eYpmdAd2BwxhjLxqOjbdbR7t1P0N9iaj7SPPPUOQq6nmqT0+8DryIP0e/8racDANjWFCpaxQ==", + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/ts-invariant/-/ts-invariant-0.8.1.tgz", + "integrity": "sha512-5LokcU57cj7D3Nl48yrZaa7E7SSBAAdxtcwfIKLfMDEaPnwhPDcBeypfugV6jFkoU0J3KuOhxMX3wpDN85W8yA==", "requires": { "tslib": "^2.1.0" } diff --git a/package.json b/package.json index e510d158080..5f812553417 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,7 @@ "optimism": "^0.16.1", "prop-types": "^15.7.2", "symbol-observable": "^4.0.0", - "ts-invariant": "^0.8.0", + "ts-invariant": "^0.8.1", "tslib": "^2.1.0", "zen-observable-ts": "^1.0.0" }, From 75f0a1721f57251c5379a9ccd7950efe26c03b2f Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Wed, 16 Jun 2021 13:37:48 -0400 Subject: [PATCH 243/380] Bump @apollo/client npm version to 3.4.0-rc.9. --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index d70f4b15e04..976bf78b4b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-rc.8", + "version": "3.4.0-rc.9", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 5f812553417..fadb4ad07fa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-rc.8", + "version": "3.4.0-rc.9", "description": "A fully-featured caching GraphQL client.", "private": true, "keywords": [ From 232cf837f9bb539fdad60f2dbe5bd4410a789de1 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Wed, 16 Jun 2021 13:50:54 -0400 Subject: [PATCH 244/380] Bump ts-invariant to version 0.8.2. https://github.com/apollographql/invariant-packages/commit/662564c768a92b6495a5c83903a4217cae821808 --- package-lock.json | 6 +++--- package.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 976bf78b4b4..9760dedba06 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9090,9 +9090,9 @@ } }, "ts-invariant": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/ts-invariant/-/ts-invariant-0.8.1.tgz", - "integrity": "sha512-5LokcU57cj7D3Nl48yrZaa7E7SSBAAdxtcwfIKLfMDEaPnwhPDcBeypfugV6jFkoU0J3KuOhxMX3wpDN85W8yA==", + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/ts-invariant/-/ts-invariant-0.8.2.tgz", + "integrity": "sha512-VI1ZSMW8soizP5dU8DsMbj/TncHf7bIUqavuE7FTeYeQat454HHurJ8wbfCnVWcDOMkyiBUWOW2ytew3xUxlRw==", "requires": { "tslib": "^2.1.0" } diff --git a/package.json b/package.json index fadb4ad07fa..f16d9aa2ea0 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,7 @@ "optimism": "^0.16.1", "prop-types": "^15.7.2", "symbol-observable": "^4.0.0", - "ts-invariant": "^0.8.1", + "ts-invariant": "^0.8.2", "tslib": "^2.1.0", "zen-observable-ts": "^1.0.0" }, From 51d963f56d59a751469502d02aa3d586cdbe0f72 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Wed, 16 Jun 2021 15:31:26 -0400 Subject: [PATCH 245/380] Export DEV rather than __DEV__ to help find/replace transforms succeed. https://github.com/apollographql/apollo-client/pull/8347#issuecomment-862650785 --- src/__tests__/__snapshots__/exports.ts.snap | 10 +++++----- src/cache/index.ts | 2 +- src/core/index.ts | 2 +- src/react/index.ts | 2 +- src/utilities/globals/index.ts | 4 ++-- src/utilities/index.ts | 4 ++-- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/__tests__/__snapshots__/exports.ts.snap b/src/__tests__/__snapshots__/exports.ts.snap index 2d668f1709a..862d137bc13 100644 --- a/src/__tests__/__snapshots__/exports.ts.snap +++ b/src/__tests__/__snapshots__/exports.ts.snap @@ -9,6 +9,7 @@ Array [ "ApolloLink", "ApolloProvider", "Cache", + "DEV", "DocumentType", "HttpLink", "InMemoryCache", @@ -16,7 +17,6 @@ Array [ "NetworkStatus", "Observable", "ObservableQuery", - "__DEV__", "applyNextFetchPolicy", "checkFetcher", "concat", @@ -65,11 +65,11 @@ exports[`exports of public entry points @apollo/client/cache 1`] = ` Array [ "ApolloCache", "Cache", + "DEV", "EntityStore", "InMemoryCache", "MissingFieldError", "Policies", - "__DEV__", "cacheSlot", "canonicalStringify", "defaultDataIdFromObject", @@ -87,13 +87,13 @@ Array [ "ApolloError", "ApolloLink", "Cache", + "DEV", "HttpLink", "InMemoryCache", "MissingFieldError", "NetworkStatus", "Observable", "ObservableQuery", - "__DEV__", "applyNextFetchPolicy", "checkFetcher", "concat", @@ -228,8 +228,8 @@ exports[`exports of public entry points @apollo/client/react 1`] = ` Array [ "ApolloConsumer", "ApolloProvider", + "DEV", "DocumentType", - "__DEV__", "getApolloContext", "operationName", "parser", @@ -324,9 +324,9 @@ Array [ exports[`exports of public entry points @apollo/client/utilities 1`] = ` Array [ "Concast", + "DEV", "DeepMerger", "Observable", - "__DEV__", "addTypenameToDocument", "argumentsObjectFromField", "asyncMap", diff --git a/src/cache/index.ts b/src/cache/index.ts index 220bfdf378a..53d5c650ae4 100644 --- a/src/cache/index.ts +++ b/src/cache/index.ts @@ -1,4 +1,4 @@ -export { __DEV__ } from "../utilities"; +export { DEV } from "../utilities"; export { Transaction, ApolloCache } from './core/cache'; export { Cache } from './core/types/Cache'; diff --git a/src/core/index.ts b/src/core/index.ts index 4ea4873e0d1..14e5084b701 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -1,6 +1,6 @@ /* Core */ -export { __DEV__ } from "../utilities"; +export { DEV } from "../utilities"; export { ApolloClient, diff --git a/src/react/index.ts b/src/react/index.ts index 3d9c5583b9e..b3e508d44c5 100644 --- a/src/react/index.ts +++ b/src/react/index.ts @@ -1,4 +1,4 @@ -export { __DEV__ } from "../utilities"; +export { DEV } from "../utilities"; export { ApolloProvider, diff --git a/src/utilities/globals/index.ts b/src/utilities/globals/index.ts index 0b1cd926db7..01195a99607 100644 --- a/src/utilities/globals/index.ts +++ b/src/utilities/globals/index.ts @@ -1,7 +1,7 @@ // Just in case the graphql package switches from process.env.NODE_ENV to // __DEV__, make sure __DEV__ is polyfilled before importing graphql. -import { default as __DEV__ } from "./__DEV__"; -export { __DEV__ } +import DEV from "./__DEV__"; +export { DEV } // Import graphql/jsutils/instanceOf safely, working around its unchecked usage // of process.env.NODE_ENV and https://github.com/graphql/graphql-js/pull/2894. diff --git a/src/utilities/index.ts b/src/utilities/index.ts index 06bb6fcce4f..40c7bdde254 100644 --- a/src/utilities/index.ts +++ b/src/utilities/index.ts @@ -1,5 +1,5 @@ -import { __DEV__ } from "./globals"; -export { __DEV__ } +import { DEV } from "./globals"; +export { DEV } export { DirectiveInfo, From bfe0e75662d3fb7cbedb5dc17518c741eeeaafe3 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Wed, 16 Jun 2021 15:34:20 -0400 Subject: [PATCH 246/380] Bump @apollo/client npm version to 3.4.0-rc.10. --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9760dedba06..11853b34c0d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-rc.9", + "version": "3.4.0-rc.10", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index f16d9aa2ea0..d9ca02805ee 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-rc.9", + "version": "3.4.0-rc.10", "description": "A fully-featured caching GraphQL client.", "private": true, "keywords": [ From eea2567dc5108b64b2f2ab30ec02638c7d310473 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Thu, 17 Jun 2021 12:13:29 -0400 Subject: [PATCH 247/380] Update typescript from v3.9.9 to v4.3.3. --- package-lock.json | 6 +++--- package.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 11853b34c0d..dceb11d4788 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9216,9 +9216,9 @@ } }, "typescript": { - "version": "3.9.9", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.9.9.tgz", - "integrity": "sha512-kdMjTiekY+z/ubJCATUPlRDl39vXYiMV9iyeMuEuXZh2we6zz80uovNN2WlAxmmdE/Z/YQe+EbOEXB5RHEED3w==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.3.3.tgz", + "integrity": "sha512-rUvLW0WtF7PF2b9yenwWUi9Da9euvDRhmH7BLyBG4DCFfOJ850LGNknmRpp8Z8kXNUPObdZQEfKOiHtXuQHHKA==", "dev": true }, "ua-parser-js": { diff --git a/package.json b/package.json index d9ca02805ee..57ae3627fcf 100644 --- a/package.json +++ b/package.json @@ -123,7 +123,7 @@ "terser": "5.7.0", "ts-jest": "26.5.6", "ts-node": "10.0.0", - "typescript": "3.9.9", + "typescript": "^4.3.3", "wait-for-observables": "1.0.3" }, "publishConfig": { From a3c69264265964aa08ffe05a59038416efc8cfc9 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Thu, 17 Jun 2021 12:29:00 -0400 Subject: [PATCH 248/380] Fix type errors after updating typescript. --- config/helpers.ts | 2 +- src/__tests__/client.ts | 5 ++-- src/__tests__/graphqlSubscriptions.ts | 4 +-- src/__tests__/local-state/resolvers.ts | 2 +- src/cache/inmemory/__tests__/readFromStore.ts | 2 +- src/core/ObservableQuery.ts | 16 +++++----- src/core/__tests__/QueryManager/index.ts | 4 +-- src/link/http/__tests__/HttpLink.ts | 11 ++----- src/link/http/__tests__/checkFetcher.ts | 11 ++----- src/link/http/__tests__/helpers.ts | 29 +++++++++++++++++++ src/link/retry/__tests__/retryLink.ts | 2 +- src/react/components/Query.tsx | 2 +- .../hoc/__tests__/queries/errors.test.tsx | 2 +- 13 files changed, 54 insertions(+), 38 deletions(-) create mode 100644 src/link/http/__tests__/helpers.ts diff --git a/config/helpers.ts b/config/helpers.ts index 1d650a850ec..2c881a98b0e 100644 --- a/config/helpers.ts +++ b/config/helpers.ts @@ -11,7 +11,7 @@ export function eachFile(dir: string, callback: ( ) => any) { const promises: Promise[] = []; - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { glob(`${dir}/**/*.js`, (error, files) => { if (error) return reject(error); diff --git a/src/__tests__/client.ts b/src/__tests__/client.ts index 9e0b4e40f9b..9156269c2f3 100644 --- a/src/__tests__/client.ts +++ b/src/__tests__/client.ts @@ -2582,8 +2582,9 @@ describe('client', () => { const lastError = observable.getLastError(); const lastResult = observable.getLastResult(); - expect(lastResult.loading).toBeFalsy(); - expect(lastResult.networkStatus).toBe(8); + expect(lastResult).toBeTruthy(); + expect(lastResult!.loading).toBe(false); + expect(lastResult!.networkStatus).toBe(8); observable.resetLastResults(); subscription = observable.subscribe(observerOptions); diff --git a/src/__tests__/graphqlSubscriptions.ts b/src/__tests__/graphqlSubscriptions.ts index 39a010b7136..8477adec4a2 100644 --- a/src/__tests__/graphqlSubscriptions.ts +++ b/src/__tests__/graphqlSubscriptions.ts @@ -190,7 +190,7 @@ describe('GraphQL Subscriptions', () => { const promises = []; for (let i = 0; i < 2; i += 1) { promises.push( - new Promise((resolve, reject) => { + new Promise((resolve, reject) => { obs.subscribe({ next(result) { fail('Should have hit the error block'); @@ -234,7 +234,7 @@ describe('GraphQL Subscriptions', () => { cache: new InMemoryCache({ addTypename: false }), }); - return new Promise(resolve => { + return new Promise(resolve => { client.subscribe(defaultOptions).subscribe({ complete() { resolve(); diff --git a/src/__tests__/local-state/resolvers.ts b/src/__tests__/local-state/resolvers.ts index a6273cce4ee..8c384b7c547 100644 --- a/src/__tests__/local-state/resolvers.ts +++ b/src/__tests__/local-state/resolvers.ts @@ -447,7 +447,7 @@ describe('Basic resolver capabilities', () => { }); function check(result: ApolloQueryResult) { - return new Promise(resolve => { + return new Promise(resolve => { expect(result.data.developer.id).toBe(developerId); expect(result.data.developer.handle).toBe('@benjamn'); expect(result.data.developer.tickets.length).toBe(ticketsPerDev); diff --git a/src/cache/inmemory/__tests__/readFromStore.ts b/src/cache/inmemory/__tests__/readFromStore.ts index c3c1deff2a6..40dedd5528b 100644 --- a/src/cache/inmemory/__tests__/readFromStore.ts +++ b/src/cache/inmemory/__tests__/readFromStore.ts @@ -1710,7 +1710,7 @@ describe('reading from the store', () => { ...snapshotAfterGC, __META: zeusMeta, }; - delete snapshotWithoutAres["Deity:{\"name\":\"Ares\"}"]; + delete (snapshotWithoutAres as any)["Deity:{\"name\":\"Ares\"}"]; expect(cache.extract()).toEqual(snapshotWithoutAres); // Ares already removed, so no new garbage to collect. expect(cache.gc()).toEqual([]); diff --git a/src/core/ObservableQuery.ts b/src/core/ObservableQuery.ts index 36ad810e288..aafb93323e3 100644 --- a/src/core/ObservableQuery.ts +++ b/src/core/ObservableQuery.ts @@ -61,9 +61,9 @@ export class ObservableQuery< private observers = new Set>>(); private subscriptions = new Set(); - private lastResult: ApolloQueryResult; - private lastResultSnapshot: ApolloQueryResult; - private lastError: ApolloError; + private lastResult: ApolloQueryResult | undefined; + private lastResultSnapshot: ApolloQueryResult | undefined; + private lastError: ApolloError | undefined; private queryInfo: QueryInfo; constructor({ @@ -134,11 +134,11 @@ export class ObservableQuery< (lastResult && lastResult.networkStatus) || NetworkStatus.ready; - const result: ApolloQueryResult = { + const result = { ...lastResult, loading: isNetworkRequestInFlight(networkStatus), networkStatus, - }; + } as ApolloQueryResult; if (this.isTornDown) { return result; @@ -208,11 +208,11 @@ export class ObservableQuery< // Returns the last result that observer.next was called with. This is not the same as // getCurrentResult! If you're not sure which you need, then you probably need getCurrentResult. - public getLastResult(): ApolloQueryResult { + public getLastResult(): ApolloQueryResult | undefined { return this.lastResult; } - public getLastError(): ApolloError { + public getLastError(): ApolloError | undefined { return this.lastError; } @@ -623,7 +623,7 @@ once, rather than every time you call fetchMore.`); errors: error.graphQLErrors, networkStatus: NetworkStatus.error, loading: false, - }); + } as ApolloQueryResult); iterateObserversSafely(this.observers, 'error', this.lastError = error); }, diff --git a/src/core/__tests__/QueryManager/index.ts b/src/core/__tests__/QueryManager/index.ts index a474a3fd378..6703a735ba6 100644 --- a/src/core/__tests__/QueryManager/index.ts +++ b/src/core/__tests__/QueryManager/index.ts @@ -5440,7 +5440,7 @@ describe('QueryManager', () => { describe('awaitRefetchQueries', () => { const awaitRefetchTest = ({ awaitRefetchQueries, testQueryError = false }: MutationBaseOptions & { testQueryError?: boolean }) => - new Promise((resolve, reject) => { + new Promise((resolve, reject) => { const query = gql` query getAuthors($id: ID!) { author(id: $id) { @@ -5540,7 +5540,7 @@ describe('QueryManager', () => { expect(stripSymbols(result.data)).toEqual(secondReqData); }, ) - .then(resolve) + .then(() => resolve()) .catch(error => { const isRefetchError = awaitRefetchQueries && testQueryError && error.message.includes(refetchError?.message); diff --git a/src/link/http/__tests__/HttpLink.ts b/src/link/http/__tests__/HttpLink.ts index cf9c2b2383c..3d17685832b 100644 --- a/src/link/http/__tests__/HttpLink.ts +++ b/src/link/http/__tests__/HttpLink.ts @@ -11,6 +11,7 @@ import { ClientParseError } from '../serializeFetchParameter'; import { ServerParseError } from '../parseAndCheckHttpResponse'; import { ServerError } from '../../..'; import DoneCallback = jest.DoneCallback; +import { voidFetchDuringEachTest } from './helpers'; const sampleQuery = gql` query SampleQuery { @@ -907,15 +908,7 @@ describe('HttpLink', () => { }); describe('Dev warnings', () => { - let oldFetch: WindowOrWorkerGlobalScope['fetch'];; - beforeEach(() => { - oldFetch = window.fetch; - delete window.fetch; - }); - - afterEach(() => { - window.fetch = oldFetch; - }); + voidFetchDuringEachTest(); it('warns if fetch is undeclared', done => { try { diff --git a/src/link/http/__tests__/checkFetcher.ts b/src/link/http/__tests__/checkFetcher.ts index 9b554231779..7233c56a31e 100644 --- a/src/link/http/__tests__/checkFetcher.ts +++ b/src/link/http/__tests__/checkFetcher.ts @@ -1,15 +1,8 @@ import { checkFetcher } from '../checkFetcher'; +import { voidFetchDuringEachTest } from './helpers'; describe('checkFetcher', () => { - let oldFetch: WindowOrWorkerGlobalScope['fetch']; - beforeEach(() => { - oldFetch = window.fetch; - delete window.fetch; - }); - - afterEach(() => { - window.fetch = oldFetch; - }); + voidFetchDuringEachTest(); it('throws if no fetch is present', () => { expect(() => checkFetcher(undefined)).toThrow( diff --git a/src/link/http/__tests__/helpers.ts b/src/link/http/__tests__/helpers.ts new file mode 100644 index 00000000000..4f760aa5400 --- /dev/null +++ b/src/link/http/__tests__/helpers.ts @@ -0,0 +1,29 @@ +export function voidFetchDuringEachTest() { + let fetchDesc = Object.getOwnPropertyDescriptor(window, "fetch"); + + beforeEach(() => { + fetchDesc = fetchDesc || Object.getOwnPropertyDescriptor(window, "fetch"); + if (fetchDesc?.configurable) { + delete (window as any).fetch; + } + }); + + afterEach(() => { + if (fetchDesc?.configurable) { + Object.defineProperty(window, "fetch", fetchDesc); + } + }); +} + +describe("voidFetchDuringEachTest", () => { + voidFetchDuringEachTest(); + + it("hides the global.fetch function", () => { + expect(window.fetch).toBe(void 0); + expect(() => fetch).toThrowError(ReferenceError); + }); + + it("globalThis === window", () => { + expect(globalThis).toBe(window); + }); +}); diff --git a/src/link/retry/__tests__/retryLink.ts b/src/link/retry/__tests__/retryLink.ts index 37dcbc9afac..68b13c5db42 100644 --- a/src/link/retry/__tests__/retryLink.ts +++ b/src/link/retry/__tests__/retryLink.ts @@ -67,7 +67,7 @@ describe('RetryLink', () => { const firstTry = fromError(standardError); // Hold the test hostage until we're hit let secondTry; - const untilSecondTry = new Promise(resolve => { + const untilSecondTry = new Promise(resolve => { secondTry = { subscribe(observer: any) { resolve(); // Release hold on test. diff --git a/src/react/components/Query.tsx b/src/react/components/Query.tsx index ad9b2aa23c2..ed33d9890f1 100644 --- a/src/react/components/Query.tsx +++ b/src/react/components/Query.tsx @@ -9,7 +9,7 @@ export function Query( ) { const { children, query, ...options } = props; const result = useQuery(query, options); - return children && result ? children(result) : null; + return result ? children(result) : null; } export interface Query { diff --git a/src/react/hoc/__tests__/queries/errors.test.tsx b/src/react/hoc/__tests__/queries/errors.test.tsx index c854d40ae20..dd0879574df 100644 --- a/src/react/hoc/__tests__/queries/errors.test.tsx +++ b/src/react/hoc/__tests__/queries/errors.test.tsx @@ -252,7 +252,7 @@ describe('[queries] errors', () => { }); it('will not log a warning when there is an error that is not caught in the render method when using query', () => - new Promise((resolve, reject) => { + new Promise((resolve, reject) => { const query: DocumentNode = gql` query people { allPeople(first: 1) { From 464d5a638642968730812d9ed730aff313bea303 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Thu, 17 Jun 2021 13:02:13 -0400 Subject: [PATCH 249/380] Use exact typescript@4.3.3 version in package.json devDependencies. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 57ae3627fcf..faad96e68ab 100644 --- a/package.json +++ b/package.json @@ -123,7 +123,7 @@ "terser": "5.7.0", "ts-jest": "26.5.6", "ts-node": "10.0.0", - "typescript": "^4.3.3", + "typescript": "4.3.3", "wait-for-observables": "1.0.3" }, "publishConfig": { From 5a7f65d4f43dc6eae81dc166cd5a186921a0a44c Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Thu, 17 Jun 2021 15:21:31 -0400 Subject: [PATCH 250/380] Mention consequences of PR #8394 in CHANGELOG.md. --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c4907269e1..98a7af3eca9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,9 @@ - Respect `no-cache` fetch policy (by not reading any `data` from the cache) for `loading: true` results triggered by `notifyOnNetworkStatusChange: true`.
[@jcreighton](https://github.com/jcreighton) in [#7761](https://github.com/apollographql/apollo-client/pull/7761) +- The TypeScript return types of the `getLastResult` and `getLastError` methods of `ObservableQuery` now correctly include the possibility of returning `undefined`. If you happen to be calling either of these methods directly, you may need to adjust how the calling code handles the methods' possibly-`undefined` results.
+ [@benjamn](https://github.com/benjamn) in [#8394](https://github.com/apollographql/apollo-client/pull/8394) + ### Improvements - `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.
From 020fa76013bca24594a67d0a877d21c7a7fd88be Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Thu, 17 Jun 2021 10:16:26 -0400 Subject: [PATCH 251/380] Be more defensive against find/replace __DEV__ minifiers. https://github.com/apollographql/apollo-client/pull/8347#issuecomment-862784664 --- src/utilities/globals/{__DEV__.ts => DEV.ts} | 12 ++++++++++-- src/utilities/globals/index.ts | 2 +- 2 files changed, 11 insertions(+), 3 deletions(-) rename src/utilities/globals/{__DEV__.ts => DEV.ts} (50%) diff --git a/src/utilities/globals/__DEV__.ts b/src/utilities/globals/DEV.ts similarity index 50% rename from src/utilities/globals/__DEV__.ts rename to src/utilities/globals/DEV.ts index 2ff0f7aedfc..04ad1a11958 100644 --- a/src/utilities/globals/__DEV__.ts +++ b/src/utilities/globals/DEV.ts @@ -1,11 +1,17 @@ import global from "../common/global"; import { maybe } from "../common/maybe"; +// To keep string-based find/replace minifiers from messing with __DEV__ inside +// string literals or properties like global.__DEV__, we construct the "__DEV__" +// string in a roundabout way that won't be altered by find/replace strategies. +const __ = "__"; +const GLOBAL_KEY = [__, __].join("DEV"); + function getDEV() { try { return Boolean(__DEV__); } catch { - Object.defineProperty(global, "__DEV__", { + Object.defineProperty(global, GLOBAL_KEY, { // In a buildless browser environment, maybe(() => process.env.NODE_ENV) // evaluates as undefined, so __DEV__ becomes true by default, but can be // initialized to false instead by a script/module that runs earlier. @@ -14,7 +20,9 @@ function getDEV() { configurable: true, writable: true, }); - return global.__DEV__; + // Using computed property access rather than global.__DEV__ here prevents + // string-based find/replace strategies from munging this to global.false: + return (global as any)[GLOBAL_KEY]; } } diff --git a/src/utilities/globals/index.ts b/src/utilities/globals/index.ts index 01195a99607..ae5e9ba808f 100644 --- a/src/utilities/globals/index.ts +++ b/src/utilities/globals/index.ts @@ -1,6 +1,6 @@ // Just in case the graphql package switches from process.env.NODE_ENV to // __DEV__, make sure __DEV__ is polyfilled before importing graphql. -import DEV from "./__DEV__"; +import DEV from "./DEV"; export { DEV } // Import graphql/jsutils/instanceOf safely, working around its unchecked usage From 56ecc6a6c3f583758b18d7680243d18a533c92f0 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Thu, 17 Jun 2021 11:32:15 -0400 Subject: [PATCH 252/380] Advertise sideEffects in @apollo/client/utilities/package.json. --- config/entryPoints.js | 5 ++++- config/prepareDist.js | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/config/entryPoints.js b/config/entryPoints.js index cb016135b90..71c05482311 100644 --- a/config/entryPoints.js +++ b/config/entryPoints.js @@ -22,7 +22,10 @@ const entryPoints = [ { dirs: ['react', 'hooks'] }, { dirs: ['react', 'parser'] }, { dirs: ['react', 'ssr'] }, - { dirs: ['utilities'] }, + { dirs: ['utilities'], + sideEffects: [ + "./globals/**" + ]}, { dirs: ['testing'], extensions: [".js", ".jsx"] }, ]; diff --git a/config/prepareDist.js b/config/prepareDist.js index b153a131850..0a06adf0812 100644 --- a/config/prepareDist.js +++ b/config/prepareDist.js @@ -62,6 +62,7 @@ fs.copyFileSync(`${srcDir}/LICENSE`, `${destDir}/LICENSE`); entryPoints.forEach(function buildPackageJson({ dirs, bundleName = dirs[dirs.length - 1], + sideEffects = false, }) { if (!dirs.length) return; fs.writeFileSync( @@ -71,7 +72,7 @@ entryPoints.forEach(function buildPackageJson({ main: `${bundleName}.cjs.js`, module: 'index.js', types: 'index.d.ts', - sideEffects: false, + sideEffects, }, null, 2) + "\n", ); }); From e04e95999762b71e629520f6a1d7f118e64e2006 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Thu, 17 Jun 2021 11:44:06 -0400 Subject: [PATCH 253/380] Actually use DEV import in index.ts files to prevent its tree-shaking. --- src/__tests__/__snapshots__/exports.ts.snap | 4 ---- src/cache/index.ts | 4 +++- src/core/index.ts | 4 ++-- src/errors/index.ts | 4 ++++ src/link/core/index.ts | 4 ++++ src/react/index.ts | 4 +++- src/testing/index.ts | 3 +++ src/utilities/index.ts | 2 ++ 8 files changed, 21 insertions(+), 8 deletions(-) diff --git a/src/__tests__/__snapshots__/exports.ts.snap b/src/__tests__/__snapshots__/exports.ts.snap index 862d137bc13..82a7e0a1010 100644 --- a/src/__tests__/__snapshots__/exports.ts.snap +++ b/src/__tests__/__snapshots__/exports.ts.snap @@ -9,7 +9,6 @@ Array [ "ApolloLink", "ApolloProvider", "Cache", - "DEV", "DocumentType", "HttpLink", "InMemoryCache", @@ -65,7 +64,6 @@ exports[`exports of public entry points @apollo/client/cache 1`] = ` Array [ "ApolloCache", "Cache", - "DEV", "EntityStore", "InMemoryCache", "MissingFieldError", @@ -87,7 +85,6 @@ Array [ "ApolloError", "ApolloLink", "Cache", - "DEV", "HttpLink", "InMemoryCache", "MissingFieldError", @@ -228,7 +225,6 @@ exports[`exports of public entry points @apollo/client/react 1`] = ` Array [ "ApolloConsumer", "ApolloProvider", - "DEV", "DocumentType", "getApolloContext", "operationName", diff --git a/src/cache/index.ts b/src/cache/index.ts index 53d5c650ae4..0aa33bdd2c8 100644 --- a/src/cache/index.ts +++ b/src/cache/index.ts @@ -1,4 +1,6 @@ -export { DEV } from "../utilities"; +import { invariant } from "ts-invariant"; +import { DEV } from "../utilities"; +invariant("boolean" === typeof DEV, DEV); export { Transaction, ApolloCache } from './core/cache'; export { Cache } from './core/types/Cache'; diff --git a/src/core/index.ts b/src/core/index.ts index 14e5084b701..b3723e5af78 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -1,6 +1,6 @@ /* Core */ -export { DEV } from "../utilities"; +import { DEV } from "../utilities"; export { ApolloClient, @@ -91,7 +91,7 @@ export { // Note that all invariant.* logging is hidden in production. import { setVerbosity } from "ts-invariant"; export { setVerbosity as setLogVerbosity } -setVerbosity("log"); +setVerbosity(DEV ? "log" : "silent"); // Note that importing `gql` by itself, then destructuring // additional properties separately before exporting, is intentional. diff --git a/src/errors/index.ts b/src/errors/index.ts index 956fa3df4e5..e053540cbed 100644 --- a/src/errors/index.ts +++ b/src/errors/index.ts @@ -1,3 +1,7 @@ +import { invariant } from "ts-invariant"; +import { DEV } from "../utilities"; +invariant("boolean" === typeof DEV, DEV); + import { GraphQLError } from 'graphql'; import { isNonEmptyArray } from '../utilities'; diff --git a/src/link/core/index.ts b/src/link/core/index.ts index 8093e7c6cd2..b01ccf98897 100644 --- a/src/link/core/index.ts +++ b/src/link/core/index.ts @@ -1,3 +1,7 @@ +import { invariant } from "ts-invariant"; +import { DEV } from "../../utilities"; +invariant("boolean" === typeof DEV, DEV); + export { empty } from './empty'; export { from } from './from'; export { split } from './split'; diff --git a/src/react/index.ts b/src/react/index.ts index b3e508d44c5..eeee3f721a2 100644 --- a/src/react/index.ts +++ b/src/react/index.ts @@ -1,4 +1,6 @@ -export { DEV } from "../utilities"; +import { invariant } from "ts-invariant"; +import { DEV } from "../utilities"; +invariant("boolean" === typeof DEV, DEV); export { ApolloProvider, diff --git a/src/testing/index.ts b/src/testing/index.ts index bec83b671fe..831c6de2b10 100644 --- a/src/testing/index.ts +++ b/src/testing/index.ts @@ -1 +1,4 @@ +import { invariant } from "ts-invariant"; +import { DEV } from "../utilities"; +invariant("boolean" === typeof DEV, DEV); export * from '../utilities/testing'; diff --git a/src/utilities/index.ts b/src/utilities/index.ts index 40c7bdde254..5ad7f9f5581 100644 --- a/src/utilities/index.ts +++ b/src/utilities/index.ts @@ -1,4 +1,6 @@ +import { invariant } from "ts-invariant"; import { DEV } from "./globals"; +invariant("boolean" === typeof DEV, DEV); export { DEV } export { From 72735a391c73579e864c402284581609d2837d8c Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Thu, 17 Jun 2021 15:55:18 -0400 Subject: [PATCH 254/380] Bump @apollo/client npm version to 3.4.0-rc.11. --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index dceb11d4788..4b6135ffc5e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-rc.10", + "version": "3.4.0-rc.11", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index faad96e68ab..02e079f0ad1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-rc.10", + "version": "3.4.0-rc.11", "description": "A fully-featured caching GraphQL client.", "private": true, "keywords": [ From d7edb88547471f415a4c94027450c44b0e1f4e57 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Mon, 14 Jun 2021 14:07:50 -0400 Subject: [PATCH 255/380] Log non-fatal error when fields are missing from written results. Fixes #8331 and #6915, and should help with the underlying cause of https://github.com/apollographql/apollo-client/issues/7436#issuecomment-860631219 --- src/cache/inmemory/writeToStore.ts | 31 +++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/src/cache/inmemory/writeToStore.ts b/src/cache/inmemory/writeToStore.ts index 83d94f4ce7e..47f32692e0d 100644 --- a/src/cache/inmemory/writeToStore.ts +++ b/src/cache/inmemory/writeToStore.ts @@ -18,8 +18,8 @@ import { Reference, isReference, shouldInclude, - hasDirectives, cloneDeep, + addTypenameToDocument, } from '../../utilities'; import { NormalizedCache, ReadMergeModifyContext, MergeTree } from './types'; @@ -44,6 +44,7 @@ export interface WriteContext extends ReadMergeModifyContext { mergeTree: MergeTree; selections: Set; }>; + clientOnly: boolean; }; interface ProcessSelectionSetOptions { @@ -86,6 +87,7 @@ export class StoreWriter { fragmentMap: createFragmentMap(getFragmentDefinitions(query)), overwrite: !!overwrite, incomingById: new Map, + clientOnly: false, }; const ref = this.processSelectionSet({ @@ -231,7 +233,13 @@ export class StoreWriter { const resultFieldKey = resultKeyNameFromField(selection); const value = result[resultFieldKey]; - if (typeof value !== 'undefined') { + const wasClientOnly = context.clientOnly; + context.clientOnly = wasClientOnly || !!( + selection.directives && + selection.directives.some(d => d.name.value === "client") + ); + + if (value !== void 0) { const storeFieldName = policies.getStoreFieldName({ typename, fieldName: selection.name.value, @@ -303,17 +311,18 @@ export class StoreWriter { }); } else if ( - policies.usingPossibleTypes && - !hasDirectives(["defer", "client"], selection) + !context.clientOnly && + !addTypenameToDocument.added(selection) ) { - throw new InvariantError( - `Missing field '${resultFieldKey}' in ${JSON.stringify( - result, - null, - 2, - ).substring(0, 100)}`, - ); + invariant.error(`Missing field '${ + resultKeyNameFromField(selection) + }' while writing result ${ + JSON.stringify(result, null, 2) + }`.substring(0, 1000)); } + + context.clientOnly = wasClientOnly; + } else { // This is not a field, so it must be a fragment, either inline or named const fragment = getFragmentFromSelection( From d11ea159d66d73e99b1f51f15792372ca1a1b1cb Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 15 Jun 2021 11:48:23 -0400 Subject: [PATCH 256/380] New internal testing utility: withErrorSpy(it, "should...", ...) --- src/__tests__/__snapshots__/exports.ts.snap | 1 + src/utilities/testing/index.ts | 1 + src/utilities/testing/itAsync.ts | 17 +++++++++-------- src/utilities/testing/withErrorSpy.ts | 21 +++++++++++++++++++++ 4 files changed, 32 insertions(+), 8 deletions(-) create mode 100644 src/utilities/testing/withErrorSpy.ts diff --git a/src/__tests__/__snapshots__/exports.ts.snap b/src/__tests__/__snapshots__/exports.ts.snap index 82a7e0a1010..bf8dd514224 100644 --- a/src/__tests__/__snapshots__/exports.ts.snap +++ b/src/__tests__/__snapshots__/exports.ts.snap @@ -314,6 +314,7 @@ Array [ "mockSingleLink", "stripSymbols", "subscribeAndCount", + "withErrorSpy", ] `; diff --git a/src/utilities/testing/index.ts b/src/utilities/testing/index.ts index 45c2f3198f1..668557783e9 100644 --- a/src/utilities/testing/index.ts +++ b/src/utilities/testing/index.ts @@ -13,3 +13,4 @@ export { createMockClient } from './mocking/mockClient'; export { stripSymbols } from './stripSymbols'; export { default as subscribeAndCount } from './subscribeAndCount'; export { itAsync } from './itAsync'; +export { withErrorSpy } from './withErrorSpy'; diff --git a/src/utilities/testing/itAsync.ts b/src/utilities/testing/itAsync.ts index 17c8406321e..9d54b947671 100644 --- a/src/utilities/testing/itAsync.ts +++ b/src/utilities/testing/itAsync.ts @@ -14,12 +14,13 @@ function wrap(key?: "only" | "skip" | "todo") { } const wrappedIt = wrap(); -export function itAsync(...args: Parameters) { - return wrappedIt.apply(this, args); -} -export namespace itAsync { - export const only = wrap("only"); - export const skip = wrap("skip"); - export const todo = wrap("todo"); -} +export const itAsync = Object.assign(function ( + ...args: Parameters +) { + return wrappedIt.apply(this, args); +}, { + only: wrap("only"), + skip: wrap("skip"), + todo: wrap("todo"), +}); diff --git a/src/utilities/testing/withErrorSpy.ts b/src/utilities/testing/withErrorSpy.ts new file mode 100644 index 00000000000..2c39e8c1f91 --- /dev/null +++ b/src/utilities/testing/withErrorSpy.ts @@ -0,0 +1,21 @@ +export function withErrorSpy< + TArgs extends any[], + TResult, +>( + it: (...args: TArgs) => TResult, + ...args: TArgs +) { + const fn = args[1]; + args[1] = function () { + const args = arguments; + const errorSpy = jest.spyOn(console, 'error'); + errorSpy.mockImplementation(() => {}); + return new Promise(resolve => { + resolve(fn?.apply(this, args)); + }).finally(() => { + expect(errorSpy).toMatchSnapshot(); + errorSpy.mockReset(); + }); + }; + return it(...args); +} From 4b4a9b80e816e6d6ed5640b7c5437da7e24e5734 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Mon, 14 Jun 2021 17:05:19 -0400 Subject: [PATCH 257/380] Fix tests and capture/check invariant.error messages. --- src/__tests__/ApolloClient.ts | 68 ++++---- .../__snapshots__/ApolloClient.ts.snap | 39 +++++ src/__tests__/__snapshots__/client.ts.snap | 21 +++ .../__snapshots__/mutationResults.ts.snap | 20 +++ src/__tests__/client.ts | 35 ++-- .../local-state/__snapshots__/export.ts.snap | 141 ++++++++++++++++ .../local-state/__snapshots__/general.ts.snap | 38 +++++ src/__tests__/local-state/export.ts | 8 +- src/__tests__/local-state/general.ts | 6 +- src/__tests__/mutationResults.ts | 19 +-- .../__tests__/__snapshots__/policies.ts.snap | 156 ++++++++++++++++++ .../__snapshots__/readFromStore.ts.snap | 19 +++ .../__tests__/__snapshots__/roundtrip.ts.snap | 41 +++++ .../__snapshots__/writeToStore.ts.snap | 78 +++++++++ src/cache/inmemory/__tests__/cache.ts | 2 +- src/cache/inmemory/__tests__/entityStore.ts | 4 +- src/cache/inmemory/__tests__/policies.ts | 12 +- src/cache/inmemory/__tests__/readFromStore.ts | 3 +- src/cache/inmemory/__tests__/roundtrip.ts | 9 +- src/cache/inmemory/__tests__/writeToStore.ts | 43 +++-- src/core/__tests__/ObservableQuery.ts | 5 +- src/core/__tests__/QueryManager/index.ts | 14 +- src/core/__tests__/fetchPolicies.ts | 2 +- .../__tests__/client/Query.test.tsx | 4 +- .../client/__snapshots__/Query.test.tsx.snap | 16 ++ .../__snapshots__/useQuery.test.tsx.snap | 17 ++ .../useSubscription.test.tsx.snap | 47 ++++++ src/react/hooks/__tests__/useQuery.test.tsx | 12 +- .../hooks/__tests__/useSubscription.test.tsx | 10 +- 29 files changed, 768 insertions(+), 121 deletions(-) create mode 100644 src/__tests__/local-state/__snapshots__/export.ts.snap create mode 100644 src/cache/inmemory/__tests__/__snapshots__/roundtrip.ts.snap create mode 100644 src/react/hooks/__tests__/__snapshots__/useQuery.test.tsx.snap create mode 100644 src/react/hooks/__tests__/__snapshots__/useSubscription.test.tsx.snap diff --git a/src/__tests__/ApolloClient.ts b/src/__tests__/ApolloClient.ts index 5f195adcbec..5d59a8a7344 100644 --- a/src/__tests__/ApolloClient.ts +++ b/src/__tests__/ApolloClient.ts @@ -12,7 +12,7 @@ import { Observable } from '../utilities'; import { ApolloLink } from '../link/core'; import { HttpLink } from '../link/http'; import { InMemoryCache } from '../cache'; -import { stripSymbols } from '../testing'; +import { stripSymbols, withErrorSpy } from '../testing'; import { TypedDocumentNode } from '@graphql-typed-document-node/core'; describe('ApolloClient', () => { @@ -834,7 +834,7 @@ describe('ApolloClient', () => { }); }); - it('should warn when the data provided does not match the query shape', () => { + withErrorSpy(it, 'should warn when the data provided does not match the query shape', () => { const client = new ApolloClient({ link: ApolloLink.empty(), cache: new InMemoryCache({ @@ -843,28 +843,26 @@ describe('ApolloClient', () => { }), }); - expect(() => { - client.writeQuery({ - data: { - todos: [ - { - id: '1', - name: 'Todo 1', - __typename: 'Todo', - }, - ], - }, - query: gql` - query { - todos { - id - name - description - } + client.writeQuery({ + data: { + todos: [ + { + id: '1', + name: 'Todo 1', + __typename: 'Todo', + }, + ], + }, + query: gql` + query { + todos { + id + name + description } - `, - }); - }).toThrowError(/Missing field 'description' /); + } + `, + }); }); }); @@ -1119,7 +1117,7 @@ describe('ApolloClient', () => { }); }); - it('should warn when the data provided does not match the fragment shape', () => { + withErrorSpy(it, 'should warn when the data provided does not match the fragment shape', () => { const client = new ApolloClient({ link: ApolloLink.empty(), cache: new InMemoryCache({ @@ -1128,18 +1126,16 @@ describe('ApolloClient', () => { }), }); - expect(() => { - client.writeFragment({ - data: { __typename: 'Bar', i: 10 }, - id: 'bar', - fragment: gql` - fragment fragmentBar on Bar { - i - e - } - `, - }); - }).toThrowError(/Missing field 'e' /); + client.writeFragment({ + data: { __typename: 'Bar', i: 10 }, + id: 'bar', + fragment: gql` + fragment fragmentBar on Bar { + i + e + } + `, + }); }); describe('change will call observable next', () => { diff --git a/src/__tests__/__snapshots__/ApolloClient.ts.snap b/src/__tests__/__snapshots__/ApolloClient.ts.snap index d49b8df8c55..63f2d60b480 100644 --- a/src/__tests__/__snapshots__/ApolloClient.ts.snap +++ b/src/__tests__/__snapshots__/ApolloClient.ts.snap @@ -209,6 +209,25 @@ Object { } `; +exports[`ApolloClient writeFragment should warn when the data provided does not match the fragment shape 1`] = ` +[MockFunction] { + "calls": Array [ + Array [ + "Missing field 'e' while writing result { + \\"__typename\\": \\"Bar\\", + \\"i\\": 10 +}", + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], +} +`; + exports[`ApolloClient writeFragment will write some deeply nested data into the store at any id 1`] = ` Object { "__META": Object { @@ -359,6 +378,26 @@ Object { } `; +exports[`ApolloClient writeQuery should warn when the data provided does not match the query shape 1`] = ` +[MockFunction] { + "calls": Array [ + Array [ + "Missing field 'description' while writing result { + \\"id\\": \\"1\\", + \\"name\\": \\"Todo 1\\", + \\"__typename\\": \\"Todo\\" +}", + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], +} +`; + exports[`ApolloClient writeQuery will write some deeply nested data to the store 1`] = ` Object { "ROOT_QUERY": Object { diff --git a/src/__tests__/__snapshots__/client.ts.snap b/src/__tests__/__snapshots__/client.ts.snap index 4abceb872b6..4e2bfb9219d 100644 --- a/src/__tests__/__snapshots__/client.ts.snap +++ b/src/__tests__/__snapshots__/client.ts.snap @@ -41,3 +41,24 @@ Object { }, } `; + +exports[`client should warn if server returns wrong data 1`] = ` +[MockFunction] { + "calls": Array [ + Array [ + "Missing field 'description' while writing result { + \\"id\\": \\"1\\", + \\"name\\": \\"Todo 1\\", + \\"price\\": 100, + \\"__typename\\": \\"Todo\\" +}", + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], +} +`; diff --git a/src/__tests__/__snapshots__/mutationResults.ts.snap b/src/__tests__/__snapshots__/mutationResults.ts.snap index 211d8a2fa53..887a5147f14 100644 --- a/src/__tests__/__snapshots__/mutationResults.ts.snap +++ b/src/__tests__/__snapshots__/mutationResults.ts.snap @@ -1,5 +1,25 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`mutation results should warn when the result fields don't match the query fields 1`] = ` +[MockFunction] { + "calls": Array [ + Array [ + "Missing field 'description' while writing result { + \\"id\\": \\"2\\", + \\"name\\": \\"Todo 2\\", + \\"__typename\\": \\"createTodo\\" +}", + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], +} +`; + exports[`mutation results should write results to cache according to errorPolicy 1`] = `Object {}`; exports[`mutation results should write results to cache according to errorPolicy 2`] = ` diff --git a/src/__tests__/client.ts b/src/__tests__/client.ts index 9156269c2f3..c810a705fad 100644 --- a/src/__tests__/client.ts +++ b/src/__tests__/client.ts @@ -20,6 +20,7 @@ import { stripSymbols, subscribeAndCount, mockSingleLink, + withErrorSpy, } from '../testing'; describe('client', () => { @@ -2081,6 +2082,7 @@ describe('client', () => { resolve(); }); }); + itAsync('should allow errors to be returned from a mutation', (resolve, reject) => { const mutation = gql` mutation { @@ -2102,7 +2104,12 @@ describe('client', () => { const client = new ApolloClient({ link: mockSingleLink({ request: { query: mutation }, - result: { data, errors }, + result: { + errors, + data: { + newPerson: data, + }, + }, }).setOnError(reject), cache: new InMemoryCache({ addTypename: false }), }); @@ -2112,7 +2119,9 @@ describe('client', () => { expect(result.errors).toBeDefined(); expect(result.errors!.length).toBe(1); expect(result.errors![0].message).toBe(errors[0].message); - expect(result.data).toEqual(data); + expect(result.data).toEqual({ + newPerson: data, + }); resolve(); }) .catch((error: ApolloError) => { @@ -2132,9 +2141,11 @@ describe('client', () => { } `; const data = { - person: { - firstName: 'John', - lastName: 'Smith', + newPerson: { + person: { + firstName: 'John', + lastName: 'Smith', + }, }, }; const errors = [new Error('Some kind of GraphQL error.')]; @@ -2631,7 +2642,7 @@ describe('client', () => { }).then(resolve, reject); }); - itAsync('should warn if server returns wrong data', (resolve, reject) => { + withErrorSpy(itAsync, 'should warn if server returns wrong data', (resolve, reject) => { const query = gql` query { todos { @@ -2654,6 +2665,7 @@ describe('client', () => { ], }, }; + const link = mockSingleLink({ request: { query }, result, @@ -2666,14 +2678,9 @@ describe('client', () => { }), }); - return client.query({ query }).then( - result => { - fail("should have errored"); - }, - error => { - expect(error.message).toMatch(/Missing field 'description' /); - }, - ).then(resolve, reject); + return client.query({ query }).then(({ data }) => { + expect(data).toEqual(result.data); + }).then(resolve, reject); }); itAsync('runs a query with the connection directive and writes it to the store key defined in the directive', (resolve, reject) => { diff --git a/src/__tests__/local-state/__snapshots__/export.ts.snap b/src/__tests__/local-state/__snapshots__/export.ts.snap new file mode 100644 index 00000000000..4af22816166 --- /dev/null +++ b/src/__tests__/local-state/__snapshots__/export.ts.snap @@ -0,0 +1,141 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`@client @export tests should NOT refetch if an @export variable has not changed, the current fetch policy is not cache-only, and the query includes fields that need to be resolved remotely 1`] = ` +[MockFunction] { + "calls": Array [ + Array [ + "Missing field 'postCount' while writing result { + \\"currentAuthorId\\": 100 +}", + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], +} +`; + +exports[`@client @export tests should allow @client @export variables to be used with remote queries 1`] = ` +[MockFunction] { + "calls": Array [ + Array [ + "Missing field 'postCount' while writing result { + \\"currentAuthor\\": { + \\"name\\": \\"John Smith\\", + \\"authorId\\": 100, + \\"__typename\\": \\"Author\\" + } +}", + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], +} +`; + +exports[`@client @export tests should refetch if an @export variable changes, the current fetch policy is not cache-only, and the query includes fields that need to be resolved remotely 1`] = ` +[MockFunction] { + "calls": Array [ + Array [ + "Missing field 'postCount' while writing result { + \\"appContainer\\": { + \\"systemDetails\\": { + \\"currentAuthor\\": { + \\"name\\": \\"John Smith\\", + \\"authorId\\": 100, + \\"__typename\\": \\"Author\\" + }, + \\"__typename\\": \\"SystemDetails\\" + }, + \\"__typename\\": \\"AppContainer\\" + } +}", + ], + Array [ + "Missing field 'title' while writing result { + \\"loggedInReviewerId\\": 100, + \\"__typename\\": \\"Post\\", + \\"id\\": 10 +}", + ], + Array [ + "Missing field 'reviewerDetails' while writing result { + \\"postRequiringReview\\": { + \\"loggedInReviewerId\\": 100, + \\"__typename\\": \\"Post\\", + \\"id\\": 10 + } +}", + ], + Array [ + "Missing field 'id' while writing result { + \\"__typename\\": \\"Post\\" +}", + ], + Array [ + "Missing field 'title' while writing result { + \\"__typename\\": \\"Post\\" +}", + ], + Array [ + "Missing field 'reviewerDetails' while writing result { + \\"postRequiringReview\\": { + \\"__typename\\": \\"Post\\" + } +}", + ], + Array [ + "Missing field 'post' while writing result { + \\"primaryReviewerId\\": 100, + \\"secondaryReviewerId\\": 200 +}", + ], + Array [ + "Missing field 'postCount' while writing result { + \\"currentAuthorId\\": 100 +}", + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + Object { + "type": "return", + "value": undefined, + }, + Object { + "type": "return", + "value": undefined, + }, + Object { + "type": "return", + "value": undefined, + }, + Object { + "type": "return", + "value": undefined, + }, + Object { + "type": "return", + "value": undefined, + }, + Object { + "type": "return", + "value": undefined, + }, + Object { + "type": "return", + "value": undefined, + }, + ], +} +`; diff --git a/src/__tests__/local-state/__snapshots__/general.ts.snap b/src/__tests__/local-state/__snapshots__/general.ts.snap index 9747bcf8cd7..331aef47fe9 100644 --- a/src/__tests__/local-state/__snapshots__/general.ts.snap +++ b/src/__tests__/local-state/__snapshots__/general.ts.snap @@ -3,3 +3,41 @@ exports[`Combining client and server state/operations should correctly propagate an error from a client resolver 1`] = `"Illegal Query Operation Occurred"`; exports[`Combining client and server state/operations should correctly propagate an error from a client resolver 2`] = `"Illegal Mutation Operation Occurred"`; + +exports[`Combining client and server state/operations should handle a simple query with both server and client fields 1`] = ` +[MockFunction] { + "calls": Array [ + Array [ + "Missing field 'lastCount' while writing result { + \\"count\\": 0 +}", + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], +} +`; + +exports[`Combining client and server state/operations should support nested querying of both server and client fields 1`] = ` +[MockFunction] { + "calls": Array [ + Array [ + "Missing field 'lastName' while writing result { + \\"__typename\\": \\"User\\", + \\"id\\": 123, + \\"firstName\\": \\"John\\" +}", + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], +} +`; diff --git a/src/__tests__/local-state/export.ts b/src/__tests__/local-state/export.ts index a9e14a3a3c0..75d77a9e203 100644 --- a/src/__tests__/local-state/export.ts +++ b/src/__tests__/local-state/export.ts @@ -2,7 +2,7 @@ import gql from 'graphql-tag'; import { print } from 'graphql'; import { Observable } from '../../utilities'; -import { itAsync } from '../../testing'; +import { itAsync, withErrorSpy } from '../../testing'; import { ApolloLink } from '../../link/core'; import { ApolloClient } from '../../core'; import { InMemoryCache } from '../../cache'; @@ -179,7 +179,7 @@ describe('@client @export tests', () => { }, ); - it('should allow @client @export variables to be used with remote queries', done => { + withErrorSpy(it, 'should allow @client @export variables to be used with remote queries', done => { const query = gql` query currentAuthorPostCount($authorId: Int!) { currentAuthor @client { @@ -714,7 +714,7 @@ describe('@client @export tests', () => { }, ); - it( + withErrorSpy(it, 'should refetch if an @export variable changes, the current fetch ' + 'policy is not cache-only, and the query includes fields that need to ' + 'be resolved remotely', @@ -779,7 +779,7 @@ describe('@client @export tests', () => { } ); - it( + withErrorSpy(it, 'should NOT refetch if an @export variable has not changed, the ' + 'current fetch policy is not cache-only, and the query includes fields ' + 'that need to be resolved remotely', diff --git a/src/__tests__/local-state/general.ts b/src/__tests__/local-state/general.ts index 4c442f6b6ed..47f595440ec 100644 --- a/src/__tests__/local-state/general.ts +++ b/src/__tests__/local-state/general.ts @@ -6,7 +6,7 @@ import { ApolloLink } from '../../link/core'; import { Operation } from '../../link/core'; import { ApolloClient } from '../../core'; import { ApolloCache, InMemoryCache } from '../../cache'; -import { itAsync } from '../../testing'; +import { itAsync, withErrorSpy } from '../../testing'; describe('General functionality', () => { it('should not impact normal non-@client use', () => { @@ -885,7 +885,7 @@ describe('Combining client and server state/operations', () => { resolve(); }); - itAsync('should handle a simple query with both server and client fields', (resolve, reject) => { + withErrorSpy(itAsync, 'should handle a simple query with both server and client fields', (resolve, reject) => { const query = gql` query GetCount { count @client @@ -920,7 +920,7 @@ describe('Combining client and server state/operations', () => { }); }); - itAsync('should support nested querying of both server and client fields', (resolve, reject) => { + withErrorSpy(itAsync, 'should support nested querying of both server and client fields', (resolve, reject) => { const query = gql` query GetUser { user { diff --git a/src/__tests__/mutationResults.ts b/src/__tests__/mutationResults.ts index bc8ca258580..9a78a3255b8 100644 --- a/src/__tests__/mutationResults.ts +++ b/src/__tests__/mutationResults.ts @@ -6,7 +6,7 @@ import { ApolloClient } from '../core'; import { InMemoryCache } from '../cache'; import { ApolloLink } from '../link/core'; import { Observable, ObservableSubscription as Subscription } from '../utilities'; -import { itAsync, subscribeAndCount, mockSingleLink } from '../testing'; +import { itAsync, subscribeAndCount, mockSingleLink, withErrorSpy } from '../testing'; describe('mutation results', () => { const query = gql` @@ -400,7 +400,7 @@ describe('mutation results', () => { resolve(); }); - itAsync("should warn when the result fields don't match the query fields", (resolve, reject) => { + withErrorSpy(itAsync, "should warn when the result fields don't match the query fields", (resolve, reject) => { let handle: any; let subscriptionHandle: Subscription; @@ -482,16 +482,11 @@ describe('mutation results', () => { return newResults; }, }, - })).then( - () => { - subscriptionHandle.unsubscribe(); - fail("should have errored"); - }, - error => { - subscriptionHandle.unsubscribe(); - expect(error.message).toMatch(/Missing field 'description' /); - }, - ).then(resolve, reject); + })).finally( + () => subscriptionHandle.unsubscribe(), + ).then(result => { + expect(result).toEqual(mutationTodoResult); + }).then(resolve, reject); }); describe('InMemoryCache type/field policies', () => { diff --git a/src/cache/inmemory/__tests__/__snapshots__/policies.ts.snap b/src/cache/inmemory/__tests__/__snapshots__/policies.ts.snap index 296372a0cd1..d414466e566 100644 --- a/src/cache/inmemory/__tests__/__snapshots__/policies.ts.snap +++ b/src/cache/inmemory/__tests__/__snapshots__/policies.ts.snap @@ -50,6 +50,51 @@ Object { } `; +exports[`type policies complains about missing key fields 1`] = ` +[MockFunction] { + "calls": Array [ + Array [ + "Missing field 'title' while writing result { + \\"year\\": 2011, + \\"theInformationBookData\\": { + \\"__typename\\": \\"Book\\", + \\"isbn\\": \\"1400096235\\", + \\"title\\": \\"The Information\\", + \\"subtitle\\": \\"A History, a Theory, a Flood\\", + \\"author\\": { + \\"name\\": \\"James Gleick\\" + } + } +}", + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], +} +`; + +exports[`type policies field policies assumes keyArgs:false when read and merge function present 1`] = ` +[MockFunction] { + "calls": Array [ + Array [ + "Missing field 'a' while writing result { + \\"__typename\\": \\"TypeA\\" +}", + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], +} +`; + exports[`type policies field policies can handle Relay-style pagination 1`] = ` Object { "Artist:{\\"href\\":\\"/artist/jean-michel-basquiat\\"}": Object { @@ -749,6 +794,7 @@ Object { "cursor": "YXJyYXljb25uZWN0aW9uOjEx", "node": Object { "__typename": "SearchableItem", + "description": "", "displayLabel": "James Turrell: Light knows when we’re looking", }, }, @@ -1211,6 +1257,45 @@ Object { } `; +exports[`type policies field policies read and merge can cooperate through options.storage 1`] = ` +[MockFunction] { + "calls": Array [ + Array [ + "Missing field 'result' while writing result { + \\"__typename\\": \\"Job\\", + \\"name\\": \\"Job #1\\" +}", + ], + Array [ + "Missing field 'result' while writing result { + \\"__typename\\": \\"Job\\", + \\"name\\": \\"Job #2\\" +}", + ], + Array [ + "Missing field 'result' while writing result { + \\"__typename\\": \\"Job\\", + \\"name\\": \\"Job #3\\" +}", + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + Object { + "type": "return", + "value": undefined, + }, + Object { + "type": "return", + "value": undefined, + }, + ], +} +`; + exports[`type policies field policies read, merge, and modify functions can access options.storage 1`] = ` Object { "ROOT_QUERY": Object { @@ -1233,6 +1318,77 @@ Object { } `; +exports[`type policies field policies readField helper function calls custom read functions 1`] = ` +[MockFunction] { + "calls": Array [ + Array [ + "Missing field 'blockers' while writing result { + \\"__typename\\": \\"Task\\", + \\"id\\": 4, + \\"description\\": \\"grandchild task\\" +}", + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], +} +`; + +exports[`type policies field policies runs nested merge functions as well as ancestors 1`] = ` +[MockFunction] { + "calls": Array [ + Array [ + "Missing field 'time' while writing result { + \\"__typename\\": \\"Event\\", + \\"id\\": 123 +}", + ], + Array [ + "Missing field 'time' while writing result { + \\"__typename\\": \\"Event\\", + \\"id\\": 345, + \\"name\\": \\"Rooftop dog party\\", + \\"attendees\\": [ + { + \\"__typename\\": \\"Attendee\\", + \\"id\\": 456, + \\"name\\": \\"Inspector Beckett\\" + }, + { + \\"__typename\\": \\"Attendee\\", + \\"id\\": 234 + } + ] +}", + ], + Array [ + "Missing field 'name' while writing result { + \\"__typename\\": \\"Attendee\\", + \\"id\\": 234 +}", + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + Object { + "type": "return", + "value": undefined, + }, + Object { + "type": "return", + "value": undefined, + }, + ], +} +`; + exports[`type policies support inheritance 1`] = ` Object { "Cobra:{\\"tagId\\":\\"Egypt30BC\\"}": Object { diff --git a/src/cache/inmemory/__tests__/__snapshots__/readFromStore.ts.snap b/src/cache/inmemory/__tests__/__snapshots__/readFromStore.ts.snap index 46732d054ee..7de2c6014cb 100644 --- a/src/cache/inmemory/__tests__/__snapshots__/readFromStore.ts.snap +++ b/src/cache/inmemory/__tests__/__snapshots__/readFromStore.ts.snap @@ -1,5 +1,24 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`reading from the store propagates eviction signals to parent queries 1`] = ` +[MockFunction] { + "calls": Array [ + Array [ + "Missing field 'children' while writing result { + \\"__typename\\": \\"Deity\\", + \\"name\\": \\"Zeus\\" +}", + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], +} +`; + exports[`reading from the store returns === results for different queries 1`] = ` Object { "ROOT_QUERY": Object { diff --git a/src/cache/inmemory/__tests__/__snapshots__/roundtrip.ts.snap b/src/cache/inmemory/__tests__/__snapshots__/roundtrip.ts.snap new file mode 100644 index 00000000000..9f5f5e848ca --- /dev/null +++ b/src/cache/inmemory/__tests__/__snapshots__/roundtrip.ts.snap @@ -0,0 +1,41 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`roundtrip fragments should throw an error on two of the same inline fragment types 1`] = ` +[MockFunction] { + "calls": Array [ + Array [ + "Missing field 'rank' while writing result { + \\"__typename\\": \\"Jedi\\", + \\"name\\": \\"Luke Skywalker\\", + \\"side\\": \\"bright\\" +}", + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], +} +`; + +exports[`roundtrip fragments should throw on error on two of the same spread fragment types 1`] = ` +[MockFunction] { + "calls": Array [ + Array [ + "Missing field 'rank' while writing result { + \\"__typename\\": \\"Jedi\\", + \\"name\\": \\"Luke Skywalker\\", + \\"side\\": \\"bright\\" +}", + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], +} +`; diff --git a/src/cache/inmemory/__tests__/__snapshots__/writeToStore.ts.snap b/src/cache/inmemory/__tests__/__snapshots__/writeToStore.ts.snap index caa466e21f8..94ea2a95cb2 100644 --- a/src/cache/inmemory/__tests__/__snapshots__/writeToStore.ts.snap +++ b/src/cache/inmemory/__tests__/__snapshots__/writeToStore.ts.snap @@ -69,6 +69,46 @@ Object { } `; +exports[`writing to the store should not keep reference when type of mixed inlined field changes to non-inlined field 1`] = ` +[MockFunction] { + "calls": Array [ + Array [ + "Missing field 'price' while writing result { + \\"id\\": \\"1\\", + \\"name\\": \\"Todo 1\\", + \\"description\\": \\"Description 1\\", + \\"__typename\\": \\"ShoppingCartItem\\" +}", + ], + Array [ + "Missing field 'expensive' while writing result { + \\"id\\": 1 +}", + ], + Array [ + "Missing field 'id' while writing result { + \\"__typename\\": \\"Cat\\", + \\"name\\": \\"cat\\" +}", + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + Object { + "type": "return", + "value": undefined, + }, + Object { + "type": "return", + "value": undefined, + }, + ], +} +`; + exports[`writing to the store should respect id fields added by fragments 1`] = ` Object { "AType:a-id": Object { @@ -177,3 +217,41 @@ Object { }, } `; + +exports[`writing to the store writeResultToStore shape checking should warn when it receives the wrong data with non-union fragments 1`] = ` +[MockFunction] { + "calls": Array [ + Array [ + "Missing field 'description' while writing result { + \\"id\\": \\"1\\", + \\"name\\": \\"Todo 1\\" +}", + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], +} +`; + +exports[`writing to the store writeResultToStore shape checking should write the result data without validating its shape when a fragment matcher is not provided 1`] = ` +[MockFunction] { + "calls": Array [ + Array [ + "Missing field 'description' while writing result { + \\"id\\": \\"1\\", + \\"name\\": \\"Todo 1\\" +}", + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], +} +`; diff --git a/src/cache/inmemory/__tests__/cache.ts b/src/cache/inmemory/__tests__/cache.ts index 1db6fc073c6..ea199eb9d4c 100644 --- a/src/cache/inmemory/__tests__/cache.ts +++ b/src/cache/inmemory/__tests__/cache.ts @@ -2857,7 +2857,7 @@ describe("ReactiveVar and makeVar", () => { const query = gql` query { - onCall { + onCall @client { name } } diff --git a/src/cache/inmemory/__tests__/entityStore.ts b/src/cache/inmemory/__tests__/entityStore.ts index 396b683aede..2001417ace2 100644 --- a/src/cache/inmemory/__tests__/entityStore.ts +++ b/src/cache/inmemory/__tests__/entityStore.ts @@ -307,6 +307,7 @@ describe('EntityStore', () => { { __typename: 'Book', isbn: '9781451673319', + title: 'Fahrenheit 451', }, ], }, @@ -535,6 +536,7 @@ describe('EntityStore', () => { { __typename: 'Book', isbn: '0735211280', + title: "Spineless", }, ], }, @@ -1559,7 +1561,7 @@ describe('EntityStore', () => { expect(cache.identify(todoRef!)).toBe("Todo:123"); const taskRef = cache.writeFragment({ - fragment: gql`fragment TaskId on Task { id }`, + fragment: gql`fragment TaskId on Task { uuid }`, data: { __typename: "Task", uuid: "eb8cffcc-7a9e-4d8b-a517-7d987bf42138", diff --git a/src/cache/inmemory/__tests__/policies.ts b/src/cache/inmemory/__tests__/policies.ts index f4c98c30c6b..58fc8032af1 100644 --- a/src/cache/inmemory/__tests__/policies.ts +++ b/src/cache/inmemory/__tests__/policies.ts @@ -9,6 +9,7 @@ import { MockLink } from '../../../utilities/testing/mocking/mockLink'; import subscribeAndCount from '../../../utilities/testing/subscribeAndCount'; import { itAsync } from '../../../utilities/testing/itAsync'; import { FieldPolicy, StorageType } from "../policies"; +import { withErrorSpy } from "../../../testing"; function reverse(s: string) { return s.split("").reverse().join(""); @@ -253,7 +254,7 @@ describe("type policies", function () { checkAuthorName(cache); }); - it("complains about missing key fields", function () { + withErrorSpy(it, "complains about missing key fields", function () { const cache = new InMemoryCache({ typePolicies: { Book: { @@ -583,7 +584,7 @@ describe("type policies", function () { expect(result).toEqual(data); }); - it("assumes keyArgs:false when read and merge function present", function () { + withErrorSpy(it, "assumes keyArgs:false when read and merge function present", function () { const cache = new InMemoryCache({ typePolicies: { TypeA: { @@ -1281,7 +1282,7 @@ describe("type policies", function () { expect(cache.extract(true)).toEqual(expectedExtraction); }); - it("read and merge can cooperate through options.storage", function () { + withErrorSpy(it, "read and merge can cooperate through options.storage", function () { const cache = new InMemoryCache({ typePolicies: { Query: { @@ -1922,7 +1923,7 @@ describe("type policies", function () { }); }); - it("readField helper function calls custom read functions", function () { + withErrorSpy(it, "readField helper function calls custom read functions", function () { // Rather than writing ownTime data into the cache, we maintain it // externally in this object: const ownTimes: Record> = { @@ -3099,6 +3100,7 @@ describe("type policies", function () { node: { __typename: "SearchableItem", displayLabel: "James Turrell: Light knows when we’re looking", + description: "", }, }, ]; @@ -3528,7 +3530,7 @@ describe("type policies", function () { }); }); - it("runs nested merge functions as well as ancestors", function () { + withErrorSpy(it, "runs nested merge functions as well as ancestors", function () { let eventMergeCount = 0; let attendeeMergeCount = 0; diff --git a/src/cache/inmemory/__tests__/readFromStore.ts b/src/cache/inmemory/__tests__/readFromStore.ts index 40dedd5528b..5ac15d5f5be 100644 --- a/src/cache/inmemory/__tests__/readFromStore.ts +++ b/src/cache/inmemory/__tests__/readFromStore.ts @@ -21,6 +21,7 @@ import { jest.mock('optimism'); import { wrap } from 'optimism'; +import { withErrorSpy } from '../../../testing'; describe('resultCacheMaxSize', () => { const cache = new InMemoryCache(); @@ -1375,7 +1376,7 @@ describe('reading from the store', () => { }); }); - it("propagates eviction signals to parent queries", () => { + withErrorSpy(it, "propagates eviction signals to parent queries", () => { const cache = new InMemoryCache({ typePolicies: { Deity: { diff --git a/src/cache/inmemory/__tests__/roundtrip.ts b/src/cache/inmemory/__tests__/roundtrip.ts index 8604a09a883..7e1bd70a762 100644 --- a/src/cache/inmemory/__tests__/roundtrip.ts +++ b/src/cache/inmemory/__tests__/roundtrip.ts @@ -10,6 +10,7 @@ import { readQueryFromStore, withError, } from './helpers'; +import { withErrorSpy } from '../../../testing'; function assertDeeplyFrozen(value: any, stack: any[] = []) { if (value !== null && typeof value === 'object' && stack.indexOf(value) < 0) { @@ -315,7 +316,7 @@ describe('roundtrip', () => { // XXX this test is weird because it assumes the server returned an incorrect result // However, the user may have written this result with client.writeQuery. - it('should throw an error on two of the same inline fragment types', () => { + withErrorSpy(it, 'should throw an error on two of the same inline fragment types', () => { expect(() => { storeRoundtrip( gql` @@ -342,7 +343,7 @@ describe('roundtrip', () => { ], }, ); - }).toThrowError(/Missing field 'rank' /); + }).toThrowError(/Can't find field 'rank' /); }); it('should resolve fields it can on interface with non matching inline fragments', () => { @@ -455,7 +456,7 @@ describe('roundtrip', () => { }); }); - it('should throw on error on two of the same spread fragment types', () => { + withErrorSpy(it, 'should throw on error on two of the same spread fragment types', () => { expect(() => { storeRoundtrip( gql` @@ -486,7 +487,7 @@ describe('roundtrip', () => { ], }, ); - }).toThrowError(/Missing field 'rank' /); + }).toThrowError(/Can't find field 'rank' /); }); it('should resolve on @include and @skip with inline fragments', () => { diff --git a/src/cache/inmemory/__tests__/writeToStore.ts b/src/cache/inmemory/__tests__/writeToStore.ts index ee346b96b18..2abed99d149 100644 --- a/src/cache/inmemory/__tests__/writeToStore.ts +++ b/src/cache/inmemory/__tests__/writeToStore.ts @@ -20,6 +20,7 @@ import { itAsync } from '../../../utilities/testing/itAsync'; import { StoreWriter } from '../writeToStore'; import { defaultNormalizedCacheFactory, writeQueryToStore } from './helpers'; import { InMemoryCache } from '../inMemoryCache'; +import { withErrorSpy } from '../../../testing'; const getIdField = ({ id }: { id: string }) => id; @@ -1798,7 +1799,7 @@ describe('writing to the store', () => { } `; - it('should write the result data without validating its shape when a fragment matcher is not provided', () => { + withErrorSpy(it, 'should write the result data without validating its shape when a fragment matcher is not provided', () => { const result = { todos: [ { @@ -1823,7 +1824,7 @@ describe('writing to the store', () => { expect((newStore as any).lookup('1')).toEqual(result.todos[0]); }); - it('should warn when it receives the wrong data with non-union fragments', () => { + withErrorSpy(it, 'should warn when it receives the wrong data with non-union fragments', () => { const result = { todos: [ { @@ -1840,13 +1841,11 @@ describe('writing to the store', () => { }), ); - expect(() => { - writeQueryToStore({ - writer, - query, - result, - }); - }).toThrowError(/Missing field 'description' /); + writeQueryToStore({ + writer, + query, + result, + }); }); it('should warn when it receives the wrong data inside a fragment', () => { @@ -1893,13 +1892,11 @@ describe('writing to the store', () => { }), ); - expect(() => { - writeQueryToStore({ - writer, - query: queryWithInterface, - result, - }); - }).toThrowError(/Missing field 'price' /); + writeQueryToStore({ + writer, + query: queryWithInterface, + result, + }); }); it('should warn if a result is missing __typename when required', () => { @@ -1920,13 +1917,11 @@ describe('writing to the store', () => { }), ); - expect(() => { - writeQueryToStore({ - writer, - query: addTypenameToDocument(query), - result, - }); - }).toThrowError(/Missing field '__typename' /); + writeQueryToStore({ + writer, + query: addTypenameToDocument(query), + result, + }); }); it('should not warn if a field is null', () => { @@ -2190,7 +2185,7 @@ describe('writing to the store', () => { }); }); - it('should not keep reference when type of mixed inlined field changes to non-inlined field', () => { + withErrorSpy(it, 'should not keep reference when type of mixed inlined field changes to non-inlined field', () => { const store = defaultNormalizedCacheFactory(); const query = gql` diff --git a/src/core/__tests__/ObservableQuery.ts b/src/core/__tests__/ObservableQuery.ts index 7caa643e226..cc8ec7d8de8 100644 --- a/src/core/__tests__/ObservableQuery.ts +++ b/src/core/__tests__/ObservableQuery.ts @@ -1843,7 +1843,10 @@ describe('ObservableQuery', () => { value: 'oyez', }; } - client.writeQuery({ query, data }); + client.writeQuery({ + query: queryOptions.query, + data, + }); }, error(err) { expect(err.message).toMatch(/No more mocked responses/); diff --git a/src/core/__tests__/QueryManager/index.ts b/src/core/__tests__/QueryManager/index.ts index 6703a735ba6..8c30b7ced94 100644 --- a/src/core/__tests__/QueryManager/index.ts +++ b/src/core/__tests__/QueryManager/index.ts @@ -5741,7 +5741,11 @@ describe('QueryManager', () => { const queryManager = createQueryManager({ link: mockSingleLink({ request: { query }, - result: { author: { firstName: 'John' } }, + result: { + data: { + author: { firstName: 'John' }, + }, + }, }), }); @@ -5749,6 +5753,7 @@ describe('QueryManager', () => { expect(queryManager['inFlightLinkObservables'].size).toBe(1) }); + it('should allow overriding global queryDeduplication: true to false', () => { const query = gql` query { @@ -5757,10 +5762,15 @@ describe('QueryManager', () => { } } `; + const queryManager = createQueryManager({ link: mockSingleLink({ request: { query }, - result: { author: { firstName: 'John' } }, + result: { + data: { + author: { firstName: 'John' }, + }, + }, }), queryDeduplication: true, }); diff --git a/src/core/__tests__/fetchPolicies.ts b/src/core/__tests__/fetchPolicies.ts index 16cecb01b92..0e951595873 100644 --- a/src/core/__tests__/fetchPolicies.ts +++ b/src/core/__tests__/fetchPolicies.ts @@ -610,7 +610,7 @@ describe('cache-only', () => { })), }); - const query = gql`query { counter }`; + const query = gql`query { count }`; const observable = client.watchQuery({ query, diff --git a/src/react/components/__tests__/client/Query.test.tsx b/src/react/components/__tests__/client/Query.test.tsx index a95d18e5ad9..c6de0e546de 100644 --- a/src/react/components/__tests__/client/Query.test.tsx +++ b/src/react/components/__tests__/client/Query.test.tsx @@ -8,7 +8,7 @@ import { ApolloError } from '../../../../errors'; import { ApolloLink } from '../../../../link/core'; import { InMemoryCache as Cache } from '../../../../cache'; import { ApolloProvider } from '../../../context'; -import { itAsync, stripSymbols, MockedProvider, mockSingleLink } from '../../../../testing'; +import { itAsync, stripSymbols, MockedProvider, mockSingleLink, withErrorSpy } from '../../../../testing'; import { Query } from '../../Query'; const allPeopleQuery: DocumentNode = gql` @@ -1853,7 +1853,7 @@ describe('Query component', () => { console.warn = origConsoleWarn; }); - itAsync( + withErrorSpy(itAsync, 'should attempt a refetch when the query result was marked as being ' + 'partial, the returned data was reset to an empty Object by the ' + 'Apollo Client QueryManager (due to a cache miss), and the ' + diff --git a/src/react/components/__tests__/client/__snapshots__/Query.test.tsx.snap b/src/react/components/__tests__/client/__snapshots__/Query.test.tsx.snap index 8e19a2575ff..e4bafb7dc02 100644 --- a/src/react/components/__tests__/client/__snapshots__/Query.test.tsx.snap +++ b/src/react/components/__tests__/client/__snapshots__/Query.test.tsx.snap @@ -1,5 +1,21 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`Query component Partial refetching should attempt a refetch when the query result was marked as being partial, the returned data was reset to an empty Object by the Apollo Client QueryManager (due to a cache miss), and the \`partialRefetch\` prop is \`true\` 1`] = ` +[MockFunction] { + "calls": Array [ + Array [ + "Missing field 'allPeople' while writing result {}", + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], +} +`; + exports[`Query component calls the children prop: result in render prop 1`] = ` Object { "called": true, diff --git a/src/react/hooks/__tests__/__snapshots__/useQuery.test.tsx.snap b/src/react/hooks/__tests__/__snapshots__/useQuery.test.tsx.snap new file mode 100644 index 00000000000..45381278cbb --- /dev/null +++ b/src/react/hooks/__tests__/__snapshots__/useQuery.test.tsx.snap @@ -0,0 +1,17 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`useQuery Hook Partial refetching should attempt a refetch when the query result was marked as being partial, the returned data was reset to an empty Object by the Apollo Client QueryManager (due to a cache miss), and the \`partialRefetch\` prop is \`true\` 1`] = ` +[MockFunction] { + "calls": Array [ + Array [ + "Missing field 'allPeople' while writing result {}", + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], +} +`; diff --git a/src/react/hooks/__tests__/__snapshots__/useSubscription.test.tsx.snap b/src/react/hooks/__tests__/__snapshots__/useSubscription.test.tsx.snap new file mode 100644 index 00000000000..ad04394392b --- /dev/null +++ b/src/react/hooks/__tests__/__snapshots__/useSubscription.test.tsx.snap @@ -0,0 +1,47 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`useSubscription Hook should handle immediate completions gracefully 1`] = ` +[MockFunction] { + "calls": Array [ + Array [ + "Missing field 'car' while writing result {}", + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], +} +`; + +exports[`useSubscription Hook should handle immediate completions with multiple subscriptions gracefully 1`] = ` +[MockFunction] { + "calls": Array [ + Array [ + "Missing field 'car' while writing result {}", + ], + Array [ + "Missing field 'car' while writing result {}", + ], + Array [ + "Missing field 'car' while writing result {}", + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + Object { + "type": "return", + "value": undefined, + }, + Object { + "type": "return", + "value": undefined, + }, + ], +} +`; diff --git a/src/react/hooks/__tests__/useQuery.test.tsx b/src/react/hooks/__tests__/useQuery.test.tsx index 00201ec04b6..2f1bff39850 100644 --- a/src/react/hooks/__tests__/useQuery.test.tsx +++ b/src/react/hooks/__tests__/useQuery.test.tsx @@ -8,7 +8,7 @@ import { InMemoryCache } from '../../../cache'; import { ApolloProvider } from '../../context'; import { Observable, Reference, concatPagination } from '../../../utilities'; import { ApolloLink } from '../../../link/core'; -import { itAsync, MockLink, MockedProvider, mockSingleLink } from '../../../testing'; +import { itAsync, MockLink, MockedProvider, mockSingleLink, withErrorSpy } from '../../../testing'; import { useQuery } from '../useQuery'; import { useMutation } from '../useMutation'; import { QueryFunctionOptions } from '../..'; @@ -2022,7 +2022,7 @@ describe('useQuery Hook', () => { }); describe('Partial refetching', () => { - itAsync( + withErrorSpy(itAsync, 'should attempt a refetch when the query result was marked as being ' + 'partial, the returned data was reset to an empty Object by the ' + 'Apollo Client QueryManager (due to a cache miss), and the ' + @@ -2391,13 +2391,15 @@ describe('useQuery Hook', () => { let renderCount = 0; const Component = () => { const [mutate, { loading: mutationLoading }] = useMutation(mutation, { - optimisticResponse: carData, + optimisticResponse: { + addCar: carData, + }, update: (cache, { data }) => { cache.modify({ fields: { cars(existing, { readField }) { const newCarRef = cache.writeFragment({ - data, + data: data!.addCar, fragment: gql`fragment NewCar on Car { id make @@ -2405,7 +2407,7 @@ describe('useQuery Hook', () => { }`, }); if (existing.some( - (ref: Reference) => readField('id', ref) === data!.id + (ref: Reference) => readField('id', ref) === data!.addCar.id )) { return existing; } diff --git a/src/react/hooks/__tests__/useSubscription.test.tsx b/src/react/hooks/__tests__/useSubscription.test.tsx index 9bfe7ecbc7d..20a01f4eeed 100644 --- a/src/react/hooks/__tests__/useSubscription.test.tsx +++ b/src/react/hooks/__tests__/useSubscription.test.tsx @@ -5,7 +5,7 @@ import gql from 'graphql-tag'; import { ApolloClient, ApolloLink, concat } from '../../../core'; import { InMemoryCache as Cache } from '../../../cache'; import { ApolloProvider } from '../../context'; -import { MockSubscriptionLink } from '../../../testing'; +import { MockSubscriptionLink, withErrorSpy } from '../../../testing'; import { useSubscription } from '../useSubscription'; describe('useSubscription Hook', () => { @@ -441,7 +441,7 @@ describe('useSubscription Hook', () => { }); }); - it('should handle immediate completions gracefully', () => { + withErrorSpy(it, 'should handle immediate completions gracefully', () => { const subscription = gql` subscription { car { @@ -452,7 +452,7 @@ describe('useSubscription Hook', () => { const result = { result: { data: null }, - } + }; const link = new MockSubscriptionLink(); const client = new ApolloClient({ @@ -496,7 +496,7 @@ describe('useSubscription Hook', () => { }); }); - it('should handle immediate completions with multiple subscriptions gracefully', () => { + withErrorSpy(it, 'should handle immediate completions with multiple subscriptions gracefully', () => { const subscription = gql` subscription { car { @@ -507,7 +507,7 @@ describe('useSubscription Hook', () => { const result = { result: { data: null }, - } + }; const link = new MockSubscriptionLink(); const client = new ApolloClient({ From 1daffc2b097dff7f6547058da03cd0b20c5c4bb7 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 22 Jun 2021 12:17:48 -0400 Subject: [PATCH 258/380] Mention PR #8416 in CHANGELOG.md. --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bfe0fa81f3f..e64edb79fab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,9 @@ - The TypeScript return types of the `getLastResult` and `getLastError` methods of `ObservableQuery` now correctly include the possibility of returning `undefined`. If you happen to be calling either of these methods directly, you may need to adjust how the calling code handles the methods' possibly-`undefined` results.
[@benjamn](https://github.com/benjamn) in [#8394](https://github.com/apollographql/apollo-client/pull/8394) +- Log non-fatal `invariant.error` message when fields are missing from result objects written into `InMemoryCache`, rather than throwing an exception. While this change relaxes an exception to be merely an error message, which is usually a backwards-compatible change, the error messages are logged in more cases now than the exception was previously thrown, and those new error messages may be worth investigating to discover potential problems in your application. The errors are not displayed for `@client`-only fields, so adding `@client` is one way to handle/hide the errors for local-only fields. Another general strategy is to use a more precise query to write specific subsets of data into the cache, rather than reusing a larger query that contains fields not present in the written `data`.
+ [@benjamn](https://github.com/benjamn) in [#8416](https://github.com/apollographql/apollo-client/pull/8416) + ### Improvements - `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.
From 3a370c6e0cd27f744dfc2911f33e356dfeded167 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 22 Jun 2021 14:53:29 -0400 Subject: [PATCH 259/380] Bump @apollo/client npm version to 3.4.0-rc.12. --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 99f668b2dc4..70de79d84dd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-rc.11", + "version": "3.4.0-rc.12", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 06fb0ad1e27..e2be09838cd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-rc.11", + "version": "3.4.0-rc.12", "description": "A fully-featured caching GraphQL client.", "private": true, "keywords": [ From 4cb88cca0d0b0d33a52ca6c93b2b28bb5edd6008 Mon Sep 17 00:00:00 2001 From: "Henry Q. Dineen" Date: Tue, 22 Jun 2021 18:17:45 -0400 Subject: [PATCH 260/380] Use WeakSet in ObjectCanon only if available (#8402) --- src/__tests__/__snapshots__/exports.ts.snap | 1 + src/cache/inmemory/object-canon.ts | 3 ++- src/utilities/common/canUse.ts | 2 ++ 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/__tests__/__snapshots__/exports.ts.snap b/src/__tests__/__snapshots__/exports.ts.snap index bf8dd514224..f43c18e7b25 100644 --- a/src/__tests__/__snapshots__/exports.ts.snap +++ b/src/__tests__/__snapshots__/exports.ts.snap @@ -329,6 +329,7 @@ Array [ "asyncMap", "buildQueryFromSelectionSet", "canUseWeakMap", + "canUseWeakSet", "checkDocument", "cloneDeep", "compact", diff --git a/src/cache/inmemory/object-canon.ts b/src/cache/inmemory/object-canon.ts index 22f39b4243d..9ffb706418d 100644 --- a/src/cache/inmemory/object-canon.ts +++ b/src/cache/inmemory/object-canon.ts @@ -1,6 +1,7 @@ import { Trie } from "@wry/trie"; import { canUseWeakMap, + canUseWeakSet, isNonNullObject as isObjectOrArray, } from "../../utilities"; @@ -71,7 +72,7 @@ function shallowCopy(value: T): T { export class ObjectCanon { // Set of all canonical objects this ObjectCanon has admitted, allowing // canon.admit to return previously-canonicalized objects immediately. - private known = new (canUseWeakMap ? WeakSet : Set)(); + private known = new (canUseWeakSet ? WeakSet : Set)(); // Efficient storage/lookup structure for canonical objects. private pool = new Trie<{ diff --git a/src/utilities/common/canUse.ts b/src/utilities/common/canUse.ts index d985867903a..9e143496e44 100644 --- a/src/utilities/common/canUse.ts +++ b/src/utilities/common/canUse.ts @@ -2,3 +2,5 @@ export const canUseWeakMap = typeof WeakMap === 'function' && !( typeof navigator === 'object' && navigator.product === 'ReactNative' ); + +export const canUseWeakSet = typeof WeakSet === 'function'; From d76c5a719244f0014867244ab09ebb0371017200 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Thu, 10 Jun 2021 15:14:44 -0400 Subject: [PATCH 261/380] inline Reobserver into ObservableQuery --- src/core/ObservableQuery.ts | 194 ++++++++++++++++++++++++++---------- 1 file changed, 141 insertions(+), 53 deletions(-) diff --git a/src/core/ObservableQuery.ts b/src/core/ObservableQuery.ts index aafb93323e3..715ae99764c 100644 --- a/src/core/ObservableQuery.ts +++ b/src/core/ObservableQuery.ts @@ -3,6 +3,8 @@ import { equal } from '@wry/equality'; import { NetworkStatus, isNetworkRequestInFlight } from './networkStatus'; import { + Concast, + compact, cloneDeep, getOperationDefinition, Observable, @@ -20,7 +22,6 @@ import { FetchMoreQueryOptions, SubscribeToMoreOptions, } from './watchQueryOptions'; -import { Reobserver } from './Reobserver'; import { QueryInfo } from './QueryInfo'; export interface FetchMoreOptions< @@ -66,6 +67,12 @@ export class ObservableQuery< private lastError: ApolloError | undefined; private queryInfo: QueryInfo; + private concast?: Concast>; + private pollingInfo?: { + interval: number; + timeout: ReturnType; + }; + private shouldFetch: false | (() => boolean); constructor({ queryManager, queryInfo, @@ -93,6 +100,11 @@ export class ObservableQuery< this.queryManager = queryManager; this.queryInfo = queryInfo; + + // Avoid polling during SSR and when the query is already in flight. + this.shouldFetch = + !this.queryManager.ssrMode && + (() => !isNetworkRequestInFlight(this.queryInfo.networkStatus)); } public result(): Promise> { @@ -238,11 +250,12 @@ export class ObservableQuery< const reobserveOptions: Partial> = { // Always disable polling for refetches. pollInterval: 0, + // Unless the provided fetchPolicy always consults the network + // (no-cache, network-only, or cache-and-network), override it with + // network-only to force the refetch for this fetchQuery call. + fetchPolicy: 'network-only', }; - // Unless the provided fetchPolicy always consults the network - // (no-cache, network-only, or cache-and-network), override it with - // network-only to force the refetch for this fetchQuery call. const { fetchPolicy } = this.options; if (fetchPolicy !== 'no-cache' && fetchPolicy !== 'cache-and-network') { @@ -260,11 +273,7 @@ export class ObservableQuery< } this.queryInfo.resetLastWrite(); - - return this.newReobserver(false).reobserve( - reobserveOptions, - NetworkStatus.refetch, - ); + return this.reobserve(reobserveOptions, NetworkStatus.refetch); } public fetchMore( @@ -493,13 +502,103 @@ once, rather than every time you call fetchMore.`); } public startPolling(pollInterval: number) { - this.getReobserver().updateOptions({ pollInterval }); + this.updateOptions({ pollInterval }); } public stopPolling() { - if (this.reobserver) { - this.reobserver.updateOptions({ pollInterval: 0 }); + this.updateOptions({ pollInterval: 0 }); + } + + public updateOptions( + newOptions: Partial>, + ) { + Object.assign(this.options, compact(newOptions)); + this.updatePolling(); + return this; + } + + private fetch( + options: WatchQueryOptions, + newNetworkStatus?: NetworkStatus, + ): Concast> { + this.queryManager.setObservableQuery(this); + return this.queryManager.fetchQueryObservable( + this.queryId, + options, + newNetworkStatus, + ); + } + + public stop() { + if (this.concast) { + this.concast.removeObserver(this.observer); + delete this.concast; + } + + if (this.pollingInfo) { + clearTimeout(this.pollingInfo.timeout); + this.options.pollInterval = 0; + this.updatePolling(); + } + } + + // Turns polling on or off based on this.options.pollInterval. + private updatePolling() { + const { + pollingInfo, + options: { + pollInterval, + }, + } = this; + + if (!pollInterval) { + if (pollingInfo) { + clearTimeout(pollingInfo.timeout); + delete this.pollingInfo; + } + return; + } + + if (pollingInfo && + pollingInfo.interval === pollInterval) { + return; + } + + invariant( + pollInterval, + 'Attempted to start a polling query without a polling interval.', + ); + + // Go no further if polling is disabled. + if (this.shouldFetch === false) { + return; } + + const info = pollingInfo || (this.pollingInfo = {} as any); + info.interval = pollInterval; + + const maybeFetch = () => { + if (this.pollingInfo) { + if (this.shouldFetch && this.shouldFetch()) { + this.reobserve({ + fetchPolicy: "network-only", + nextFetchPolicy: this.options.fetchPolicy || "cache-first", + }, NetworkStatus.poll).then(poll, poll); + } else { + poll(); + } + }; + }; + + const poll = () => { + const info = this.pollingInfo; + if (info) { + clearTimeout(info.timeout); + info.timeout = setTimeout(maybeFetch, info.interval); + } + }; + + poll(); } private updateLastResult(newResult: ApolloQueryResult) { @@ -520,7 +619,7 @@ once, rather than every time you call fetchMore.`); // this.observers can be satisfied without doing anything, which is // why we do not bother throwing an error here. if (observer === this.observer) { - return () => {}; + throw new Error('This never actually happens'); } // Zen Observable has its own error function, so in order to log correctly @@ -559,45 +658,42 @@ once, rather than every time you call fetchMore.`); }; } - private reobserver?: Reobserver; - - private getReobserver(): Reobserver { - return this.reobserver || (this.reobserver = this.newReobserver(true)); - } - - private newReobserver(shareOptions: boolean) { - const { queryManager, queryId } = this; - queryManager.setObservableQuery(this); - return new Reobserver( - this.observer, - // Sharing options allows this.reobserver.options to be === - // this.options, so we don't have to worry about synchronizing the - // properties of two distinct objects. - shareOptions ? this.options : { ...this.options }, - (currentOptions, newNetworkStatus) => { - queryManager.setObservableQuery(this); - return queryManager.fetchQueryObservable( - queryId, - currentOptions, - newNetworkStatus, - ); - }, - // Avoid polling during SSR and when the query is already in flight. - !queryManager.ssrMode && ( - () => !isNetworkRequestInFlight(this.queryInfo.networkStatus)) - ); - } - public reobserve( newOptions?: Partial>, newNetworkStatus?: NetworkStatus, ): Promise> { this.isTornDown = false; - return this.getReobserver().reobserve(newOptions, newNetworkStatus); + let options: WatchQueryOptions; + if (newNetworkStatus === NetworkStatus.refetch) { + options = Object.assign({}, this.options, compact(newOptions)); + } else if (newOptions) { + this.updateOptions(newOptions); + options = this.options; + } else { + // When we call this.updateOptions(newOptions) in the branch above, + // it takes care of calling this.updatePolling(). In this branch, we + // still need to update polling, even if there were no newOptions. + this.updatePolling(); + options = this.options; + } + + const concast = this.fetch(options, newNetworkStatus); + if (this.concast) { + // We use the {add,remove}Observer methods directly to avoid + // wrapping observer with an unnecessary SubscriptionObserver + // object, in part so that we can remove it here without triggering + // any unsubscriptions, because we just want to ignore the old + // observable, not prematurely shut it down, since other consumers + // may be awaiting this.concast.promise. + this.concast.removeObserver(this.observer, true); + } + + concast.addObserver(this.observer); + return (this.concast = concast).promise; } // Pass the current result to this.observer.next without applying any - // fetch policies, bypassing the Reobserver. + // fetch policies. private observe() { // Passing false is important so that this.getCurrentResult doesn't // save the fetchMore result as this.lastResult, causing it to be @@ -635,20 +731,12 @@ once, rather than every time you call fetchMore.`); private tearDownQuery() { if (this.isTornDown) return; - - if (this.reobserver) { - this.reobserver.stop(); - delete this.reobserver; - } - + this.stop(); // stop all active GraphQL subscriptions this.subscriptions.forEach(sub => sub.unsubscribe()); this.subscriptions.clear(); - this.queryManager.stopQuery(this.queryId); - this.observers.clear(); - this.isTornDown = true; } } From 56ac597a1d88ae4705f9dca1f95204b5de7ff36a Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Fri, 11 Jun 2021 23:28:54 -0400 Subject: [PATCH 262/380] fix no-cache not working --- src/core/ObservableQuery.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/core/ObservableQuery.ts b/src/core/ObservableQuery.ts index 715ae99764c..f8e2321bce7 100644 --- a/src/core/ObservableQuery.ts +++ b/src/core/ObservableQuery.ts @@ -250,15 +250,16 @@ export class ObservableQuery< const reobserveOptions: Partial> = { // Always disable polling for refetches. pollInterval: 0, - // Unless the provided fetchPolicy always consults the network - // (no-cache, network-only, or cache-and-network), override it with - // network-only to force the refetch for this fetchQuery call. fetchPolicy: 'network-only', }; + // Unless the provided fetchPolicy always consults the network + // (no-cache, network-only, or cache-and-network), override it with + // network-only to force the refetch for this fetchQuery call. const { fetchPolicy } = this.options; - if (fetchPolicy !== 'no-cache' && - fetchPolicy !== 'cache-and-network') { + if (fetchPolicy === 'no-cache') { + reobserveOptions.fetchPolicy = 'no-cache'; + } else if (fetchPolicy !== 'cache-and-network') { reobserveOptions.fetchPolicy = 'network-only'; // Go back to the original options.fetchPolicy after this refetch. reobserveOptions.nextFetchPolicy = fetchPolicy || "cache-first"; From 13301dfc83d865b27f0c19842a3c360b95535f15 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Tue, 15 Jun 2021 17:21:10 -0400 Subject: [PATCH 263/380] use a separate concast for refetch --- src/core/ObservableQuery.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/core/ObservableQuery.ts b/src/core/ObservableQuery.ts index f8e2321bce7..2ff866bf5bd 100644 --- a/src/core/ObservableQuery.ts +++ b/src/core/ObservableQuery.ts @@ -679,18 +679,22 @@ once, rather than every time you call fetchMore.`); } const concast = this.fetch(options, newNetworkStatus); - if (this.concast) { + if (newNetworkStatus !== NetworkStatus.refetch) { // We use the {add,remove}Observer methods directly to avoid // wrapping observer with an unnecessary SubscriptionObserver // object, in part so that we can remove it here without triggering // any unsubscriptions, because we just want to ignore the old // observable, not prematurely shut it down, since other consumers // may be awaiting this.concast.promise. - this.concast.removeObserver(this.observer, true); + if (this.concast) { + this.concast.removeObserver(this.observer, true); + } + + this.concast = concast; } concast.addObserver(this.observer); - return (this.concast = concast).promise; + return concast.promise; } // Pass the current result to this.observer.next without applying any From a9170356182bd60978e0796a88315e8bfc224563 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Wed, 16 Jun 2021 12:05:13 -0400 Subject: [PATCH 264/380] delete fetchPolicy line --- src/core/ObservableQuery.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/core/ObservableQuery.ts b/src/core/ObservableQuery.ts index 2ff866bf5bd..13da5605087 100644 --- a/src/core/ObservableQuery.ts +++ b/src/core/ObservableQuery.ts @@ -250,7 +250,6 @@ export class ObservableQuery< const reobserveOptions: Partial> = { // Always disable polling for refetches. pollInterval: 0, - fetchPolicy: 'network-only', }; // Unless the provided fetchPolicy always consults the network From 8b056839b446b3dc326eabe61301a0e88e7f7e15 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Wed, 16 Jun 2021 12:06:43 -0400 Subject: [PATCH 265/380] murder Reobserver Take the gun. Leave the canoli. --- src/core/Reobserver.ts | 154 ----------------------------------------- 1 file changed, 154 deletions(-) delete mode 100644 src/core/Reobserver.ts diff --git a/src/core/Reobserver.ts b/src/core/Reobserver.ts deleted file mode 100644 index e5cc8ed28bc..00000000000 --- a/src/core/Reobserver.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { WatchQueryOptions } from './watchQueryOptions'; -import { NetworkStatus } from './networkStatus'; -import { ApolloQueryResult } from './types'; -import { Observer, Concast, compact } from '../utilities'; -import { invariant } from 'ts-invariant'; - -// Given that QueryManager#fetchQueryObservable returns only a single -// query's worth of results, other code must be responsible for repeatedly -// calling fetchQueryObservable, while ensuring that the ObservableQuery -// consuming those results remains subscribed to the concatenation of all -// the observables returned by fetchQueryObservable. That responsibility -// falls to this Reobserver class. As a bonus, the Reobserver class is -// perfectly poised to handle polling logic, since polling is essentially -// repeated reobservation. In principle, this code could have remained in -// the ObservableQuery class, but I felt it would be easier to explain and -// understand reobservation if it was confined to a separate class. -export class Reobserver { - constructor( - private observer: Observer>, - private options: WatchQueryOptions, - // Almost certainly just a wrapper function around - // QueryManager#fetchQueryObservable, but this small dose of - // indirection means the Reobserver doesn't have to know/assume - // anything about the QueryManager class. - private fetch: ( - options: WatchQueryOptions, - newNetworkStatus?: NetworkStatus, - ) => Concast>, - // If we're polling, there may be times when we should avoid fetching, - // such as when the query is already in flight, or polling has been - // completely disabled for server-side rendering. Passing false for - // this parameter disables polling completely, and passing a boolean - // function allows determining fetch safety dynamically. - private shouldFetch: false | (() => boolean), - ) {} - - private concast?: Concast>; - - public reobserve( - newOptions?: Partial>, - newNetworkStatus?: NetworkStatus, - ): Promise> { - if (newOptions) { - this.updateOptions(newOptions); - } else { - // When we call this.updateOptions(newOptions) in the branch above, - // it takes care of calling this.updatePolling(). In this branch, we - // still need to update polling, even if there were no newOptions. - this.updatePolling(); - } - - const concast = this.fetch(this.options, newNetworkStatus); - - if (this.concast) { - // We use the {add,remove}Observer methods directly to avoid - // wrapping observer with an unnecessary SubscriptionObserver - // object, in part so that we can remove it here without triggering - // any unsubscriptions, because we just want to ignore the old - // observable, not prematurely shut it down, since other consumers - // may be awaiting this.concast.promise. - this.concast.removeObserver(this.observer, true); - } - - concast.addObserver(this.observer); - - return (this.concast = concast).promise; - } - - public updateOptions(newOptions: Partial>) { - Object.assign(this.options, compact(newOptions)); - this.updatePolling(); - return this; - } - - public stop() { - if (this.concast) { - this.concast.removeObserver(this.observer); - delete this.concast; - } - - if (this.pollingInfo) { - clearTimeout(this.pollingInfo.timeout); - this.options.pollInterval = 0; - this.updatePolling(); - } - } - - private pollingInfo?: { - interval: number; - timeout: ReturnType; - }; - - // Turns polling on or off based on this.options.pollInterval. - private updatePolling() { - const { - pollingInfo, - options: { - pollInterval, - }, - } = this; - - if (!pollInterval) { - if (pollingInfo) { - clearTimeout(pollingInfo.timeout); - delete this.pollingInfo; - } - return; - } - - if (pollingInfo && - pollingInfo.interval === pollInterval) { - return; - } - - invariant( - pollInterval, - 'Attempted to start a polling query without a polling interval.', - ); - - // Go no further if polling is disabled. - if (this.shouldFetch === false) { - return; - } - - const info = pollingInfo || ( - this.pollingInfo = {} as Reobserver["pollingInfo"] - )!; - - info.interval = pollInterval; - - const maybeFetch = () => { - if (this.pollingInfo) { - if (this.shouldFetch && this.shouldFetch()) { - this.reobserve({ - fetchPolicy: "network-only", - nextFetchPolicy: this.options.fetchPolicy || "cache-first", - }, NetworkStatus.poll).then(poll, poll); - } else { - poll(); - } - }; - }; - - const poll = () => { - const info = this.pollingInfo; - if (info) { - clearTimeout(info.timeout); - info.timeout = setTimeout(maybeFetch, info.interval); - } - }; - - poll(); - } -} From b98a2682b5a0bd27a85c0754fb5041cd97b7f9dc Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Wed, 16 Jun 2021 13:36:10 -0400 Subject: [PATCH 266/380] inline shouldFetch --- src/core/ObservableQuery.ts | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/src/core/ObservableQuery.ts b/src/core/ObservableQuery.ts index 13da5605087..ee63f310927 100644 --- a/src/core/ObservableQuery.ts +++ b/src/core/ObservableQuery.ts @@ -72,7 +72,7 @@ export class ObservableQuery< interval: number; timeout: ReturnType; }; - private shouldFetch: false | (() => boolean); + constructor({ queryManager, queryInfo, @@ -98,13 +98,7 @@ export class ObservableQuery< // related classes this.queryManager = queryManager; - this.queryInfo = queryInfo; - - // Avoid polling during SSR and when the query is already in flight. - this.shouldFetch = - !this.queryManager.ssrMode && - (() => !isNetworkRequestInFlight(this.queryInfo.networkStatus)); } public result(): Promise> { @@ -544,6 +538,11 @@ once, rather than every time you call fetchMore.`); // Turns polling on or off based on this.options.pollInterval. private updatePolling() { + // Avoid polling in SSR mode + if (this.queryManager.ssrMode) { + return; + } + const { pollingInfo, options: { @@ -569,17 +568,12 @@ once, rather than every time you call fetchMore.`); 'Attempted to start a polling query without a polling interval.', ); - // Go no further if polling is disabled. - if (this.shouldFetch === false) { - return; - } - const info = pollingInfo || (this.pollingInfo = {} as any); info.interval = pollInterval; const maybeFetch = () => { if (this.pollingInfo) { - if (this.shouldFetch && this.shouldFetch()) { + if (!isNetworkRequestInFlight(this.queryInfo.networkStatus)) { this.reobserve({ fetchPolicy: "network-only", nextFetchPolicy: this.options.fetchPolicy || "cache-first", From 1796b496eaa82eb31d681b632dce2f45abce39f4 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Wed, 16 Jun 2021 14:34:43 -0400 Subject: [PATCH 267/380] inline onSubscribe --- src/core/ObservableQuery.ts | 92 ++++++++++++++++++------------------- 1 file changed, 44 insertions(+), 48 deletions(-) diff --git a/src/core/ObservableQuery.ts b/src/core/ObservableQuery.ts index ee63f310927..a0a279693de 100644 --- a/src/core/ObservableQuery.ts +++ b/src/core/ObservableQuery.ts @@ -82,9 +82,50 @@ export class ObservableQuery< queryInfo: QueryInfo; options: WatchQueryOptions; }) { - super((observer: Observer>) => - this.onSubscribe(observer), - ); + super((observer: Observer>) => { + // Subscribing using this.observer will create an infinite notificaion + // loop, but the intent of broadcasting results to all the other + // this.observers can be satisfied without doing anything, which is + // why we do not bother throwing an error here. + if (observer === this.observer) { + throw new Error('This never actually happens'); + } + + // Zen Observable has its own error function, so in order to log correctly + // we need to provide a custom error callback. + try { + var subObserver = (observer as any)._subscription._observer; + if (subObserver && !subObserver.error) { + subObserver.error = defaultSubscriptionObserverErrorCallback; + } + } catch {} + + const first = !this.observers.size; + this.observers.add(observer); + + // Deliver most recent error or result. + if (this.lastError) { + observer.error && observer.error(this.lastError); + } else if (this.lastResult) { + observer.next && observer.next(this.lastResult); + } + + // Initiate observation of this query if it hasn't been reported to + // the QueryManager yet. + if (first) { + // Blindly catching here prevents unhandled promise rejections, + // and is safe because the ObservableQuery handles this error with + // this.observer.error, so we're not just swallowing the error by + // ignoring it here. + this.reobserve().catch(() => {}); + } + + return () => { + if (this.observers.delete(observer) && !this.observers.size) { + this.tearDownQuery(); + } + }; + }); // active state this.isTornDown = false; @@ -607,51 +648,6 @@ once, rather than every time you call fetchMore.`); return previousResult; } - private onSubscribe(observer: Observer>) { - // Subscribing using this.observer will create an infinite notificaion - // loop, but the intent of broadcasting results to all the other - // this.observers can be satisfied without doing anything, which is - // why we do not bother throwing an error here. - if (observer === this.observer) { - throw new Error('This never actually happens'); - } - - // Zen Observable has its own error function, so in order to log correctly - // we need to provide a custom error callback. - try { - var subObserver = (observer as any)._subscription._observer; - if (subObserver && !subObserver.error) { - subObserver.error = defaultSubscriptionObserverErrorCallback; - } - } catch {} - - const first = !this.observers.size; - this.observers.add(observer); - - // Deliver most recent error or result. - if (this.lastError) { - observer.error && observer.error(this.lastError); - } else if (this.lastResult) { - observer.next && observer.next(this.lastResult); - } - - // Initiate observation of this query if it hasn't been reported to - // the QueryManager yet. - if (first) { - // Blindly catching here prevents unhandled promise rejections, - // and is safe because the ObservableQuery handles this error with - // this.observer.error, so we're not just swallowing the error by - // ignoring it here. - this.reobserve().catch(() => {}); - } - - return () => { - if (this.observers.delete(observer) && !this.observers.size) { - this.tearDownQuery(); - } - }; - } - public reobserve( newOptions?: Partial>, newNetworkStatus?: NetworkStatus, From e0e0390de7a15f6b2f943514ac1b16212f0c38d8 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Wed, 16 Jun 2021 16:12:07 -0400 Subject: [PATCH 268/380] move startQuerySubscription to afterExecute --- src/react/data/QueryData.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/react/data/QueryData.ts b/src/react/data/QueryData.ts index 33d3df55c11..a72cb069bbd 100644 --- a/src/react/data/QueryData.ts +++ b/src/react/data/QueryData.ts @@ -72,8 +72,6 @@ export class QueryData extends OperationData< this.updateObservableQuery(); - if (this.isMounted) this.startQuerySubscription(); - return this.getExecuteSsrResult() || this.getExecuteResult(); } @@ -101,6 +99,10 @@ export class QueryData extends OperationData< public afterExecute({ lazy = false }: { lazy?: boolean } = {}) { this.isMounted = true; + if (this.currentObservable) { + this.startQuerySubscription(); + } + if (!lazy || this.runLazy) { this.handleErrorOrCompleted(); } From 43d64356e656c42bd28523394e07c1c0584aa4b2 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Thu, 17 Jun 2021 11:47:06 -0400 Subject: [PATCH 269/380] delete another usage of startQuerySubscription --- src/react/data/QueryData.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/react/data/QueryData.ts b/src/react/data/QueryData.ts index a72cb069bbd..22a8c3af732 100644 --- a/src/react/data/QueryData.ts +++ b/src/react/data/QueryData.ts @@ -98,8 +98,13 @@ export class QueryData extends OperationData< public afterExecute({ lazy = false }: { lazy?: boolean } = {}) { this.isMounted = true; - - if (this.currentObservable) { + const options = this.getOptions(); + const ssrDisabled = options.ssr === false; + if ( + this.currentObservable && + !ssrDisabled && + !this.ssrInitiated() + ) { this.startQuerySubscription(); } @@ -107,7 +112,7 @@ export class QueryData extends OperationData< this.handleErrorOrCompleted(); } - this.previousOptions = this.getOptions(); + this.previousOptions = options; return this.unmount.bind(this); } @@ -151,9 +156,7 @@ export class QueryData extends OperationData< }; private getExecuteResult(): QueryResult { - const result = this.getQueryResult(); - this.startQuerySubscription(); - return result; + return this.getQueryResult(); }; private getExecuteSsrResult() { From c32a1aba54bde8c223adbc754e74706b7e9f4a0c Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Mon, 21 Jun 2021 16:29:57 -0400 Subject: [PATCH 270/380] Fix react/components/__tests__/client/Query.test.tsx --- .../__tests__/client/Query.test.tsx | 115 ++---------------- 1 file changed, 8 insertions(+), 107 deletions(-) diff --git a/src/react/components/__tests__/client/Query.test.tsx b/src/react/components/__tests__/client/Query.test.tsx index c6de0e546de..add56080690 100644 --- a/src/react/components/__tests__/client/Query.test.tsx +++ b/src/react/components/__tests__/client/Query.test.tsx @@ -773,12 +773,8 @@ describe('Query component', () => { componentDidMount() { setTimeout(() => { - this.setState({ - variables: { - first: 2, - }, - }); - }); + this.setState({ variables: { first: 2 } }); + }, 10); } onCompleted(data: Data | {}) { @@ -1122,98 +1118,6 @@ describe('Query component', () => { return wait(() => expect(count).toBe(2)).then(resolve, reject); }); - itAsync('use client from props or context', (resolve, reject) => { - jest.useFakeTimers(); - - function newClient(name: string) { - const link = mockSingleLink({ - request: { query: allPeopleQuery }, - result: { data: { allPeople: { people: [{ name }] } } }, - }); - - return new ApolloClient({ - link, - cache: new Cache({ addTypename: false }), - name, - }); - } - - const skywalker = newClient('Luke Skywalker'); - const ackbar = newClient('Admiral Ackbar'); - const solo = newClient('Han Solo'); - - const propsChanges = [ - { - propsClient: null, - contextClient: ackbar, - renderedName: (name: string) => - expect(name).toEqual('Admiral Ackbar'), - }, - { - propsClient: null, - contextClient: skywalker, - renderedName: (name: string) => - expect(name).toEqual('Luke Skywalker'), - }, - { - propsClient: solo, - contextClient: skywalker, - renderedName: (name: string) => expect(name).toEqual('Han Solo'), - }, - { - propsClient: null, - contextClient: ackbar, - renderedName: (name: string) => - expect(name).toEqual('Admiral Ackbar'), - }, - { - propsClient: skywalker, - contextClient: null, - renderedName: (name: string) => - expect(name).toEqual('Luke Skywalker'), - }, - ]; - - class Component extends React.Component { - render() { - if (Object.keys(this.props).length === 0) { - return null; - } - - const query = ( - - {(result: any) => { - if (result.data && result.data.allPeople) { - this.props.renderedName(result.data.allPeople.people[0].name); - } - - return null; - }} - - ); - - if (this.props.contextClient) { - return ( - - {query} - - ); - } - - return query; - } - } - - const { rerender } = render(); - - propsChanges.forEach((props) => { - rerender(); - jest.runAllTimers(); - }); - - return wait().then(resolve, reject); - }); - itAsync('with data while loading', (resolve, reject) => { const query = gql` query people($first: Int) { @@ -1255,12 +1159,8 @@ describe('Query component', () => { componentDidMount() { setTimeout(() => { - this.setState({ - variables: { - first: 2, - }, - }); - }, 50); + this.setState({ variables: { first: 2 } }); + }, 10); } render() { @@ -1575,8 +1475,7 @@ describe('Query component', () => { }); itAsync( - 'should not persist previous result errors when a subsequent valid ' + - 'result is received', + 'should not persist previous result errors when a subsequent valid result is received', (resolve, reject) => { const query: DocumentNode = gql` query somethingelse($variable: Boolean) { @@ -1735,7 +1634,9 @@ describe('Query component', () => { }; componentDidMount() { - setTimeout(() => this.setState({ variables: { first: 2 } })); + setTimeout(() => { + this.setState({ variables: { first: 2 } }); + }, 10); } onCompleted() { From 5215a6fbac29af5bec13933d1ee558800404f4dd Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Mon, 21 Jun 2021 17:17:25 -0400 Subject: [PATCH 271/380] fix hoc test --- src/react/hoc/__tests__/queries/skip.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/react/hoc/__tests__/queries/skip.test.tsx b/src/react/hoc/__tests__/queries/skip.test.tsx index 24285bbaf54..ff0d0b1a3dc 100644 --- a/src/react/hoc/__tests__/queries/skip.test.tsx +++ b/src/react/hoc/__tests__/queries/skip.test.tsx @@ -628,7 +628,7 @@ describe('[queries] skip', () => { switch (++count) { case 1: expect(this.props.data.loading).toBe(true); - expect(ranQuery).toBe(1); + expect(ranQuery).toBe(0); break; case 2: // The first batch of data is fetched over the network, and From 3f8bc8cf9f7361cd32c58ba925945a68cb934083 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Mon, 21 Jun 2021 20:00:59 -0400 Subject: [PATCH 272/380] add a lil delay to mutations test --- .../hoc/__tests__/mutations/queries.test.tsx | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/react/hoc/__tests__/mutations/queries.test.tsx b/src/react/hoc/__tests__/mutations/queries.test.tsx index 5ba0bb3c676..46dedcb518b 100644 --- a/src/react/hoc/__tests__/mutations/queries.test.tsx +++ b/src/react/hoc/__tests__/mutations/queries.test.tsx @@ -281,13 +281,15 @@ describe('graphql(mutation) query integration', () => { > { render() { if (count === 1) { - this.props.mutate!() - .then(result => { - expect(stripSymbols(result && result.data)).toEqual( - mutationData - ); - }) - .catch(reject); + setTimeout(() => { + this.props.mutate!() + .then(result => { + expect(stripSymbols(result && result.data)).toEqual( + mutationData + ); + }) + .catch(reject); + }); } return null; } From 52141db2193090dbe32facaf95db256e7c6a0c12 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Mon, 21 Jun 2021 20:14:56 -0400 Subject: [PATCH 273/380] add delay to mutation test --- src/react/components/__tests__/client/Mutation.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/react/components/__tests__/client/Mutation.test.tsx b/src/react/components/__tests__/client/Mutation.test.tsx index e3c3861d3d8..b204767871d 100644 --- a/src/react/components/__tests__/client/Mutation.test.tsx +++ b/src/react/components/__tests__/client/Mutation.test.tsx @@ -893,7 +893,7 @@ describe('General Mutation testing', () => { {(resultQuery: any) => { if (count === 0) { - setTimeout(() => createTodo()); + setTimeout(() => createTodo(), 10); } else if (count === 1) { expect(resultMutation.loading).toBe(false); expect(resultQuery.loading).toBe(false); @@ -1070,7 +1070,7 @@ describe('General Mutation testing', () => { {(resultQuery: any) => { if (count === 0) { - setTimeout(() => createTodo({ refetchQueries })); + setTimeout(() => createTodo({ refetchQueries }), 10); } else if (count === 1) { expect(resultMutation.loading).toBe(false); expect(resultQuery.loading).toBe(false); From 10cc2daf214f2bfcef950caccd25ed967efb5e8e Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Tue, 22 Jun 2021 00:31:43 -0400 Subject: [PATCH 274/380] fix ObservableQuery visibility --- src/core/ObservableQuery.ts | 53 +++++++++++-------------------------- 1 file changed, 16 insertions(+), 37 deletions(-) diff --git a/src/core/ObservableQuery.ts b/src/core/ObservableQuery.ts index a0a279693de..2cc2ae523f5 100644 --- a/src/core/ObservableQuery.ts +++ b/src/core/ObservableQuery.ts @@ -83,14 +83,6 @@ export class ObservableQuery< options: WatchQueryOptions; }) { super((observer: Observer>) => { - // Subscribing using this.observer will create an infinite notificaion - // loop, but the intent of broadcasting results to all the other - // this.observers can be satisfied without doing anything, which is - // why we do not bother throwing an error here. - if (observer === this.observer) { - throw new Error('This never actually happens'); - } - // Zen Observable has its own error function, so in order to log correctly // we need to provide a custom error callback. try { @@ -144,6 +136,9 @@ export class ObservableQuery< public result(): Promise> { return new Promise((resolve, reject) => { + // TODO: this code doesn’t actually make sense insofar as the observer + // will never exist in this.observers due how zen-observable wraps observables. + // https://github.com/zenparsing/zen-observable/blob/master/src/Observable.js#L169 const observer: Observer> = { next: (result: ApolloQueryResult) => { resolve(result); @@ -537,19 +532,13 @@ once, rather than every time you call fetchMore.`); } public startPolling(pollInterval: number) { - this.updateOptions({ pollInterval }); + this.options.pollInterval = pollInterval; + this.updatePolling(); } public stopPolling() { - this.updateOptions({ pollInterval: 0 }); - } - - public updateOptions( - newOptions: Partial>, - ) { - Object.assign(this.options, compact(newOptions)); + this.options.pollInterval = 0; this.updatePolling(); - return this; } private fetch( @@ -564,19 +553,6 @@ once, rather than every time you call fetchMore.`); ); } - public stop() { - if (this.concast) { - this.concast.removeObserver(this.observer); - delete this.concast; - } - - if (this.pollingInfo) { - clearTimeout(this.pollingInfo.timeout); - this.options.pollInterval = 0; - this.updatePolling(); - } - } - // Turns polling on or off based on this.options.pollInterval. private updatePolling() { // Avoid polling in SSR mode @@ -656,13 +632,11 @@ once, rather than every time you call fetchMore.`); let options: WatchQueryOptions; if (newNetworkStatus === NetworkStatus.refetch) { options = Object.assign({}, this.options, compact(newOptions)); - } else if (newOptions) { - this.updateOptions(newOptions); - options = this.options; } else { - // When we call this.updateOptions(newOptions) in the branch above, - // it takes care of calling this.updatePolling(). In this branch, we - // still need to update polling, even if there were no newOptions. + if (newOptions) { + Object.assign(this.options, compact(newOptions)); + } + this.updatePolling(); options = this.options; } @@ -725,7 +699,12 @@ once, rather than every time you call fetchMore.`); private tearDownQuery() { if (this.isTornDown) return; - this.stop(); + if (this.concast) { + this.concast.removeObserver(this.observer); + delete this.concast; + } + + this.stopPolling(); // stop all active GraphQL subscriptions this.subscriptions.forEach(sub => sub.unsubscribe()); this.subscriptions.clear(); From 014078a8b7217b0af0df7bf3fc6e0318e019e6ac Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Tue, 22 Jun 2021 00:41:23 -0400 Subject: [PATCH 275/380] update bundlesize --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e2be09838cd..da5386f645f 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ { "name": "apollo-client", "path": "./dist/apollo-client.cjs.min.js", - "maxSize": "24.7 kB" + "maxSize": "24.35 kB" } ], "peerDependencies": { From a75d31fa93a5b04bdc14c4699d96da00199d9dfc Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Tue, 22 Jun 2021 01:03:53 -0400 Subject: [PATCH 276/380] add a test for StrictMode polling --- src/react/hooks/__tests__/useQuery.test.tsx | 63 +++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/src/react/hooks/__tests__/useQuery.test.tsx b/src/react/hooks/__tests__/useQuery.test.tsx index 2f1bff39850..6907c836e21 100644 --- a/src/react/hooks/__tests__/useQuery.test.tsx +++ b/src/react/hooks/__tests__/useQuery.test.tsx @@ -720,6 +720,69 @@ describe('useQuery Hook', () => { } ); + itAsync( + 'stop polling and start polling should work with StrictMode', + (resolve, reject) => { + const query = gql` + query car { + car { + id + make + } + } + `; + + const data1 = { + car: { + id: 1, + make: 'Venturi', + __typename: 'Car', + } + }; + + const mocks = [ + { request: { query }, result: { data: data1 } }, + ]; + + let renderCount = 0; + const Component = () => { + let { data, loading, stopPolling } = useQuery(query, { + pollInterval: 100, + }); + + switch (++renderCount) { + case 1: + case 2: + expect(loading).toBeTruthy(); + expect(data).toBeUndefined(); + break; + case 3: + case 4: + expect(loading).toBeFalsy(); + expect(data).toEqual(data1); + stopPolling(); + break; + default: + reject(new Error('Unexpected render count')); + } + + return null; + }; + + render( + + + + + + ); + + return wait(() => { + expect(renderCount).toBe(4); + }).then(() => setTimeout(resolve, 300), reject); + }, + ); + it('should set called to true by default', () => { const Component = () => { const { loading, called } = useQuery(CAR_QUERY); From 51b0b05abccb87ee45811d0b9369add5cb3eea92 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Tue, 22 Jun 2021 17:07:14 -0400 Subject: [PATCH 277/380] add test for unmounting in StrictMode --- src/react/hooks/__tests__/useQuery.test.tsx | 54 +++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/src/react/hooks/__tests__/useQuery.test.tsx b/src/react/hooks/__tests__/useQuery.test.tsx index 6907c836e21..76feb391398 100644 --- a/src/react/hooks/__tests__/useQuery.test.tsx +++ b/src/react/hooks/__tests__/useQuery.test.tsx @@ -677,6 +677,60 @@ describe('useQuery Hook', () => { }).then(resolve, reject); }); + itAsync('should stop polling when the component is unmounted when using StrictMode', async (resolve, reject) => { + const mocks = [ + ...CAR_MOCKS, + ...CAR_MOCKS, + ...CAR_MOCKS, + ...CAR_MOCKS, + ]; + + const mockLink = new MockLink(mocks).setOnError(reject); + + const linkRequestSpy = jest.spyOn(mockLink, 'request'); + + let renderCount = 0; + const QueryComponent = ({ unmount }: { unmount: () => void }) => { + const { data, loading } = useQuery(CAR_QUERY, { pollInterval: 10 }); + switch (++renderCount) { + case 1: + case 2: + expect(loading).toBeTruthy(); + break; + case 3: + case 4: + expect(loading).toBeFalsy(); + expect(data).toEqual(CAR_RESULT_DATA); + expect(linkRequestSpy).toHaveBeenCalledTimes(1); + if (renderCount === 3) { + unmount(); + } + break; + default: + reject("unreached"); + } + return null; + }; + + const Component = () => { + const [queryMounted, setQueryMounted] = useState(true); + const unmount = () => setTimeout(() => setQueryMounted(false), 0); + return <>{queryMounted && }; + }; + + render( + + + + + + ); + + return wait(() => { + expect(linkRequestSpy).toHaveBeenCalledTimes(1); + }).then(resolve, reject); + }); + itAsync( 'should not throw an error if `stopPolling` is called manually after ' + 'a component has unmounted (even though polling has already been ' + From 83278295d876c92a311a401bdcf38d2efc673aa8 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Tue, 22 Jun 2021 17:38:22 -0400 Subject: [PATCH 278/380] rename getQueryResult to getExecuteResult --- src/react/data/QueryData.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/react/data/QueryData.ts b/src/react/data/QueryData.ts index 22a8c3af732..9d97f7283e5 100644 --- a/src/react/data/QueryData.ts +++ b/src/react/data/QueryData.ts @@ -155,10 +155,6 @@ export class QueryData extends OperationData< this.onNewData(); }; - private getExecuteResult(): QueryResult { - return this.getQueryResult(); - }; - private getExecuteSsrResult() { const { ssr, skip } = this.getOptions(); const ssrDisabled = ssr === false; @@ -182,7 +178,7 @@ export class QueryData extends OperationData< } if (this.ssrInitiated()) { - const result = this.getQueryResult() || ssrLoading; + const result = this.getExecuteResult() || ssrLoading; if (result.loading && !skip) { this.context.renderPromises!.addQueryPromise(this, () => null); } @@ -339,7 +335,7 @@ export class QueryData extends OperationData< } } - private getQueryResult = (): QueryResult => { + private getExecuteResult(): QueryResult { let result = this.observableQueryFields() as QueryResult; const options = this.getOptions(); From 5d1db1501b7bc843a62750e98e20de09c079917e Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Wed, 23 Jun 2021 11:14:59 -0400 Subject: [PATCH 279/380] Update CHANGELOG.md --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e64edb79fab..74df99fd6e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,9 @@ - `InMemoryCache` now coalesces `EntityStore` updates to guarantee only one `store.merge(id, fields)` call per `id` per cache write.
[@benjamn](https://github.com/benjamn) in [#8372](https://github.com/apollographql/apollo-client/pull/8372) +- Fix polling when used with `React.StrictMode`,
+ [@brainkim](https://github.com/brainkim) in [#8414](https://github.com/apollographql/apollo-client/pull/8414) + ### Potentially disruptive changes - To avoid retaining sensitive information from mutation root field arguments, Apollo Client v3.4 automatically clears any `ROOT_MUTATION` fields from the cache after each mutation finishes. If you need this information to remain in the cache, you can prevent the removal by passing the `keepRootFields: true` option to `client.mutate`. `ROOT_MUTATION` result data are also passed to the mutation `update` function, so we recommend obtaining the results that way, rather than using `keepRootFields: true`, if possible.
From ea0d6b06153726389c2509bb7756617b29515258 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Wed, 23 Jun 2021 11:26:00 -0400 Subject: [PATCH 280/380] Bump @apollo/client npm version to 3.4.0-rc.13. --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 70de79d84dd..2ece037e06c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-rc.12", + "version": "3.4.0-rc.13", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index da5386f645f..572331a9ba7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-rc.12", + "version": "3.4.0-rc.13", "description": "A fully-featured caching GraphQL client.", "private": true, "keywords": [ From a7e8bf7e9758e5537088aa25a854463f692ea3d4 Mon Sep 17 00:00:00 2001 From: Armin Date: Mon, 22 Feb 2021 14:46:16 +0100 Subject: [PATCH 281/380] Do not forceUpdate if comsuming component is not mounted This fixes the warning "Can't perform a React state update on an unmounted component". --- src/react/hooks/utils/useBaseQuery.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/react/hooks/utils/useBaseQuery.ts b/src/react/hooks/utils/useBaseQuery.ts index 7052df65fc0..9af5aa7e260 100644 --- a/src/react/hooks/utils/useBaseQuery.ts +++ b/src/react/hooks/utils/useBaseQuery.ts @@ -34,8 +34,8 @@ export function useBaseQuery( // force that re-render if we're already rendering however so to be // safe we'll trigger the re-render in a microtask. In case the // component gets unmounted before this callback fires, we re-check - // queryDataRef.current before calling forceUpdate(). - Promise.resolve().then(() => queryDataRef.current && forceUpdate()); + // queryDataRef.current.isMounted before calling forceUpdate(). + Promise.resolve().then(() => queryDataRef.current && queryDataRef.current.isMounted && forceUpdate()); } else { // If we're rendering on the server side we can force an update at // any point. From 446a6153bdc07547edcbe1c3b63681cde817f274 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Wed, 23 Jun 2021 14:19:57 -0400 Subject: [PATCH 282/380] update CHANGELOG.md --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 74df99fd6e9..dd2c67a52fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,8 @@ - Fix polling when used with `React.StrictMode`,
[@brainkim](https://github.com/brainkim) in [#8414](https://github.com/apollographql/apollo-client/pull/8414) +- Fix the React integration logging `Warning: Can't perform a React state update on an unmounted component`.
[@wuarmin](https://github.com/wuarmin) in [#7745](https://github.com/apollographql/apollo-client/pull/7745) + ### Potentially disruptive changes - To avoid retaining sensitive information from mutation root field arguments, Apollo Client v3.4 automatically clears any `ROOT_MUTATION` fields from the cache after each mutation finishes. If you need this information to remain in the cache, you can prevent the removal by passing the `keepRootFields: true` option to `client.mutate`. `ROOT_MUTATION` result data are also passed to the mutation `update` function, so we recommend obtaining the results that way, rather than using `keepRootFields: true`, if possible.
From 393fc9814524b85da62e679f92887e7385be43cc Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Wed, 23 Jun 2021 14:54:53 -0400 Subject: [PATCH 283/380] make new type parameters to BaseMutationOptions optional --- src/react/types/types.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/react/types/types.ts b/src/react/types/types.ts index cd2bae7271f..58ba94b26f9 100644 --- a/src/react/types/types.ts +++ b/src/react/types/types.ts @@ -142,8 +142,8 @@ export type RefetchQueriesFunction = ( export interface BaseMutationOptions< TData, TVariables extends OperationVariables, - TContext extends DefaultContext, - TCache extends ApolloCache, + TContext extends DefaultContext = DefaultContext, + TCache extends ApolloCache = ApolloCache > extends Omit< MutationOptions, | "mutation" From 63ad2ef6f703b04fdc14123219d946195d6472ed Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Wed, 23 Jun 2021 18:58:55 -0400 Subject: [PATCH 284/380] Bump @apollo/client npm version to 3.4.0-rc.14. --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2ece037e06c..4617a0eb635 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-rc.13", + "version": "3.4.0-rc.14", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 572331a9ba7..a78e5941553 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-rc.13", + "version": "3.4.0-rc.14", "description": "A fully-featured caching GraphQL client.", "private": true, "keywords": [ From 04c4ce863a958f9725d6b43ea4d266f84f0325d5 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Thu, 24 Jun 2021 14:41:38 -0400 Subject: [PATCH 285/380] Squash remaining "not wrapped in act(...)" error messages in tests (#8425) --- .../__tests__/client/Mutation.test.tsx | 8 +++- .../hoc/__tests__/mutations/queries.test.tsx | 44 ++++++++++++------- .../hooks/__tests__/useMutation.test.tsx | 37 ++++++++++------ src/react/hooks/__tests__/useQuery.test.tsx | 40 +++++++++++++---- 4 files changed, 89 insertions(+), 40 deletions(-) diff --git a/src/react/components/__tests__/client/Mutation.test.tsx b/src/react/components/__tests__/client/Mutation.test.tsx index b204767871d..6d8f904def1 100644 --- a/src/react/components/__tests__/client/Mutation.test.tsx +++ b/src/react/components/__tests__/client/Mutation.test.tsx @@ -1,7 +1,7 @@ import React, { useState } from 'react'; import gql from 'graphql-tag'; import { ExecutionResult, GraphQLError } from 'graphql'; -import { render, cleanup, fireEvent, wait } from '@testing-library/react'; +import { render, cleanup, fireEvent, wait, act } from '@testing-library/react'; import { ApolloClient } from '../../../../core'; import { ApolloError } from '../../../../errors'; @@ -1305,7 +1305,7 @@ describe('General Mutation testing', () => { {(createTodo: any, result: any) => { if (!result.called) { - setTimeout(() => { + act(() => { createTodo(); }); } @@ -1322,6 +1322,10 @@ describe('General Mutation testing', () => { ); + + return wait(() => { + expect(count).toBe(3); + }); }); it('errors if a query is passed instead of a mutation', () => { diff --git a/src/react/hoc/__tests__/mutations/queries.test.tsx b/src/react/hoc/__tests__/mutations/queries.test.tsx index 46dedcb518b..4717f843211 100644 --- a/src/react/hoc/__tests__/mutations/queries.test.tsx +++ b/src/react/hoc/__tests__/mutations/queries.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { render, wait } from '@testing-library/react'; +import { act, render, wait } from '@testing-library/react'; import gql from 'graphql-tag'; import { DocumentNode } from 'graphql'; @@ -78,7 +78,9 @@ describe('graphql(mutation) query integration', () => { ); - return wait(() => expect(mutateFired).toBeTruthy()).then(resolve, reject); + return wait(() => { + expect(mutateFired).toBeTruthy(); + }).then(resolve, reject); }); itAsync('allows for updating queries from a mutation', (resolve, reject) => { @@ -165,28 +167,34 @@ describe('graphql(mutation) query integration', () => { options: () => ({ optimisticResponse, update }) }); - let count = 0; + let renderCount = 0; type ContainerProps = ChildProps; class Container extends React.Component { render() { if (!this.props.data || !this.props.data.todo_list) return null; if (!this.props.data.todo_list.tasks.length) { - this.props.mutate!().then(result => { - expect(stripSymbols(result && result.data)).toEqual(mutationData); + act(() => { + this.props.mutate!().then(result => { + expect(stripSymbols(result && result.data)).toEqual(mutationData); + }); }); return null; } - if (count === 0) { - count++; - expect(stripSymbols(this.props.data.todo_list.tasks)).toEqual([ - optimisticResponse.createTodo - ]); - } else if (count === 1) { - expect(stripSymbols(this.props.data.todo_list.tasks)).toEqual([ - mutationData.createTodo - ]); + switch (++renderCount) { + case 1: + expect(stripSymbols(this.props.data.todo_list.tasks)).toEqual([ + optimisticResponse.createTodo + ]); + break; + case 2: + expect(stripSymbols(this.props.data.todo_list.tasks)).toEqual([ + mutationData.createTodo + ]); + break; + default: + reject(`too many renders (${renderCount})`); } return null; @@ -201,7 +209,9 @@ describe('graphql(mutation) query integration', () => { ); - return wait(() => expect(count).toBe(1)).then(resolve, reject); + return wait(() => { + expect(renderCount).toBe(2); + }).then(resolve, reject); }); itAsync('allows for updating queries from a mutation automatically', (resolve, reject) => { @@ -324,7 +334,9 @@ describe('graphql(mutation) query integration', () => { ); - return wait(() => expect(count).toBe(3)).then(resolve, reject); + return wait(() => { + expect(count).toBe(3); + }).then(resolve, reject); }); it('should be able to override the internal `ignoreResults` setting', async () => { diff --git a/src/react/hooks/__tests__/useMutation.test.tsx b/src/react/hooks/__tests__/useMutation.test.tsx index 30d76419833..ed8ab2b535f 100644 --- a/src/react/hooks/__tests__/useMutation.test.tsx +++ b/src/react/hooks/__tests__/useMutation.test.tsx @@ -768,8 +768,7 @@ describe('useMutation Hook', () => { }); describe('Update function', () => { - - itAsync('should be called with the provided variables', async (resolve, reject) => { + itAsync('should be called with the provided variables', (resolve, reject) => { const variables = { description: 'Get milk!' }; @@ -784,13 +783,14 @@ describe('useMutation Hook', () => { } ]; + let variablesMatched = false; const Component = () => { const [createTodo] = useMutation( CREATE_TODO_MUTATION, { update(_, __, options) { expect(options.variables).toEqual(variables); - resolve(); + variablesMatched = true; } } ); @@ -807,9 +807,13 @@ describe('useMutation Hook', () => { ); + + return wait(() => { + expect(variablesMatched).toBe(true); + }).then(resolve, reject); }); - itAsync('should be called with the provided context', async (resolve, reject) => { + itAsync('should be called with the provided context', (resolve, reject) => { const context = { id: 3 }; const variables = { @@ -826,6 +830,7 @@ describe('useMutation Hook', () => { } ]; + let foundContext = false; const Component = () => { const [createTodo] = useMutation( CREATE_TODO_MUTATION, @@ -833,7 +838,7 @@ describe('useMutation Hook', () => { context, update(_, __, options) { expect(options.context).toEqual(context); - resolve(); + foundContext = true; } } ); @@ -850,10 +855,14 @@ describe('useMutation Hook', () => { ); + + return wait(() => { + expect(foundContext).toBe(true); + }).then(resolve, reject); }); describe('If context is not provided', () => { - itAsync('should be undefined', async (resolve, reject) => { + itAsync('should be undefined', (resolve, reject) => { const variables = { description: 'Get milk!' }; @@ -868,13 +877,14 @@ describe('useMutation Hook', () => { } ]; + let checkedContext = false; const Component = () => { const [createTodo] = useMutation( CREATE_TODO_MUTATION, { update(_, __, options) { expect(options.context).toBeUndefined(); - resolve(); + checkedContext = true; } } ); @@ -891,6 +901,10 @@ describe('useMutation Hook', () => { ); + + return wait(() => { + expect(checkedContext).toBe(true); + }).then(resolve, reject); }); }); }); @@ -1176,8 +1190,9 @@ describe('useMutation Hook', () => { ); - return onUpdatePromise.then(results => { + return wait(() => onUpdatePromise.then(results => { expect(finishedReobserving).toBe(true); + expect(renderCount).toBe(4); expect(results.diff).toEqual({ complete: true, @@ -1193,11 +1208,7 @@ describe('useMutation Hook', () => { todoCount: 1, }, }); - - return wait(() => { - expect(renderCount).toBe(4); - }).then(resolve, reject); - }); + })).then(resolve, reject); }); }); }); diff --git a/src/react/hooks/__tests__/useQuery.test.tsx b/src/react/hooks/__tests__/useQuery.test.tsx index 76feb391398..56656525486 100644 --- a/src/react/hooks/__tests__/useQuery.test.tsx +++ b/src/react/hooks/__tests__/useQuery.test.tsx @@ -1,4 +1,4 @@ -import React, { useState, useReducer, Fragment } from 'react'; +import React, { useState, useReducer, Fragment, useEffect } from 'react'; import { DocumentNode, GraphQLError } from 'graphql'; import gql from 'graphql-tag'; import { render, cleanup, wait, act } from '@testing-library/react'; @@ -629,6 +629,28 @@ describe('useQuery Hook', () => { }).then(resolve, reject); }); + function useStatefulUnmount() { + const [queryMounted, setQueryMounted] = useState(true); + + let mounted = false; + useEffect(() => { + mounted = true; + expect(queryMounted).toBe(true); + return () => { + mounted = false; + }; + }, []); + + return { + mounted: queryMounted, + unmount() { + if (mounted) { + setQueryMounted(mounted = false); + } + }, + }; + } + itAsync('should stop polling when the component is unmounted', async (resolve, reject) => { const mocks = [ ...CAR_MOCKS, @@ -652,7 +674,7 @@ describe('useQuery Hook', () => { expect(loading).toBeFalsy(); expect(data).toEqual(CAR_RESULT_DATA); expect(linkRequestSpy).toHaveBeenCalledTimes(1); - unmount(); + setTimeout(unmount, 10); break; default: reject("unreached"); @@ -661,9 +683,8 @@ describe('useQuery Hook', () => { }; const Component = () => { - const [queryMounted, setQueryMounted] = useState(true); - const unmount = () => setTimeout(() => setQueryMounted(false), 0); - return <>{queryMounted && }; + const { mounted, unmount } = useStatefulUnmount(); + return <>{mounted && }; }; render( @@ -674,6 +695,7 @@ describe('useQuery Hook', () => { return wait(() => { expect(linkRequestSpy).toHaveBeenCalledTimes(1); + expect(renderCount).toBe(2); }).then(resolve, reject); }); @@ -703,7 +725,7 @@ describe('useQuery Hook', () => { expect(data).toEqual(CAR_RESULT_DATA); expect(linkRequestSpy).toHaveBeenCalledTimes(1); if (renderCount === 3) { - unmount(); + setTimeout(unmount, 10); } break; default: @@ -713,9 +735,8 @@ describe('useQuery Hook', () => { }; const Component = () => { - const [queryMounted, setQueryMounted] = useState(true); - const unmount = () => setTimeout(() => setQueryMounted(false), 0); - return <>{queryMounted && }; + const { mounted, unmount } = useStatefulUnmount(); + return <>{mounted && }; }; render( @@ -728,6 +749,7 @@ describe('useQuery Hook', () => { return wait(() => { expect(linkRequestSpy).toHaveBeenCalledTimes(1); + expect(renderCount).toBe(4); }).then(resolve, reject); }); From 8beabc3e64d2f18677e1275d10a86f2e87d02e76 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Thu, 24 Jun 2021 14:45:32 -0400 Subject: [PATCH 286/380] Squash one more act(...) error message. Follow-up to #8425. --- .../__tests__/client/Mutation.test.tsx | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/react/components/__tests__/client/Mutation.test.tsx b/src/react/components/__tests__/client/Mutation.test.tsx index 6d8f904def1..4b847b01157 100644 --- a/src/react/components/__tests__/client/Mutation.test.tsx +++ b/src/react/components/__tests__/client/Mutation.test.tsx @@ -886,24 +886,24 @@ describe('General Mutation testing', () => { } ]; - let count = 0; + let renderCount = 0; const Component = () => ( {(createTodo: any, resultMutation: any) => ( {(resultQuery: any) => { - if (count === 0) { + ++renderCount; + if (renderCount === 1) { setTimeout(() => createTodo(), 10); - } else if (count === 1) { + } else if (renderCount === 2) { expect(resultMutation.loading).toBe(false); expect(resultQuery.loading).toBe(false); - } else if (count === 2) { + } else if (renderCount === 3) { expect(resultMutation.loading).toBe(true); expect(stripSymbols(resultQuery.data)).toEqual(queryData); - } else if (count === 3) { + } else if (renderCount === 4) { expect(resultMutation.loading).toBe(false); } - count++; return null; }} @@ -917,7 +917,9 @@ describe('General Mutation testing', () => { ); - await wait(); + return wait(() => { + expect(renderCount).toBe(4); + }); }); it('allows a refetchQueries prop as string and variables have updated', async () => { From f11a163a4e507441bd51c2e0f9571aad853d5856 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Fri, 25 Jun 2021 16:22:13 -0400 Subject: [PATCH 287/380] Tolerate absolute Windows paths in isExternal Rollup helper. Addressing concerns raised by @dylanwulf on the PR that introduced this code: https://github.com/apollographql/apollo-client/pull/8341#pullrequestreview-691209843 --- config/rollup.config.js | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/config/rollup.config.js b/config/rollup.config.js index 511c832acfc..7db275f0777 100644 --- a/config/rollup.config.js +++ b/config/rollup.config.js @@ -2,19 +2,19 @@ import nodeResolve from '@rollup/plugin-node-resolve'; import { terser as minify } from 'rollup-plugin-terser'; import path from 'path'; -const packageJson = require('../package.json'); const entryPoints = require('./entryPoints'); - const distDir = './dist'; function isExternal(id, parentId, entryPointsAreExternal = true) { // Rollup v2.26.8 started passing absolute id strings to this function, thanks // apparently to https://github.com/rollup/rollup/pull/3753, so we relativize // the id again in those cases. - if (path.posix.isAbsolute(id)) { + if (path.isAbsolute(id)) { + const posixId = toPosixPath(id); + const posixParentId = toPosixPath(parentId); id = path.posix.relative( - path.posix.dirname(parentId), - id, + path.posix.dirname(posixParentId), + posixId, ); if (!id.startsWith(".")) { id = "./" + id; @@ -37,6 +37,23 @@ function isExternal(id, parentId, entryPointsAreExternal = true) { return false; } +// Adapted from https://github.com/meteor/meteor/blob/devel/tools/static-assets/server/mini-files.ts +function toPosixPath(p) { + // Sometimes, you can have a path like \Users\IEUser on windows, and this + // actually means you want C:\Users\IEUser + if (p[0] === '\\') { + p = process.env.SystemDrive + p; + } + + p = p.replace(/\\/g, '/'); + if (p[1] === ':') { + // Transform "C:/bla/bla" to "/c/bla/bla" + p = '/' + p[0] + p.slice(2); + } + + return p; +} + function prepareCJS(input, output) { return { input, From 36fea0f99ee01bfa29193ffe87312b1516d40950 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Mon, 28 Jun 2021 16:58:46 -0400 Subject: [PATCH 288/380] Make `ObservableQuery#getCurrentResult` always call `queryInfo.getDiff()` (#8422) --- CHANGELOG.md | 8 +- src/core/ObservableQuery.ts | 41 ++-- src/core/__tests__/ObservableQuery.ts | 5 +- src/react/hooks/__tests__/useQuery.test.tsx | 226 ++++++++++++++++++++ 4 files changed, 252 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dd2c67a52fd..8774adfe60f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,10 +27,14 @@ - `InMemoryCache` now coalesces `EntityStore` updates to guarantee only one `store.merge(id, fields)` call per `id` per cache write.
[@benjamn](https://github.com/benjamn) in [#8372](https://github.com/apollographql/apollo-client/pull/8372) -- Fix polling when used with `React.StrictMode`,
+- Fix polling when used with ``.
[@brainkim](https://github.com/brainkim) in [#8414](https://github.com/apollographql/apollo-client/pull/8414) -- Fix the React integration logging `Warning: Can't perform a React state update on an unmounted component`.
[@wuarmin](https://github.com/wuarmin) in [#7745](https://github.com/apollographql/apollo-client/pull/7745) +- Fix the React integration logging `Warning: Can't perform a React state update on an unmounted component`.
+ [@wuarmin](https://github.com/wuarmin) in [#7745](https://github.com/apollographql/apollo-client/pull/7745) + +- Make `ObservableQuery#getCurrentResult` always call `queryInfo.getDiff()`.
+ [@benjamn](https://github.com/benjamn) in [#8422](https://github.com/apollographql/apollo-client/pull/8422) ### Potentially disruptive changes diff --git a/src/core/ObservableQuery.ts b/src/core/ObservableQuery.ts index 2cc2ae523f5..a0825f65888 100644 --- a/src/core/ObservableQuery.ts +++ b/src/core/ObservableQuery.ts @@ -169,7 +169,12 @@ export class ObservableQuery< } public getCurrentResult(saveAsLastResult = true): ApolloQueryResult { - const { lastResult } = this; + const { + lastResult, + options: { + fetchPolicy = "cache-first", + }, + } = this; const networkStatus = this.queryInfo.networkStatus || @@ -182,33 +187,18 @@ export class ObservableQuery< networkStatus, } as ApolloQueryResult; - if (this.isTornDown) { - return result; - } - - const { fetchPolicy = 'cache-first' } = this.options; - if (fetchPolicy === 'no-cache' || - fetchPolicy === 'network-only') { - // Similar to setting result.partial to false, but taking advantage - // of the falsiness of missing fields. - delete result.partial; - } else if ( - !result.data || - // If this.options.query has @client(always: true) fields, we cannot - // trust result.data, since it was read from the cache without - // running local resolvers (and it's too late to run resolvers now, - // since we must return a result synchronously). TODO In the future - // (after Apollo Client 3.0), we should find a way to trust - // this.lastResult in more cases, and read from the cache only in - // cases when no result has been received yet. - !this.queryManager.transform(this.options.query).hasForcedResolvers - ) { + // If this.options.query has @client(always: true) fields, we cannot trust + // diff.result, since it was read from the cache without running local + // resolvers (and it's too late to run resolvers now, since we must return a + // result synchronously). + if (!this.queryManager.transform(this.options.query).hasForcedResolvers) { const diff = this.queryInfo.getDiff(); - // XXX the only reason this typechecks is that diff.result is inferred as any + result.data = ( diff.complete || this.options.returnPartialData ) ? diff.result : void 0; + if (diff.complete) { // If the diff is complete, and we're using a FetchPolicy that // terminates after a complete cache read, we can assume the next @@ -220,7 +210,10 @@ export class ObservableQuery< result.loading = false; } delete result.partial; - } else { + } else if (fetchPolicy !== "no-cache") { + // Since result.partial comes from diff.complete, and we shouldn't be + // using cache data to provide a DiffResult when the fetchPolicy is + // "no-cache", avoid annotating result.partial for "no-cache" results. result.partial = true; } diff --git a/src/core/__tests__/ObservableQuery.ts b/src/core/__tests__/ObservableQuery.ts index cc8ec7d8de8..d0a2b27175c 100644 --- a/src/core/__tests__/ObservableQuery.ts +++ b/src/core/__tests__/ObservableQuery.ts @@ -1648,15 +1648,16 @@ describe('ObservableQuery', () => { }); expect(observable.getCurrentResult()).toEqual({ - data: void 0, + data: dataOne, loading: true, - networkStatus: 1, + networkStatus: NetworkStatus.loading, }); subscribeAndCount(reject, observable, (handleCount, subResult) => { if (handleCount === 1) { expect(subResult).toEqual({ loading: true, + data: dataOne, networkStatus: NetworkStatus.loading, }); } else if (handleCount === 2) { diff --git a/src/react/hooks/__tests__/useQuery.test.tsx b/src/react/hooks/__tests__/useQuery.test.tsx index 56656525486..272c1f48a78 100644 --- a/src/react/hooks/__tests__/useQuery.test.tsx +++ b/src/react/hooks/__tests__/useQuery.test.tsx @@ -3129,6 +3129,232 @@ describe('useQuery Hook', () => { expect(renderCount).toBe(5); }).then(resolve, reject); }); + + itAsync("should be cleared when variables change causes cache miss", (resolve, reject) => { + const peopleData = [ + { id: 1, name: 'John Smith', gender: 'male' }, + { id: 2, name: 'Sara Smith', gender: 'female' }, + { id: 3, name: 'Budd Deey', gender: 'nonbinary' }, + { id: 4, name: 'Johnny Appleseed', gender: 'male' }, + { id: 5, name: 'Ada Lovelace', gender: 'female' }, + ]; + + const link = new ApolloLink(operation => { + return new Observable(observer => { + const { gender } = operation.variables; + new Promise(resolve => setTimeout(resolve, 300)).then(() => { + observer.next({ + data: { + people: gender === "all" ? peopleData : + gender ? peopleData.filter( + person => person.gender === gender + ) : peopleData, + } + }); + observer.complete(); + }); + }); + }); + + type Person = { + __typename: string; + id: string; + name: string; + }; + + const ALL_PEOPLE: TypedDocumentNode<{ + people: Person[]; + }> = gql` + query AllPeople($gender: String!) { + people(gender: $gender) { + id + name + } + } + `; + + let renderCount = 0; + function App() { + const [gender, setGender] = useState("all"); + const { + loading, + networkStatus, + data, + } = useQuery(ALL_PEOPLE, { + variables: { gender }, + fetchPolicy: "network-only", + }); + + const currentPeopleNames = data?.people?.map(person => person.name); + + switch (++renderCount) { + case 1: + expect(gender).toBe("all"); + expect(loading).toBe(true); + expect(networkStatus).toBe(NetworkStatus.loading); + expect(data).toBeUndefined(); + expect(currentPeopleNames).toBeUndefined(); + break; + + case 2: + expect(gender).toBe("all"); + expect(loading).toBe(false); + expect(networkStatus).toBe(NetworkStatus.ready); + expect(data).toEqual({ + people: peopleData.map(({ gender, ...person }) => person), + }); + expect(currentPeopleNames).toEqual([ + "John Smith", + "Sara Smith", + "Budd Deey", + "Johnny Appleseed", + "Ada Lovelace", + ]); + act(() => { + setGender("female"); + }); + break; + + case 3: + expect(gender).toBe("female"); + expect(loading).toBe(true); + expect(networkStatus).toBe(NetworkStatus.setVariables); + expect(data).toBeUndefined(); + expect(currentPeopleNames).toBeUndefined(); + break; + + case 4: + expect(gender).toBe("female"); + expect(loading).toBe(false); + expect(networkStatus).toBe(NetworkStatus.ready); + expect(data!.people.length).toBe(2); + expect(currentPeopleNames).toEqual([ + "Sara Smith", + "Ada Lovelace", + ]); + act(() => { + setGender("nonbinary"); + }); + break; + + case 5: + expect(gender).toBe("nonbinary"); + expect(loading).toBe(true); + expect(networkStatus).toBe(NetworkStatus.setVariables); + expect(data).toBeUndefined(); + expect(currentPeopleNames).toBeUndefined(); + break; + + case 6: + expect(gender).toBe("nonbinary"); + expect(loading).toBe(false); + expect(networkStatus).toBe(NetworkStatus.ready); + expect(data!.people.length).toBe(1); + expect(currentPeopleNames).toEqual([ + "Budd Deey", + ]); + act(() => { + setGender("male"); + }); + break; + + case 7: + expect(gender).toBe("male"); + expect(loading).toBe(true); + expect(networkStatus).toBe(NetworkStatus.setVariables); + expect(data).toBeUndefined(); + expect(currentPeopleNames).toBeUndefined(); + break; + + case 8: + expect(gender).toBe("male"); + expect(loading).toBe(false); + expect(networkStatus).toBe(NetworkStatus.ready); + expect(data!.people.length).toBe(2); + expect(currentPeopleNames).toEqual([ + "John Smith", + "Johnny Appleseed", + ]); + act(() => { + setGender("female"); + }); + break; + + case 9: + expect(gender).toBe("female"); + expect(loading).toBe(true); + expect(networkStatus).toBe(NetworkStatus.setVariables); + expect(data!.people.length).toBe(2); + expect(currentPeopleNames).toEqual([ + "Sara Smith", + "Ada Lovelace", + ]); + break; + + case 10: + expect(gender).toBe("female"); + expect(loading).toBe(false); + expect(networkStatus).toBe(NetworkStatus.ready); + expect(data!.people.length).toBe(2); + expect(currentPeopleNames).toEqual([ + "Sara Smith", + "Ada Lovelace", + ]); + act(() => { + setGender("all"); + }); + break; + + case 11: + expect(gender).toBe("all"); + expect(loading).toBe(true); + expect(networkStatus).toBe(NetworkStatus.setVariables); + expect(data!.people.length).toBe(5); + expect(currentPeopleNames).toEqual([ + "John Smith", + "Sara Smith", + "Budd Deey", + "Johnny Appleseed", + "Ada Lovelace", + ]); + break; + + case 12: + expect(gender).toBe("all"); + expect(loading).toBe(false); + expect(networkStatus).toBe(NetworkStatus.ready); + expect(data!.people.length).toBe(5); + expect(currentPeopleNames).toEqual([ + "John Smith", + "Sara Smith", + "Budd Deey", + "Johnny Appleseed", + "Ada Lovelace", + ]); + break; + + default: + reject(`too many (${renderCount}) renders`); + } + + return null; + } + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link + }); + + render( + + + , + ); + + return wait(() => { + expect(renderCount).toBe(12); + }).then(resolve, reject); + }); }); describe("canonical cache results", () => { From edab3679a862719a459f71ce772f98f6350071a5 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Mon, 28 Jun 2021 17:03:08 -0400 Subject: [PATCH 289/380] Bump @apollo/client npm version to 3.4.0-rc.15. --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 67224342e02..a26364309dc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-rc.14", + "version": "3.4.0-rc.15", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index dcf10ab9fe0..19d18adfd82 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-rc.14", + "version": "3.4.0-rc.15", "description": "A fully-featured caching GraphQL client.", "private": true, "keywords": [ From 622880dad4ed39f2bd589dae0329156a51bdc119 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Wed, 30 Jun 2021 21:46:07 -0400 Subject: [PATCH 290/380] add a failing test for #8274 --- .../hooks/__tests__/useMutation.test.tsx | 137 +++++++++++++++++- 1 file changed, 135 insertions(+), 2 deletions(-) diff --git a/src/react/hooks/__tests__/useMutation.test.tsx b/src/react/hooks/__tests__/useMutation.test.tsx index ed8ab2b535f..8bf931a7069 100644 --- a/src/react/hooks/__tests__/useMutation.test.tsx +++ b/src/react/hooks/__tests__/useMutation.test.tsx @@ -1,5 +1,5 @@ import React, { useEffect } from 'react'; -import { DocumentNode, GraphQLError } from 'graphql'; +import { GraphQLError } from 'graphql'; import gql from 'graphql-tag'; import { render, cleanup, wait } from '@testing-library/react'; @@ -18,7 +18,7 @@ describe('useMutation Hook', () => { priority: string; } - const CREATE_TODO_MUTATION: DocumentNode = gql` + const CREATE_TODO_MUTATION = gql` mutation createTodo($description: String!, $priority: String) { createTodo(description: $description, priority: $priority) { id @@ -1210,5 +1210,138 @@ describe('useMutation Hook', () => { }); })).then(resolve, reject); }); + + itAsync('refetchQueries with operation names should update cache after unmount', async (resolve, reject) => { + const GET_TODOS_QUERY = gql` + query getTodos { + todos { + id + description + priority + } + } + `; + + const GET_TODOS_RESULT_1 = { + todos: [ + { + id: 2, + description: 'Walk the dog', + priority: 'Medium', + __typename: 'Todo' + }, + { + id: 3, + description: 'Call mom', + priority: 'Low', + __typename: 'Todo' + }, + ], + }; + + const GET_TODOS_RESULT_2 = { + todos: [ + { + id: 1, + description: 'Get milk!', + priority: 'High', + __typename: 'Todo' + }, + { + id: 2, + description: 'Walk the dog', + priority: 'Medium', + __typename: 'Todo' + }, + { + id: 3, + description: 'Call mom', + priority: 'Low', + __typename: 'Todo' + }, + ], + }; + + const variables = { description: 'Get milk!' }; + + const mocks = [ + { + request: { + query: GET_TODOS_QUERY, + }, + result: { data: GET_TODOS_RESULT_1 }, + }, + { + request: { + query: CREATE_TODO_MUTATION, + variables, + }, + result: { + data: CREATE_TODO_RESULT, + }, + }, + { + request: { + query: GET_TODOS_QUERY, + }, + result: { data: GET_TODOS_RESULT_2 }, + }, + ]; + + const link = mockSingleLink(...mocks).setOnError(reject); + const client = new ApolloClient({ + link, + cache: new InMemoryCache(), + }); + + let unmount: Function; + let renderCount = 0; + const QueryComponent = () => { + const { loading, data } = useQuery(GET_TODOS_QUERY); + const [mutate] = useMutation(CREATE_TODO_MUTATION); + switch (++renderCount) { + case 1: + expect(loading).toBe(true); + expect(data).toBeUndefined(); + break; + case 2: + expect(loading).toBe(false); + expect(data).toEqual(mocks[0].result.data); + setTimeout(() => { + act(() => { + mutate({ + variables, + refetchQueries: ['getTodos'], + update() { + unmount(); + }, + }); + }); + }); + break; + case 3: + expect(loading).toBe(false); + expect(data).toEqual(mocks[0].result.data); + break; + default: + reject("too many renders"); + } + + return null; + }; + + ({unmount} = render( + + + + )); + + return wait(() => expect(renderCount).toBe(3)) + .then(() => { + expect(client.readQuery({ query: GET_TODOS_QUERY})) + .toEqual(mocks[2].result.data); + }) + .then(resolve, reject); + }); }); }); From 7eae2dad55afeef2d0195d659ac671354e8df9c3 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Wed, 30 Jun 2021 21:47:09 -0400 Subject: [PATCH 291/380] fix unmounting and refetching queries not updating the cache --- src/core/QueryInfo.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/core/QueryInfo.ts b/src/core/QueryInfo.ts index 138fae80e68..e0ddfa17a25 100644 --- a/src/core/QueryInfo.ts +++ b/src/core/QueryInfo.ts @@ -362,7 +362,7 @@ export class QueryInfo { this.getDiffOptions(options.variables), ); - } else if (!this.stopped && cacheWriteBehavior !== CacheWriteBehavior.FORBID) { + } else if (cacheWriteBehavior !== CacheWriteBehavior.FORBID) { if (shouldWriteResult(result, options.errorPolicy)) { // Using a transaction here so we have a chance to read the result // back from the cache before the watch callback fires as a result @@ -448,7 +448,6 @@ export class QueryInfo { result.data = diff.result; } }); - } else { this.lastWrite = void 0; } From c94c8233cf46ddbf92bcd3562d8c71f61ce85613 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Thu, 1 Jul 2021 10:32:15 -0400 Subject: [PATCH 292/380] add failing tests for refetchQueries with document nodes --- .../hooks/__tests__/useMutation.test.tsx | 247 +++++++++++++++--- 1 file changed, 206 insertions(+), 41 deletions(-) diff --git a/src/react/hooks/__tests__/useMutation.test.tsx b/src/react/hooks/__tests__/useMutation.test.tsx index 8bf931a7069..3d4c8f556f9 100644 --- a/src/react/hooks/__tests__/useMutation.test.tsx +++ b/src/react/hooks/__tests__/useMutation.test.tsx @@ -1211,59 +1211,224 @@ describe('useMutation Hook', () => { })).then(resolve, reject); }); - itAsync('refetchQueries with operation names should update cache after unmount', async (resolve, reject) => { - const GET_TODOS_QUERY = gql` - query getTodos { - todos { - id - description - priority - } + const GET_TODOS_QUERY = gql` + query getTodos { + todos { + id + description + priority } - `; + } + `; - const GET_TODOS_RESULT_1 = { - todos: [ - { - id: 2, - description: 'Walk the dog', - priority: 'Medium', - __typename: 'Todo' + const GET_TODOS_RESULT_1 = { + todos: [ + { + id: 2, + description: 'Walk the dog', + priority: 'Medium', + __typename: 'Todo' + }, + { + id: 3, + description: 'Call mom', + priority: 'Low', + __typename: 'Todo' + }, + ], + }; + + const GET_TODOS_RESULT_2 = { + todos: [ + { + id: 1, + description: 'Get milk!', + priority: 'High', + __typename: 'Todo' + }, + { + id: 2, + description: 'Walk the dog', + priority: 'Medium', + __typename: 'Todo' + }, + { + id: 3, + description: 'Call mom', + priority: 'Low', + __typename: 'Todo' + }, + ], + }; + + itAsync('refetchQueries with operation names should update cache', async (resolve, reject) => { + const variables = { description: 'Get milk!' }; + const mocks = [ + { + request: { + query: GET_TODOS_QUERY, }, - { - id: 3, - description: 'Call mom', - priority: 'Low', - __typename: 'Todo' + result: { data: GET_TODOS_RESULT_1 }, + }, + { + request: { + query: CREATE_TODO_MUTATION, + variables, + }, + result: { + data: CREATE_TODO_RESULT, }, - ], + }, + { + request: { + query: GET_TODOS_QUERY, + }, + result: { data: GET_TODOS_RESULT_2 }, + }, + ]; + + const link = mockSingleLink(...mocks).setOnError(reject); + const client = new ApolloClient({ + link, + cache: new InMemoryCache(), + }); + + let renderCount = 0; + const QueryComponent = () => { + const { loading, data } = useQuery(GET_TODOS_QUERY); + const [mutate] = useMutation(CREATE_TODO_MUTATION); + switch (++renderCount) { + case 1: + expect(loading).toBe(true); + expect(data).toBeUndefined(); + break; + case 2: + expect(loading).toBe(false); + expect(data).toEqual(mocks[0].result.data); + setTimeout(() => { + act(() => { + mutate({ + variables, + refetchQueries: ['getTodos'], + }); + }); + }); + break; + case 3: + case 4: + expect(loading).toBe(false); + expect(data).toEqual(mocks[0].result.data); + break; + case 5: + expect(loading).toBe(false); + expect(data).toEqual(mocks[2].result.data); + break; + default: + reject("too many renders"); + } + + return null; }; - const GET_TODOS_RESULT_2 = { - todos: [ - { - id: 1, - description: 'Get milk!', - priority: 'High', - __typename: 'Todo' + render( + + + + ); + + return wait(() => expect(renderCount).toBe(5)) + .then(() => { + expect(client.readQuery({ query: GET_TODOS_QUERY})) + .toEqual(mocks[2].result.data); + }) + .then(resolve, reject); + }); + + itAsync('refetchQueries with document nodes should update cache', async (resolve, reject) => { + const variables = { description: 'Get milk!' }; + const mocks = [ + { + request: { + query: GET_TODOS_QUERY, }, - { - id: 2, - description: 'Walk the dog', - priority: 'Medium', - __typename: 'Todo' + result: { data: GET_TODOS_RESULT_1 }, + }, + { + request: { + query: CREATE_TODO_MUTATION, + variables, }, - { - id: 3, - description: 'Call mom', - priority: 'Low', - __typename: 'Todo' + result: { + data: CREATE_TODO_RESULT, }, - ], + }, + { + request: { + query: GET_TODOS_QUERY, + }, + result: { data: GET_TODOS_RESULT_2 }, + }, + ]; + + const link = mockSingleLink(...mocks).setOnError(reject); + const client = new ApolloClient({ + link, + cache: new InMemoryCache(), + }); + + let renderCount = 0; + const QueryComponent = () => { + const { loading, data } = useQuery(GET_TODOS_QUERY); + const [mutate] = useMutation(CREATE_TODO_MUTATION); + switch (++renderCount) { + case 1: + expect(loading).toBe(true); + expect(data).toBeUndefined(); + break; + case 2: + expect(loading).toBe(false); + expect(data).toEqual(mocks[0].result.data); + setTimeout(() => { + act(() => { + mutate({ + variables, + refetchQueries: [GET_TODOS_QUERY], + }); + }); + }); + break; + case 3: + case 4: + expect(loading).toBe(false); + expect(data).toEqual(mocks[0].result.data); + break; + case 5: + expect(loading).toBe(false); + expect(data).toEqual(mocks[2].result.data); + break; + default: + reject("too many renders"); + } + + return null; }; - const variables = { description: 'Get milk!' }; + render( + + + + ); + return wait(() => expect(renderCount).toBe(5)) + .then(() => { + expect(client.readQuery({ query: GET_TODOS_QUERY})) + .toEqual(mocks[2].result.data); + }) + .then(resolve, reject); + }); + + itAsync('refetchQueries should update cache after unmount', async (resolve, reject) => { + const variables = { description: 'Get milk!' }; const mocks = [ { request: { From 89e76fbc9c03780dee06a4dce15dc95e90698ec1 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Thu, 1 Jul 2021 10:35:07 -0400 Subject: [PATCH 293/380] fix using DocumentNodes in refetchQueries --- src/core/QueryManager.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index 7ccc02fc1e4..22221e5055f 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -742,8 +742,10 @@ export class QueryManager { if (Array.isArray(include)) { include.forEach(desc => { - if (typeof desc === "string" || isDocumentNode(desc)) { + if (typeof desc === "string") { queryNamesAndDocs.set(desc, false); + } else if (isDocumentNode(desc)) { + queryNamesAndDocs.set(this.transform(desc).document, false); } else if (isNonNullObject(desc) && desc.query) { legacyQueryOptions.add(desc); } From db3439e46fe370f929719a507abfd37ac5d97b31 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 6 Jul 2021 12:10:54 -0400 Subject: [PATCH 294/380] Bump bundlesize limit from 24.35kB to 24.4kB. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c968d561a84..9c8d087f402 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ { "name": "apollo-client", "path": "./dist/apollo-client.cjs.min.js", - "maxSize": "24.35 kB" + "maxSize": "24.4 kB" } ], "peerDependencies": { From 424e4bc66ed55c6e82cc2616114a8d9c3a4cf99c Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 6 Jul 2021 12:11:19 -0400 Subject: [PATCH 295/380] Bump @apollo/client npm version to 3.4.0-rc.16. --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index e7bfcbb96c6..480d42b32c7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-rc.15", + "version": "3.4.0-rc.16", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 9c8d087f402..a61ad34d427 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-rc.15", + "version": "3.4.0-rc.16", "description": "A fully-featured caching GraphQL client.", "private": true, "keywords": [ From b8b82c7c582ae3c430ce3b67c02cfa249d6fa05e Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 22 Jun 2021 18:01:49 -0400 Subject: [PATCH 296/380] Allow specifying canon when creating StoreReader. --- src/cache/inmemory/readFromStore.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/cache/inmemory/readFromStore.ts b/src/cache/inmemory/readFromStore.ts index 7fcfa59b343..63d87046620 100644 --- a/src/cache/inmemory/readFromStore.ts +++ b/src/cache/inmemory/readFromStore.ts @@ -25,6 +25,7 @@ import { getFragmentFromSelection, maybeDeepFreeze, isNonNullObject, + canUseWeakMap, } from '../../utilities'; import { Cache } from '../core/types/Cache'; import { @@ -84,6 +85,7 @@ export interface StoreReaderConfig { cache: InMemoryCache, addTypename?: boolean; resultCacheMaxSize?: number; + canon?: ObjectCanon; } // Arguments type after keyArgs translation. @@ -127,12 +129,20 @@ export class StoreReader { resultCacheMaxSize?: number; }; + private canon: ObjectCanon; + + private knownResults = new ( + canUseWeakMap ? WeakMap : Map + ), SelectionSetNode>(); + constructor(config: StoreReaderConfig) { this.config = { ...config, addTypename: config.addTypename !== false, }; + this.canon = config.canon || new ObjectCanon; + this.executeSelectionSet = wrap(options => { const { canonizeResults } = options.context; @@ -421,10 +431,6 @@ export class StoreReader { return finalResult; } - private canon = new ObjectCanon; - - private knownResults = new WeakMap, SelectionSetNode>(); - // Uncached version of executeSubSelectedArray. private execSubSelectedArrayImpl({ field, From 2d204032bf791da83f939c2285a6882ea39d420d Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 22 Jun 2021 16:40:01 -0400 Subject: [PATCH 297/380] Support cache.gc({ resetResultCache: true }) to jettison result cache. --- src/cache/inmemory/__tests__/cache.ts | 11 +++++++ src/cache/inmemory/__tests__/entityStore.ts | 33 +++++++++++++++++++-- src/cache/inmemory/inMemoryCache.ts | 17 +++++++++-- 3 files changed, 56 insertions(+), 5 deletions(-) diff --git a/src/cache/inmemory/__tests__/cache.ts b/src/cache/inmemory/__tests__/cache.ts index ea199eb9d4c..6ab41b79a22 100644 --- a/src/cache/inmemory/__tests__/cache.ts +++ b/src/cache/inmemory/__tests__/cache.ts @@ -2603,6 +2603,17 @@ describe("InMemoryCache#modify", () => { expect(aResults).toEqual([a123, a124]); expect(bResults).toEqual([b321, b322]); + + // Check that resetting the result cache does not trigger additional watch + // notifications. + expect(cache.gc({ + resetResultCache: true, + })).toEqual([]); + expect(aResults).toEqual([a123, a124]); + expect(bResults).toEqual([b321, b322]); + cache["broadcastWatches"](); + expect(aResults).toEqual([a123, a124]); + expect(bResults).toEqual([b321, b322]); }); it("should handle argument-determined field identities", () => { diff --git a/src/cache/inmemory/__tests__/entityStore.ts b/src/cache/inmemory/__tests__/entityStore.ts index 2001417ace2..bd1fb502e42 100644 --- a/src/cache/inmemory/__tests__/entityStore.ts +++ b/src/cache/inmemory/__tests__/entityStore.ts @@ -7,6 +7,7 @@ import { ApolloCache } from '../../core/cache'; import { Cache } from '../../core/types/Cache'; import { Reference, makeReference, isReference } from '../../../utilities/graphql/storeUtils'; import { MissingFieldError } from '../..'; +import { TypedDocumentNode } from '@graphql-typed-document-node/core'; describe('EntityStore', () => { it('should support result caching if so configured', () => { @@ -56,7 +57,17 @@ describe('EntityStore', () => { }, }); - const query = gql` + const query: TypedDocumentNode<{ + book: { + __typename: string; + title: string; + isbn: string; + author: { + __typename: string; + name: string; + }; + }; + }> = gql` query { book { title @@ -159,11 +170,16 @@ describe('EntityStore', () => { }, }); + const resultBeforeGC = cache.readQuery({ query }); + expect(cache.gc().sort()).toEqual([ 'Author:Ray Bradbury', 'Book:9781451673319', ]); + const resultAfterGC = cache.readQuery({ query }); + expect(resultBeforeGC).toBe(resultAfterGC); + expect(cache.extract()).toEqual({ ROOT_QUERY: { __typename: "Query", @@ -184,8 +200,19 @@ describe('EntityStore', () => { }, }); - // Nothing left to garbage collect. - expect(cache.gc()).toEqual([]); + // Nothing left to garbage collect, but let's also reset the result cache to + // demonstrate that the recomputed cache results are unchanged. + expect(cache.gc({ + resetResultCache: true, + })).toEqual([]); + + const resultAfterFullGC = cache.readQuery({ query }); + expect(resultAfterFullGC).toEqual(resultBeforeGC); + expect(resultAfterFullGC).toEqual(resultAfterGC); + // These !== relations are triggered by the resetResultCache:true option + // passed to cache.gc, above. + expect(resultAfterFullGC).not.toBe(resultBeforeGC); + expect(resultAfterFullGC).not.toBe(resultAfterGC); // Go back to the pre-GC snapshot. cache.restore(snapshot); diff --git a/src/cache/inmemory/inMemoryCache.ts b/src/cache/inmemory/inMemoryCache.ts index dc28a4868b3..d7aef25d83b 100644 --- a/src/cache/inmemory/inMemoryCache.ts +++ b/src/cache/inmemory/inMemoryCache.ts @@ -106,6 +106,13 @@ export class InMemoryCache extends ApolloCache { // original this.data cache object. this.optimisticData = rootStore.stump; + this.resetResultCache(); + } + + private resetResultCache() { + // The StoreWriter is mostly stateless and so doesn't really need to be + // reset, but it does need to have its writer.storeReader reference updated, + // so it's simpler to update this.storeWriter as well. this.storeWriter = new StoreWriter( this, this.storeReader = new StoreReader({ @@ -263,9 +270,15 @@ export class InMemoryCache extends ApolloCache { } // Request garbage collection of unreachable normalized entities. - public gc() { + public gc(options?: { + resetResultCache?: boolean; + }) { canonicalStringify.reset(); - return this.optimisticData.gc(); + const ids = this.optimisticData.gc(); + if (options && options.resetResultCache) { + this.resetResultCache(); + } + return ids; } // Call this method to ensure the given root ID remains in the cache after From 6c7715ce72400d0c697697f5e714e5202ebca32b Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 22 Jun 2021 18:00:35 -0400 Subject: [PATCH 298/380] Add options.preserveCanon to cache.gc method. --- src/cache/inmemory/__tests__/entityStore.ts | 17 +++++++++++++++-- src/cache/inmemory/inMemoryCache.ts | 20 ++++++++++++++++---- 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/src/cache/inmemory/__tests__/entityStore.ts b/src/cache/inmemory/__tests__/entityStore.ts index bd1fb502e42..c1ac73c284b 100644 --- a/src/cache/inmemory/__tests__/entityStore.ts +++ b/src/cache/inmemory/__tests__/entityStore.ts @@ -200,19 +200,32 @@ describe('EntityStore', () => { }, }); - // Nothing left to garbage collect, but let's also reset the result cache to + // Nothing left to collect, but let's also reset the result cache to // demonstrate that the recomputed cache results are unchanged. + const originalReader = cache["storeReader"]; expect(cache.gc({ resetResultCache: true, })).toEqual([]); + expect(cache["storeReader"]).not.toBe(originalReader); + const resultAfterResetResultCache = cache.readQuery({ query }); + expect(resultAfterResetResultCache).toBe(resultBeforeGC); + expect(resultAfterResetResultCache).toBe(resultAfterGC); + + // Now discard cache.storeReader.canon as well. + expect(cache.gc({ + resetResultCache: true, + preserveCanon: false, + })).toEqual([]); const resultAfterFullGC = cache.readQuery({ query }); expect(resultAfterFullGC).toEqual(resultBeforeGC); expect(resultAfterFullGC).toEqual(resultAfterGC); - // These !== relations are triggered by the resetResultCache:true option + // These !== relations are triggered by the preserveCanon:false option // passed to cache.gc, above. expect(resultAfterFullGC).not.toBe(resultBeforeGC); expect(resultAfterFullGC).not.toBe(resultAfterGC); + // Result caching immediately begins working again after the intial reset. + expect(cache.readQuery({ query })).toBe(resultAfterFullGC); // Go back to the pre-GC snapshot. cache.restore(snapshot); diff --git a/src/cache/inmemory/inMemoryCache.ts b/src/cache/inmemory/inMemoryCache.ts index d7aef25d83b..115cd8dd01e 100644 --- a/src/cache/inmemory/inMemoryCache.ts +++ b/src/cache/inmemory/inMemoryCache.ts @@ -109,7 +109,9 @@ export class InMemoryCache extends ApolloCache { this.resetResultCache(); } - private resetResultCache() { + private resetResultCache(preserveCanon = true) { + const previousReader = this.storeReader; + // The StoreWriter is mostly stateless and so doesn't really need to be // reset, but it does need to have its writer.storeReader reference updated, // so it's simpler to update this.storeWriter as well. @@ -119,6 +121,11 @@ export class InMemoryCache extends ApolloCache { cache: this, addTypename: this.addTypename, resultCacheMaxSize: this.config.resultCacheMaxSize, + canon: ( + preserveCanon && + previousReader && + previousReader["canon"] + ) || void 0, }), ); @@ -269,14 +276,19 @@ export class InMemoryCache extends ApolloCache { }; } - // Request garbage collection of unreachable normalized entities. public gc(options?: { + // If true, also free non-essential result cache memory by bulk-releasing + // this.{store{Reader,Writer},maybeBroadcastWatch}. Defaults to false. resetResultCache?: boolean; + // If resetResultCache is true, this.storeReader.canon will be preserved by + // default, but can also be discarded by passing false for preserveCanon. + // Defaults to true, but has no effect if resetResultCache is false. + preserveCanon?: boolean; }) { canonicalStringify.reset(); const ids = this.optimisticData.gc(); - if (options && options.resetResultCache) { - this.resetResultCache(); + if (options && options.resetResultCache && !this.txCount) { + this.resetResultCache(options.preserveCanon); } return ids; } From 19210dec11a8462c88a4f98f667803dd01c328bd Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 22 Jun 2021 17:03:40 -0400 Subject: [PATCH 299/380] Call cache.init() in cache.restore to reset EntityStore and result cache. If the cache already has much data in it, the speedup from throwing it all away before replacing it (and thus not having to delete each entity one by one) could be pretty significant. --- .../__tests__/__snapshots__/cache.ts.snap | 11 ++++ src/cache/inmemory/__tests__/cache.ts | 55 ++++++++++++++++++- src/cache/inmemory/inMemoryCache.ts | 4 ++ 3 files changed, 69 insertions(+), 1 deletion(-) diff --git a/src/cache/inmemory/__tests__/__snapshots__/cache.ts.snap b/src/cache/inmemory/__tests__/__snapshots__/cache.ts.snap index 6ba6bada5b1..c41f77bbc78 100644 --- a/src/cache/inmemory/__tests__/__snapshots__/cache.ts.snap +++ b/src/cache/inmemory/__tests__/__snapshots__/cache.ts.snap @@ -1,5 +1,16 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`Cache cache.restore replaces cache.{store{Reader,Writer},maybeBroadcastWatch} 1`] = ` +Object { + "ROOT_QUERY": Object { + "__typename": "Query", + "a": "ay", + "b": "bee", + "c": "see", + }, +} +`; + exports[`Cache writeFragment will write some deeply nested data into the store at any id (1/2) 1`] = ` Object { "__META": Object { diff --git a/src/cache/inmemory/__tests__/cache.ts b/src/cache/inmemory/__tests__/cache.ts index 6ab41b79a22..7ce7a6c9a6b 100644 --- a/src/cache/inmemory/__tests__/cache.ts +++ b/src/cache/inmemory/__tests__/cache.ts @@ -8,6 +8,9 @@ import { InMemoryCache, InMemoryCacheConfig } from '../inMemoryCache'; jest.mock('optimism'); import { wrap } from 'optimism'; +import { StoreReader } from '../readFromStore'; +import { StoreWriter } from '../writeToStore'; +import { ObjectCanon } from '../object-canon'; disableFragmentWarnings(); @@ -1330,7 +1333,57 @@ describe('Cache', () => { ); }); - describe('batch', () => { + describe('cache.restore', () => { + it('replaces cache.{store{Reader,Writer},maybeBroadcastWatch}', () => { + const cache = new InMemoryCache; + const query = gql`query { a b c }`; + + const originalReader = cache["storeReader"]; + expect(originalReader).toBeInstanceOf(StoreReader); + + const originalWriter = cache["storeWriter"]; + expect(originalWriter).toBeInstanceOf(StoreWriter); + + const originalMBW = cache["maybeBroadcastWatch"]; + expect(typeof originalMBW).toBe("function"); + + const originalCanon = originalReader["canon"]; + expect(originalCanon).toBeInstanceOf(ObjectCanon); + + cache.writeQuery({ + query, + data: { + a: "ay", + b: "bee", + c: "see", + }, + }); + + const snapshot = cache.extract(); + expect(snapshot).toMatchSnapshot(); + + cache.restore({}); + expect(cache.extract()).toEqual({}); + expect(cache.readQuery({ query })).toBe(null); + + cache.restore(snapshot); + expect(cache.extract()).toEqual(snapshot); + expect(cache.readQuery({ query })).toEqual({ + a: "ay", + b: "bee", + c: "see", + }); + + expect(originalReader).not.toBe(cache["storeReader"]); + expect(originalWriter).not.toBe(cache["storeWriter"]); + expect(originalMBW).not.toBe(cache["maybeBroadcastWatch"]); + // The cache.storeReader.canon is preserved by default, but can be dropped + // by passing preserveCanon:false to cache.gc. + expect(originalCanon).toBe(cache["storeReader"]["canon"]); + }); + }); + + describe('cache.batch', () => { const last = (array: E[]) => array[array.length - 1]; function watch(cache: InMemoryCache, query: DocumentNode) { diff --git a/src/cache/inmemory/inMemoryCache.ts b/src/cache/inmemory/inMemoryCache.ts index 115cd8dd01e..474ed469ace 100644 --- a/src/cache/inmemory/inMemoryCache.ts +++ b/src/cache/inmemory/inMemoryCache.ts @@ -159,6 +159,10 @@ export class InMemoryCache extends ApolloCache { } public restore(data: NormalizedCacheObject): this { + this.init(); + // Since calling this.init() discards/replaces the entire StoreReader, along + // with the result caches it maintains, this.data.replace(data) won't have + // to bother deleting the old data. if (data) this.data.replace(data); return this; } From 4a85baf875dc2b59744896b7a8c71ca81a4ec1bd Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Wed, 23 Jun 2021 15:20:53 -0400 Subject: [PATCH 300/380] Mention PR #8421 in CHANGELOG.md. --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f4d6d7ccf47..0ec48754a2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -103,6 +103,9 @@ - Add expected/received `variables` to `No more mocked responses...` error messages generated by `MockLink`.
[@markneub](github.com/markneub) in [#8340](https://github.com/apollographql/apollo-client/pull/8340) +- The `InMemoryCache` version of the `cache.gc` method now supports additional options for removing non-essential (recomputable) result caching data.
+ [@benjamn](https://github.com/benjamn) in [#8421](https://github.com/apollographql/apollo-client/pull/8421) + ### Documentation TBD From 8fb9f85b7c9ccf80ec20cbcd3dfc224229d54320 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Wed, 23 Jun 2021 15:32:51 -0400 Subject: [PATCH 301/380] Bump bundlesize limit from 24.4kB to 24.6kB. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a61ad34d427..67c90800946 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ { "name": "apollo-client", "path": "./dist/apollo-client.cjs.min.js", - "maxSize": "24.4 kB" + "maxSize": "24.6 kB" } ], "peerDependencies": { From f0fd5e7bad525b4811e6c3b01dbe3f4a53bde4e6 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Mon, 28 Jun 2021 15:06:09 -0400 Subject: [PATCH 302/380] Make StoreReader canon field public. https://github.com/apollographql/apollo-client/pull/8421#discussion_r659918131 --- src/cache/inmemory/__tests__/cache.ts | 4 ++-- src/cache/inmemory/__tests__/readFromStore.ts | 4 ++-- src/cache/inmemory/inMemoryCache.ts | 2 +- src/cache/inmemory/readFromStore.ts | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/cache/inmemory/__tests__/cache.ts b/src/cache/inmemory/__tests__/cache.ts index 7ce7a6c9a6b..d44eec76d8e 100644 --- a/src/cache/inmemory/__tests__/cache.ts +++ b/src/cache/inmemory/__tests__/cache.ts @@ -1347,7 +1347,7 @@ describe('Cache', () => { const originalMBW = cache["maybeBroadcastWatch"]; expect(typeof originalMBW).toBe("function"); - const originalCanon = originalReader["canon"]; + const originalCanon = originalReader.canon; expect(originalCanon).toBeInstanceOf(ObjectCanon); cache.writeQuery({ @@ -1379,7 +1379,7 @@ describe('Cache', () => { expect(originalMBW).not.toBe(cache["maybeBroadcastWatch"]); // The cache.storeReader.canon is preserved by default, but can be dropped // by passing preserveCanon:false to cache.gc. - expect(originalCanon).toBe(cache["storeReader"]["canon"]); + expect(originalCanon).toBe(cache["storeReader"].canon); }); }); diff --git a/src/cache/inmemory/__tests__/readFromStore.ts b/src/cache/inmemory/__tests__/readFromStore.ts index 5ac15d5f5be..060c5902f51 100644 --- a/src/cache/inmemory/__tests__/readFromStore.ts +++ b/src/cache/inmemory/__tests__/readFromStore.ts @@ -2025,7 +2025,7 @@ describe('reading from the store', () => { }, }); - const canon = cache["storeReader"]["canon"]; + const canon = cache["storeReader"].canon; const query = gql` query { @@ -2081,7 +2081,7 @@ describe('reading from the store', () => { }, }); - const canon = cache["storeReader"]["canon"]; + const canon = cache["storeReader"].canon; const fragment = gql` fragment CountFragment on Query { diff --git a/src/cache/inmemory/inMemoryCache.ts b/src/cache/inmemory/inMemoryCache.ts index 474ed469ace..e43b70ab5cd 100644 --- a/src/cache/inmemory/inMemoryCache.ts +++ b/src/cache/inmemory/inMemoryCache.ts @@ -124,7 +124,7 @@ export class InMemoryCache extends ApolloCache { canon: ( preserveCanon && previousReader && - previousReader["canon"] + previousReader.canon ) || void 0, }), ); diff --git a/src/cache/inmemory/readFromStore.ts b/src/cache/inmemory/readFromStore.ts index 63d87046620..37200a3110a 100644 --- a/src/cache/inmemory/readFromStore.ts +++ b/src/cache/inmemory/readFromStore.ts @@ -129,12 +129,12 @@ export class StoreReader { resultCacheMaxSize?: number; }; - private canon: ObjectCanon; - private knownResults = new ( canUseWeakMap ? WeakMap : Map ), SelectionSetNode>(); + public canon: ObjectCanon; + constructor(config: StoreReaderConfig) { this.config = { ...config, From 713796a4860db1662c9ea0ef44f76d056bab2bb1 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Mon, 28 Jun 2021 15:15:21 -0400 Subject: [PATCH 303/380] Align naming of resetResultCache and resetResultIdentities. https://github.com/apollographql/apollo-client/pull/8421#discussion_r659931222 https://github.com/apollographql/apollo-client/pull/8421#discussion_r659935784 --- src/cache/inmemory/__tests__/entityStore.ts | 2 +- src/cache/inmemory/inMemoryCache.ts | 24 +++++++++++---------- src/cache/inmemory/readFromStore.ts | 3 +++ 3 files changed, 17 insertions(+), 12 deletions(-) diff --git a/src/cache/inmemory/__tests__/entityStore.ts b/src/cache/inmemory/__tests__/entityStore.ts index c1ac73c284b..fd92516f5cc 100644 --- a/src/cache/inmemory/__tests__/entityStore.ts +++ b/src/cache/inmemory/__tests__/entityStore.ts @@ -214,7 +214,7 @@ describe('EntityStore', () => { // Now discard cache.storeReader.canon as well. expect(cache.gc({ resetResultCache: true, - preserveCanon: false, + resetResultIdentities: true, })).toEqual([]); const resultAfterFullGC = cache.readQuery({ query }); diff --git a/src/cache/inmemory/inMemoryCache.ts b/src/cache/inmemory/inMemoryCache.ts index e43b70ab5cd..463d91723ca 100644 --- a/src/cache/inmemory/inMemoryCache.ts +++ b/src/cache/inmemory/inMemoryCache.ts @@ -109,7 +109,7 @@ export class InMemoryCache extends ApolloCache { this.resetResultCache(); } - private resetResultCache(preserveCanon = true) { + private resetResultCache(resetResultIdentities?: boolean) { const previousReader = this.storeReader; // The StoreWriter is mostly stateless and so doesn't really need to be @@ -121,11 +121,9 @@ export class InMemoryCache extends ApolloCache { cache: this, addTypename: this.addTypename, resultCacheMaxSize: this.config.resultCacheMaxSize, - canon: ( - preserveCanon && - previousReader && - previousReader.canon - ) || void 0, + canon: resetResultIdentities + ? void 0 + : previousReader && previousReader.canon, }), ); @@ -285,14 +283,18 @@ export class InMemoryCache extends ApolloCache { // this.{store{Reader,Writer},maybeBroadcastWatch}. Defaults to false. resetResultCache?: boolean; // If resetResultCache is true, this.storeReader.canon will be preserved by - // default, but can also be discarded by passing false for preserveCanon. - // Defaults to true, but has no effect if resetResultCache is false. - preserveCanon?: boolean; + // default, but can also be discarded by passing resetResultIdentities:true. + // Defaults to false. + resetResultIdentities?: boolean; }) { canonicalStringify.reset(); const ids = this.optimisticData.gc(); - if (options && options.resetResultCache && !this.txCount) { - this.resetResultCache(options.preserveCanon); + if (options && !this.txCount) { + if (options.resetResultCache) { + this.resetResultCache(options.resetResultIdentities); + } else if (options.resetResultIdentities) { + this.storeReader.resetCanon(); + } } return ids; } diff --git a/src/cache/inmemory/readFromStore.ts b/src/cache/inmemory/readFromStore.ts index 37200a3110a..81a9b585ef9 100644 --- a/src/cache/inmemory/readFromStore.ts +++ b/src/cache/inmemory/readFromStore.ts @@ -134,6 +134,9 @@ export class StoreReader { ), SelectionSetNode>(); public canon: ObjectCanon; + public resetCanon() { + this.canon = new ObjectCanon; + } constructor(config: StoreReaderConfig) { this.config = { From 10d75582c03c42b99109e0875a5de667ba5355fe Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Mon, 28 Jun 2021 16:11:15 -0400 Subject: [PATCH 304/380] Add FinalizationRegistry test of cache.gc, and make it pass. https://github.com/apollographql/apollo-client/pull/8421#discussion_r659942273 --- scripts/memory/tests.js | 102 ++++++++++++++++++++++++++++ src/cache/inmemory/entityStore.ts | 15 ++-- src/cache/inmemory/inMemoryCache.ts | 8 +++ 3 files changed, 120 insertions(+), 5 deletions(-) diff --git a/scripts/memory/tests.js b/scripts/memory/tests.js index 99413446160..670beb42c90 100644 --- a/scripts/memory/tests.js +++ b/scripts/memory/tests.js @@ -95,6 +95,108 @@ describe("garbage collection", () => { })); }); + itAsync("should release cache.storeReader if requested via cache.gc", (resolve, reject) => { + const expectedKeys = { + __proto__: null, + StoreReader1: true, + ObjectCanon1: true, + StoreReader2: true, + ObjectCanon2: true, + StoreReader3: false, + ObjectCanon3: false, + }; + + const registry = makeRegistry(key => { + // Referring to client here should keep the client itself alive + // until after the ObservableQuery is (or should have been) + // collected. Collecting the ObservableQuery just because the whole + // client instance was collected is not interesting. + assert.strictEqual(client instanceof ApolloClient, true); + if (key in expectedKeys) { + assert.strictEqual(expectedKeys[key], true, key); + } + delete expectedKeys[key]; + if (Object.keys(expectedKeys).every(key => !expectedKeys[key])) { + setTimeout(resolve, 100); + } + }, reject); + + const cache = new InMemoryCache({ + typePolicies: { + Query: { + fields: { + local() { + return "hello"; + }, + }, + }, + }, + }); + + const client = new ApolloClient({ cache }); + + (function () { + const query = gql`query { local }`; + const obsQuery = client.watchQuery({ query }); + + function register(suffix) { + const reader = cache["storeReader"]; + registry.register(reader, "StoreReader" + suffix); + registry.register(reader.canon, "ObjectCanon" + suffix); + } + + register(1); + + const sub = obsQuery.subscribe({ + next(result) { + assert.deepStrictEqual(result.data, { + local: "hello", + }); + + assert.strictEqual( + cache.readQuery({ query }), + result.data, + ); + + assert.deepStrictEqual(cache.gc(), []); + + // Nothing changes because we merely called cache.gc(). + assert.strictEqual( + cache.readQuery({ query }), + result.data, + ); + + assert.deepStrictEqual(cache.gc({ + // Now reset the result cache but preserve reader.canon, so the + // results will be === even though they have to be recomputed. + resetResultCache: true, + resetResultIdentities: false, + }), []); + + register(2); + + const dataAfterResetWithSameCanon = cache.readQuery({ query }); + assert.strictEqual(dataAfterResetWithSameCanon, result.data); + + assert.deepStrictEqual(cache.gc({ + // Finally, do a full reset of the result caching system, including + // discarding reader.canon, so === result identity is lost. + resetResultCache: true, + resetResultIdentities: true, + }), []); + + register(3); + + const dataAfterFullReset = cache.readQuery({ query }); + assert.notStrictEqual(dataAfterFullReset, result.data); + assert.deepStrictEqual(dataAfterFullReset, result.data); + + sub.unsubscribe(); + }, + }); + })(); + }); + itAsync("should collect ObservableQuery after tear-down", (resolve, reject) => { const expectedKeys = new Set([ "ObservableQuery", diff --git a/src/cache/inmemory/entityStore.ts b/src/cache/inmemory/entityStore.ts index 64cad62ccc8..7d9eec0292d 100644 --- a/src/cache/inmemory/entityStore.ts +++ b/src/cache/inmemory/entityStore.ts @@ -507,11 +507,20 @@ export type FieldValueGetter = EntityStore["getFieldValue"]; class CacheGroup { private d: OptimisticDependencyFunction | null = null; + // Used by the EntityStore#makeCacheKey method to compute cache keys + // specific to this CacheGroup. + public keyMaker: Trie; + constructor( public readonly caching: boolean, private parent: CacheGroup | null = null, ) { - this.d = caching ? dep() : null; + this.resetCaching(); + } + + public resetCaching() { + this.d = this.caching ? dep() : null; + this.keyMaker = new Trie(canUseWeakMap); } public depend(dataId: string, storeFieldName: string) { @@ -547,10 +556,6 @@ class CacheGroup { ); } } - - // Used by the EntityStore#makeCacheKey method to compute cache keys - // specific to this CacheGroup. - public readonly keyMaker = new Trie(canUseWeakMap); } function makeDepKey(dataId: string, storeFieldName: string) { diff --git a/src/cache/inmemory/inMemoryCache.ts b/src/cache/inmemory/inMemoryCache.ts index 463d91723ca..f48a210f071 100644 --- a/src/cache/inmemory/inMemoryCache.ts +++ b/src/cache/inmemory/inMemoryCache.ts @@ -154,6 +154,14 @@ export class InMemoryCache extends ApolloCache { } } }); + + // Since we have thrown away all the cached functions that depend on the + // CacheGroup dependencies maintained by EntityStore, we should also reset + // all CacheGroup dependency information. + new Set([ + this.data.group, + this.optimisticData.group, + ]).forEach(group => group.resetCaching()); } public restore(data: NormalizedCacheObject): this { From bb3daf0b9d5e4b49623a93574412f5adce49306d Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Mon, 28 Jun 2021 16:47:59 -0400 Subject: [PATCH 305/380] Fix comments still mentioning cache.gc({ preserveCanon }). --- src/cache/inmemory/__tests__/cache.ts | 2 +- src/cache/inmemory/__tests__/entityStore.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/cache/inmemory/__tests__/cache.ts b/src/cache/inmemory/__tests__/cache.ts index d44eec76d8e..809786a20d9 100644 --- a/src/cache/inmemory/__tests__/cache.ts +++ b/src/cache/inmemory/__tests__/cache.ts @@ -1378,7 +1378,7 @@ describe('Cache', () => { expect(originalWriter).not.toBe(cache["storeWriter"]); expect(originalMBW).not.toBe(cache["maybeBroadcastWatch"]); // The cache.storeReader.canon is preserved by default, but can be dropped - // by passing preserveCanon:false to cache.gc. + // by passing resetResultIdentities:true to cache.gc. expect(originalCanon).toBe(cache["storeReader"].canon); }); }); diff --git a/src/cache/inmemory/__tests__/entityStore.ts b/src/cache/inmemory/__tests__/entityStore.ts index fd92516f5cc..7a57ab251c4 100644 --- a/src/cache/inmemory/__tests__/entityStore.ts +++ b/src/cache/inmemory/__tests__/entityStore.ts @@ -220,8 +220,8 @@ describe('EntityStore', () => { const resultAfterFullGC = cache.readQuery({ query }); expect(resultAfterFullGC).toEqual(resultBeforeGC); expect(resultAfterFullGC).toEqual(resultAfterGC); - // These !== relations are triggered by the preserveCanon:false option - // passed to cache.gc, above. + // These !== relations are triggered by passing resetResultIdentities:true + // to cache.gc, above. expect(resultAfterFullGC).not.toBe(resultBeforeGC); expect(resultAfterFullGC).not.toBe(resultAfterGC); // Result caching immediately begins working again after the intial reset. From 3161e31538c33f3aafb18f955fbee0e6e7a0b0c0 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 6 Jul 2021 14:29:22 -0400 Subject: [PATCH 306/380] Bump @apollo/client npm version to 3.4.0-rc.17. --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 480d42b32c7..d872e503ced 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-rc.16", + "version": "3.4.0-rc.17", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 67c90800946..95b4cdc4b4f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-rc.16", + "version": "3.4.0-rc.17", "description": "A fully-featured caching GraphQL client.", "private": true, "keywords": [ From 337be9ec6874db0475845f8662fad24bfaf18cb5 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 6 Jul 2021 18:24:37 -0400 Subject: [PATCH 307/380] Inline private QueryInfo#canonize method. This method made more sense back when it had more than one caller. --- src/core/QueryInfo.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/core/QueryInfo.ts b/src/core/QueryInfo.ts index e0ddfa17a25..6f89ea9ebab 100644 --- a/src/core/QueryInfo.ts +++ b/src/core/QueryInfo.ts @@ -184,12 +184,13 @@ export class QueryInfo { } private getDiffOptions(variables = this.variables): Cache.DiffOptions { + const oq = this.observableQuery; return { query: this.document!, variables, returnPartialData: true, optimistic: true, - canonizeResults: this.canonize(), + canonizeResults: !oq || oq.options.canonizeResults !== false, }; } @@ -311,11 +312,6 @@ export class QueryInfo { } } - private canonize() { - const oq = this.observableQuery; - return !oq || oq.options.canonizeResults !== false; - } - private lastWrite?: { result: FetchResult; variables: WatchQueryOptions["variables"]; From b502008ff386e9baebc1fe309e7a471855b76ac7 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Wed, 7 Jul 2021 19:15:08 -0400 Subject: [PATCH 308/380] Revert "Make Rollup inject fewer _interopNamespace helpers." This reverts commit 1bb182c40c320ec89be3667c8f81965328c07aeb. Fixes #8464 --- config/rollup.config.js | 1 - 1 file changed, 1 deletion(-) diff --git a/config/rollup.config.js b/config/rollup.config.js index 7db275f0777..90d381f7a5c 100644 --- a/config/rollup.config.js +++ b/config/rollup.config.js @@ -112,7 +112,6 @@ function prepareBundle({ format: 'cjs', sourcemap: true, exports: 'named', - interop: 'esModule', externalLiveBindings: false, }, plugins: [ From 4e6c1c2dd4d7b55e7e5c85320c5fc3794e0eceeb Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Fri, 9 Jul 2021 12:17:34 -0400 Subject: [PATCH 309/380] Update to zen-observable-ts@1.1.0, fixing #8467. https://github.com/apollographql/zen-observable-ts/pull/105 --- package-lock.json | 16 ++++++++-------- package.json | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index d872e503ced..f03000c01fa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1975,9 +1975,9 @@ "dev": true }, "@types/zen-observable": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/@types/zen-observable/-/zen-observable-0.8.2.tgz", - "integrity": "sha512-HrCIVMLjE1MOozVoD86622S7aunluLb2PJdPfb3nYiEtohm8mIB/vyv0Fd37AdeMFrTUQXEunw78YloMA3Qilg==" + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/@types/zen-observable/-/zen-observable-0.8.3.tgz", + "integrity": "sha512-fbF6oTd4sGGy0xjHPKAt+eS2CrxJ3+6gQ3FGcBoIJR2TLAyCkCyI8JqZNy+FeON0AhVgNJoUumVoZQjBFUqHkw==" }, "@wry/context": { "version": "0.6.0", @@ -9667,12 +9667,12 @@ "integrity": "sha512-PQ2PC7R9rslx84ndNBZB/Dkv8V8fZEpk83RLgXtYd0fwUgEjseMn1Dgajh2x6S8QbZAFa9p2qVCEuYZNgve0dQ==" }, "zen-observable-ts": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/zen-observable-ts/-/zen-observable-ts-1.0.0.tgz", - "integrity": "sha512-KmWcbz+9kKUeAQ8btY8m1SsEFgBcp7h/Uf3V5quhan7ZWdjGsf0JcGLULQiwOZibbFWnHkYq8Nn2AZbJabovQg==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/zen-observable-ts/-/zen-observable-ts-1.1.0.tgz", + "integrity": "sha512-1h4zlLSqI2cRLPJUHJFL8bCWHhkpuXkF+dbGkRaWjgDIG26DmzyshUMrdV/rL3UnR+mhaX4fRq8LPouq0MYYIA==", "requires": { - "@types/zen-observable": "^0.8.2", - "zen-observable": "^0.8.15" + "@types/zen-observable": "0.8.3", + "zen-observable": "0.8.15" } } } diff --git a/package.json b/package.json index 95b4cdc4b4f..49dfcee46fa 100644 --- a/package.json +++ b/package.json @@ -83,7 +83,7 @@ "symbol-observable": "^4.0.0", "ts-invariant": "^0.8.2", "tslib": "^2.1.0", - "zen-observable-ts": "^1.0.0" + "zen-observable-ts": "^1.1.0" }, "devDependencies": { "@babel/parser": "7.14.7", From add15f540348a3bb529c66c038c3945b4bd77c04 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 6 Jul 2021 17:50:28 -0400 Subject: [PATCH 310/380] Avoid deleting options.nextFetchPolicy after applying it. Despite my vague suggestion in the removed comments that deleting options.nextFetchPolicy somehow helped make the fetchPolicy transition idempotent (a concern that only matters when nextFetchPolicy is a function), leaving options.nextFetchPolicy intact should also be safe/idempotent in virtually all cases, including every case where nextFetchPolicy is just a string, rather than a function. More importantly, leaving options.nextFetchPolicy intact allows it to be applied more than once, which seems to fix issue #6839. --- src/__tests__/client.ts | 4 +--- src/core/ObservableQuery.ts | 5 ----- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/src/__tests__/client.ts b/src/__tests__/client.ts index c810a705fad..a0f822d3e1b 100644 --- a/src/__tests__/client.ts +++ b/src/__tests__/client.ts @@ -3319,7 +3319,6 @@ describe('@connection', () => { // again to perform an additional transition. this.nextFetchPolicy = fetchPolicy => { ++nextFetchPolicyCallCount; - expect(fetchPolicy).toBe("cache-and-network"); return "cache-first"; }; return "cache-and-network"; @@ -3372,9 +3371,8 @@ describe('@connection', () => { client.cache.evict({ fieldName: "count" }); } else if (handleCount === 6) { expect(result.data).toEqual({ count: 2 }); - expect(nextFetchPolicyCallCount).toBe(3); + expect(nextFetchPolicyCallCount).toBe(4); expect(obs.options.fetchPolicy).toBe("cache-first"); - expect(obs.options.nextFetchPolicy).toBeUndefined(); setTimeout(resolve, 50); } else { reject("too many results"); diff --git a/src/core/ObservableQuery.ts b/src/core/ObservableQuery.ts index a0825f65888..25bb97179e5 100644 --- a/src/core/ObservableQuery.ts +++ b/src/core/ObservableQuery.ts @@ -732,11 +732,6 @@ export function applyNextFetchPolicy( } = options; if (nextFetchPolicy) { - // The options.nextFetchPolicy transition should happen only once, but it - // should also be possible (though uncommon) for a nextFetchPolicy function - // to set options.nextFetchPolicy to perform an additional transition. - options.nextFetchPolicy = void 0; - // When someone chooses "cache-and-network" or "network-only" as their // initial FetchPolicy, they often do not want future cache updates to // trigger unconditional network requests, which is what repeatedly From 143e2ea29c0f1eeffbee7decc5778291ab6bd770 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 6 Jul 2021 18:52:07 -0400 Subject: [PATCH 311/380] Specify which ObservableQuery operations run independently. ObservableQuery reobserve operations can either modify this.options and replace this.concast with a Concast derived from those modified options, or they can use a (shallow) copy of this.options to create a disposable Concast just for the current reobserve operation, without permanently altering the configuration of the ObservableQuery. This commit clarifies which operations receive fresh options (and do not modify this.concast): NetworkStatus.{refetch,poll,fetchMore}, as decided in one place, by the ObservableQuery#reobserve method. This list is subject to change, and I suspect we may end up wanting to use criteria besides NetworkStatus in some cases. For now, NetworkStatus seems to capture the cases we care about. Since NetworkStatus.poll operations "run independently" now, with their own shallow copy of this.options, the forced fetchPolicy: "network-only" no longer needs to be reset by nextFetchPolicy. More generally, since polling no longer modifies the ObservableQuery options at all, the interaction betwee polling and other kinds of network fetches should be less complicated after this commit. --- src/core/ObservableQuery.ts | 44 +++++++++++++++++++++++-------------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/src/core/ObservableQuery.ts b/src/core/ObservableQuery.ts index 25bb97179e5..8cdcc9a7551 100644 --- a/src/core/ObservableQuery.ts +++ b/src/core/ObservableQuery.ts @@ -586,7 +586,6 @@ once, rather than every time you call fetchMore.`); if (!isNetworkRequestInFlight(this.queryInfo.networkStatus)) { this.reobserve({ fetchPolicy: "network-only", - nextFetchPolicy: this.options.fetchPolicy || "cache-first", }, NetworkStatus.poll).then(poll, poll); } else { poll(); @@ -622,26 +621,38 @@ once, rather than every time you call fetchMore.`); newNetworkStatus?: NetworkStatus, ): Promise> { this.isTornDown = false; - let options: WatchQueryOptions; - if (newNetworkStatus === NetworkStatus.refetch) { - options = Object.assign({}, this.options, compact(newOptions)); - } else { - if (newOptions) { - Object.assign(this.options, compact(newOptions)); - } + const useDisposableConcast = + // Refetching uses a disposable Concast to allow refetches using different + // options/variables, without permanently altering the options of the + // original ObservableQuery. + newNetworkStatus === NetworkStatus.refetch || + // The fetchMore method does not actually call the reobserve method, but, + // if it did, it would definitely use a disposable Concast. + newNetworkStatus === NetworkStatus.fetchMore || + // Polling uses a disposable Concast so the polling options (which force + // fetchPolicy to be "network-only") won't override the original options. + newNetworkStatus === NetworkStatus.poll; + + const options = useDisposableConcast + // Disposable Concast fetches receive a shallow copy of this.options + // (merged with newOptions), leaving this.options unmodified. + ? compact(this.options, newOptions) + : Object.assign(this.options, compact(newOptions)); + + // We can skip calling updatePolling if we're not changing this.options. + if (!useDisposableConcast) { this.updatePolling(); - options = this.options; } const concast = this.fetch(options, newNetworkStatus); - if (newNetworkStatus !== NetworkStatus.refetch) { - // We use the {add,remove}Observer methods directly to avoid - // wrapping observer with an unnecessary SubscriptionObserver - // object, in part so that we can remove it here without triggering - // any unsubscriptions, because we just want to ignore the old - // observable, not prematurely shut it down, since other consumers - // may be awaiting this.concast.promise. + + if (!useDisposableConcast) { + // We use the {add,remove}Observer methods directly to avoid wrapping + // observer with an unnecessary SubscriptionObserver object, in part so + // that we can remove it here without triggering any unsubscriptions, + // because we just want to ignore the old observable, not prematurely shut + // it down, since other consumers may be awaiting this.concast.promise. if (this.concast) { this.concast.removeObserver(this.observer, true); } @@ -650,6 +661,7 @@ once, rather than every time you call fetchMore.`); } concast.addObserver(this.observer); + return concast.promise; } From 231ffbd204650b255fc62ca7dbd1bfe76202d4b6 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 6 Jul 2021 19:16:52 -0400 Subject: [PATCH 312/380] Reset options.fetchPolicy in ObservableQuery#setVariables. This change seems consistent with the goals of #7437, though variables can be changed without calling ObservableQuery#setVariables. --- src/core/ObservableQuery.ts | 27 +++++++++++---------------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/src/core/ObservableQuery.ts b/src/core/ObservableQuery.ts index 8cdcc9a7551..53839e2708a 100644 --- a/src/core/ObservableQuery.ts +++ b/src/core/ObservableQuery.ts @@ -21,6 +21,7 @@ import { WatchQueryOptions, FetchMoreQueryOptions, SubscribeToMoreOptions, + WatchQueryFetchPolicy, } from './watchQueryOptions'; import { QueryInfo } from './QueryInfo'; @@ -57,6 +58,10 @@ export class ObservableQuery< return this.options.variables; } + // Original value of this.options.fetchPolicy (defaulting to "cache-first"), + // from whenever the ObservableQuery was first created. + public initialFetchPolicy: WatchQueryFetchPolicy; + private isTornDown: boolean; private queryManager: QueryManager; private observers = new Set>>(); @@ -129,6 +134,8 @@ export class ObservableQuery< const opDef = getOperationDefinition(options.query); this.queryName = opDef && opDef.name && opDef.name.value; + this.initialFetchPolicy = options.fetchPolicy || "cache-first"; + // related classes this.queryManager = queryManager; this.queryInfo = queryInfo; @@ -475,23 +482,11 @@ once, rather than every time you call fetchMore.`); return Promise.resolve(); } - let { fetchPolicy = 'cache-first' } = this.options; - const reobserveOptions: Partial> = { - fetchPolicy, + return this.reobserve({ + // Reset options.fetchPolicy to its original value. + fetchPolicy: this.initialFetchPolicy, variables, - }; - - if (fetchPolicy !== 'cache-first' && - fetchPolicy !== 'no-cache' && - fetchPolicy !== 'network-only') { - reobserveOptions.fetchPolicy = 'cache-and-network'; - reobserveOptions.nextFetchPolicy = fetchPolicy; - } - - return this.reobserve( - reobserveOptions, - NetworkStatus.setVariables, - ); + }, NetworkStatus.setVariables); } public updateQuery( From 1ab0161871b3bdafb6b9c0feedf852e395edaad0 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 6 Jul 2021 19:32:57 -0400 Subject: [PATCH 313/380] Improve ObservableQuery nextFetchPolicy test. --- src/core/__tests__/ObservableQuery.ts | 35 +++++++++++++++++++++------ 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/src/core/__tests__/ObservableQuery.ts b/src/core/__tests__/ObservableQuery.ts index d0a2b27175c..d2399db82aa 100644 --- a/src/core/__tests__/ObservableQuery.ts +++ b/src/core/__tests__/ObservableQuery.ts @@ -978,22 +978,43 @@ describe('ObservableQuery', () => { const mocks = mockFetchQuery(queryManager); - subscribeAndCount(reject, observable, handleCount => { - if (handleCount === 1) { + subscribeAndCount(reject, observable, (count, result) => { + if (count === 1) { + expect(result).toEqual({ + loading: false, + networkStatus: NetworkStatus.ready, + data: dataOne, + }); + observable.refetch(differentVariables); - } else if (handleCount === 2) { + + } else if (count === 2) { + expect(result).toEqual({ + loading: false, + networkStatus: NetworkStatus.ready, + data: dataTwo, + }); + const fqbpCalls = mocks.fetchQueryByPolicy.mock.calls; expect(fqbpCalls.length).toBe(2); + expect(fqbpCalls[0][1].fetchPolicy).toEqual('cache-first'); expect(fqbpCalls[1][1].fetchPolicy).toEqual('network-only'); + + const fqoCalls = mocks.fetchQueryObservable.mock.calls; + expect(fqoCalls.length).toBe(2); + expect(fqoCalls[0][1].fetchPolicy).toEqual('cache-first'); + expect(fqoCalls[1][1].fetchPolicy).toEqual('network-only'); + // Although the options.fetchPolicy we passed just now to // fetchQueryByPolicy should have been network-only, // observable.options.fetchPolicy should now be updated to // cache-first, thanks to options.nextFetchPolicy. expect(observable.options.fetchPolicy).toBe('cache-first'); - const fqoCalls = mocks.fetchQueryObservable.mock.calls; - expect(fqoCalls.length).toBe(2); - expect(fqoCalls[1][1].fetchPolicy).toEqual('cache-first'); - resolve(); + + // Give the test time to fail if more results are delivered. + setTimeout(resolve, 50); + } else { + reject(new Error(`too many results (${count}, ${result})`)); } }); }); From 1dcc2e8a1a32aed3e4fd7cb431bcc4633a9fe0df Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 6 Jul 2021 19:30:53 -0400 Subject: [PATCH 314/380] Avoid specifying options.nextFetchPolicy for refetch operations. Specifying options.nextFetchPolicy is unnecessary because refetch operations receive a (disposable) copy of the ObservableQuery options, so options.fetchPolicy does not need to be reset. --- src/core/ObservableQuery.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/core/ObservableQuery.ts b/src/core/ObservableQuery.ts index 53839e2708a..298483a82a4 100644 --- a/src/core/ObservableQuery.ts +++ b/src/core/ObservableQuery.ts @@ -290,8 +290,6 @@ export class ObservableQuery< reobserveOptions.fetchPolicy = 'no-cache'; } else if (fetchPolicy !== 'cache-and-network') { reobserveOptions.fetchPolicy = 'network-only'; - // Go back to the original options.fetchPolicy after this refetch. - reobserveOptions.nextFetchPolicy = fetchPolicy || "cache-first"; } if (variables && !equal(this.options.variables, variables)) { From 4bd46edeea9ec2cfacfed29491a44abb7e0aa2be Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 29 Jun 2021 16:36:04 -0400 Subject: [PATCH 315/380] Avoid calling applyNextFetchPolicy in prepareObservableQueryOptions. https://github.com/apollographql/apollo-client/issues/8426#issuecomment-869937683 --- src/react/data/QueryData.ts | 3 -- src/react/hoc/__tests__/queries/skip.test.tsx | 47 ++++++++++++++----- 2 files changed, 35 insertions(+), 15 deletions(-) diff --git a/src/react/data/QueryData.ts b/src/react/data/QueryData.ts index 9d97f7283e5..8014c13a989 100644 --- a/src/react/data/QueryData.ts +++ b/src/react/data/QueryData.ts @@ -8,7 +8,6 @@ import { FetchMoreQueryOptions, SubscribeToMoreOptions, ObservableQuery, - applyNextFetchPolicy, FetchMoreOptions, UpdateQueryOptions, DocumentNode, @@ -199,8 +198,6 @@ export class QueryData extends OperationData< options.fetchPolicy === 'cache-and-network') ) { options.fetchPolicy = 'cache-first'; - } else if (options.nextFetchPolicy && this.currentObservable) { - applyNextFetchPolicy(options); } return { diff --git a/src/react/hoc/__tests__/queries/skip.test.tsx b/src/react/hoc/__tests__/queries/skip.test.tsx index ff0d0b1a3dc..8b6a960da18 100644 --- a/src/react/hoc/__tests__/queries/skip.test.tsx +++ b/src/react/hoc/__tests__/queries/skip.test.tsx @@ -589,6 +589,7 @@ describe('[queries] skip', () => { const data = { allPeople: { people: [{ name: 'Luke Skywalker' }] } }; const nextData = { allPeople: { people: [{ name: 'Anakin Skywalker' }] } }; + const finalData = { allPeople: { people: [{ name: 'Darth Vader' }] } }; let ranQuery = 0; @@ -605,6 +606,14 @@ describe('[queries] skip', () => { request: { query }, result: { data: nextData }, }, + { + request: { query }, + result: { data: nextData }, + }, + { + request: { query }, + result: { data: finalData }, + }, ) ); @@ -625,6 +634,8 @@ describe('[queries] skip', () => { })( class extends React.Component { render() { + expect(this.props.data?.error).toBeUndefined(); + switch (++count) { case 1: expect(this.props.data.loading).toBe(true); @@ -639,40 +650,52 @@ describe('[queries] skip', () => { expect(ranQuery).toBe(1); setTimeout(() => { this.props.setSkip(true); - }); + }, 10); break; case 3: // This render is triggered after setting skip to true. Now // let's set skip to false to re-trigger the query. + expect(this.props.skip).toBe(true); expect(this.props.data).toBeUndefined(); expect(ranQuery).toBe(1); setTimeout(() => { this.props.setSkip(false); - }); + }, 10); break; case 4: + expect(this.props.skip).toBe(false); + expect(this.props.data!.loading).toBe(true); + expect(this.props.data.allPeople).toEqual(data.allPeople); + expect(ranQuery).toBe(2); + break; + case 5: + expect(this.props.skip).toBe(false); + expect(this.props.data!.loading).toBe(false); + expect(this.props.data.allPeople).toEqual(nextData.allPeople); + expect(ranQuery).toBe(3); // Since the `nextFetchPolicy` was set to `cache-first`, our // query isn't loading as it's able to find the result of the // query directly from the cache. Let's trigger a refetch // to manually load the next batch of data. - expect(this.props.data!.loading).toBe(false); - expect(this.props.data.allPeople).toEqual(data.allPeople); - expect(ranQuery).toBe(1); setTimeout(() => { this.props.data.refetch(); - }); + }, 10); break; - case 5: + case 6: + expect(this.props.skip).toBe(false); expect(this.props.data!.loading).toBe(true); - expect(ranQuery).toBe(2); + expect(this.props.data.allPeople).toEqual(nextData.allPeople); + expect(ranQuery).toBe(4); break; - case 6: + case 7: // The next batch of data has loaded. + expect(this.props.skip).toBe(false); expect(this.props.data!.loading).toBe(false); - expect(this.props.data.allPeople).toEqual(nextData.allPeople); - expect(ranQuery).toBe(2); + expect(this.props.data.allPeople).toEqual(finalData.allPeople); + expect(ranQuery).toBe(4); break; default: + throw new Error(`too many renders (${count})`); } return null; } @@ -698,7 +721,7 @@ describe('[queries] skip', () => { ); await wait(() => { - expect(count).toEqual(6); + expect(count).toEqual(7); }); }); From bdac212592472dd040d6e7a902d052de26793ce7 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Wed, 7 Jul 2021 19:41:05 -0400 Subject: [PATCH 316/380] Improve type for QueryData["previous"]["observableQueryOptions"]. --- src/react/data/QueryData.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/react/data/QueryData.ts b/src/react/data/QueryData.ts index 8014c13a989..b6d05cde5ea 100644 --- a/src/react/data/QueryData.ts +++ b/src/react/data/QueryData.ts @@ -28,6 +28,9 @@ import { } from '../types/types'; import { OperationData } from './OperationData'; +type ObservableQueryOptions = + ReturnType["prepareObservableQueryOptions"]>; + export class QueryData extends OperationData< QueryDataOptions > { @@ -39,7 +42,7 @@ export class QueryData extends OperationData< private previous: { client?: ApolloClient; query?: DocumentNode | TypedDocumentNode; - observableQueryOptions?: {}; + observableQueryOptions?: ObservableQueryOptions; result?: QueryResult; loading?: boolean; options?: QueryDataOptions; @@ -222,7 +225,7 @@ export class QueryData extends OperationData< this.previous.observableQueryOptions = { ...observableQueryOptions, - children: null + children: void 0, }; this.currentObservable = this.refreshClient().client.watchQuery({ ...observableQueryOptions @@ -246,7 +249,7 @@ export class QueryData extends OperationData< const newObservableQueryOptions = { ...this.prepareObservableQueryOptions(), - children: null + children: void 0, }; if (this.getOptions().skip) { From 83703c2bebe483a29161d284f8b4ca2d44e81c4a Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Wed, 7 Jul 2021 19:39:23 -0400 Subject: [PATCH 317/380] Reliably reset fetchPolicy to original value when variables change. --- src/core/ObservableQuery.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/core/ObservableQuery.ts b/src/core/ObservableQuery.ts index 298483a82a4..d32122ee0b5 100644 --- a/src/core/ObservableQuery.ts +++ b/src/core/ObservableQuery.ts @@ -627,15 +627,32 @@ once, rather than every time you call fetchMore.`); // fetchPolicy to be "network-only") won't override the original options. newNetworkStatus === NetworkStatus.poll; + // Save the old variables, since Object.assign may modify them below. + const oldVariables = this.options.variables; + const options = useDisposableConcast // Disposable Concast fetches receive a shallow copy of this.options // (merged with newOptions), leaving this.options unmodified. ? compact(this.options, newOptions) : Object.assign(this.options, compact(newOptions)); - // We can skip calling updatePolling if we're not changing this.options. if (!useDisposableConcast) { + // We can skip calling updatePolling if we're not changing this.options. this.updatePolling(); + + // Reset options.fetchPolicy to its original value when variables change, + // unless a new fetchPolicy was provided by newOptions. + if ( + newOptions && + newOptions.variables && + !newOptions.fetchPolicy && + !equal(newOptions.variables, oldVariables) + ) { + options.fetchPolicy = this.initialFetchPolicy; + if (newNetworkStatus === void 0) { + newNetworkStatus = NetworkStatus.setVariables; + } + } } const concast = this.fetch(options, newNetworkStatus); From 5d47432181fce23c3d89f6c0a15baa09014c7201 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Thu, 8 Jul 2021 15:45:16 -0400 Subject: [PATCH 318/380] Test that fetchPolicy is reset when variables change. --- src/core/__tests__/ObservableQuery.ts | 112 ++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) diff --git a/src/core/__tests__/ObservableQuery.ts b/src/core/__tests__/ObservableQuery.ts index d2399db82aa..d1b585fe71e 100644 --- a/src/core/__tests__/ObservableQuery.ts +++ b/src/core/__tests__/ObservableQuery.ts @@ -1138,6 +1138,118 @@ describe('ObservableQuery', () => { }); }); + itAsync('resets fetchPolicy when variables change when using nextFetchPolicy', (resolve, reject) => { + // This query and variables are copied from react-apollo + const queryWithVars = gql` + query people($first: Int) { + allPeople(first: $first) { + people { + name + } + } + } + `; + + const data = { allPeople: { people: [{ name: 'Luke Skywalker' }] } }; + const variables1 = { first: 0 }; + + const data2 = { allPeople: { people: [{ name: 'Leia Skywalker' }] } }; + const variables2 = { first: 1 }; + + const queryManager = mockQueryManager( + reject, + { + request: { + query: queryWithVars, + variables: variables1, + }, + result: { data }, + }, + { + request: { + query: queryWithVars, + variables: variables2, + }, + result: { data: data2 }, + }, + { + request: { + query: queryWithVars, + variables: variables1, + }, + result: { data }, + }, + { + request: { + query: queryWithVars, + variables: variables2, + }, + result: { data: data2 }, + }, + ); + + const observable = queryManager.watchQuery({ + query: queryWithVars, + variables: variables1, + fetchPolicy: 'cache-and-network', + nextFetchPolicy: 'cache-first', + notifyOnNetworkStatusChange: true, + }); + + expect(observable.options.fetchPolicy).toBe('cache-and-network'); + expect(observable.initialFetchPolicy).toBe('cache-and-network'); + + subscribeAndCount(reject, observable, (handleCount, result) => { + expect(result.error).toBeUndefined(); + + if (handleCount === 1) { + expect(result.data).toEqual(data); + expect(result.loading).toBe(false); + expect(observable.options.fetchPolicy).toBe('cache-first'); + observable.refetch(variables2); + } else if (handleCount === 2) { + expect(result.loading).toBe(true); + expect(result.networkStatus).toBe(NetworkStatus.setVariables); + expect(observable.options.fetchPolicy).toBe('cache-first'); + } else if (handleCount === 3) { + expect(result.data).toEqual(data2); + expect(result.loading).toBe(false); + expect(observable.options.fetchPolicy).toBe('cache-first'); + observable.setOptions({ + variables: variables1, + }).then(result => { + expect(result.data).toEqual(data); + }).catch(reject); + expect(observable.options.fetchPolicy).toBe('cache-and-network'); + } else if (handleCount === 4) { + expect(result.loading).toBe(true); + expect(result.networkStatus).toBe(NetworkStatus.setVariables); + expect(observable.options.fetchPolicy).toBe('cache-first'); + } else if (handleCount === 5) { + expect(result.data).toEqual(data); + expect(result.loading).toBe(false); + expect(observable.options.fetchPolicy).toBe('cache-first'); + observable.reobserve({ + variables: variables2, + }).then(result => { + expect(result.data).toEqual(data2); + }).catch(reject); + expect(observable.options.fetchPolicy).toBe('cache-and-network'); + } else if (handleCount === 6) { + expect(result.data).toEqual(data2); + expect(result.loading).toBe(true); + expect(observable.options.fetchPolicy).toBe('cache-first'); + } else if (handleCount === 7) { + expect(result.data).toEqual(data2); + expect(result.loading).toBe(false); + expect(observable.options.fetchPolicy).toBe('cache-first'); + setTimeout(resolve, 10); + } else { + reject(`too many renders (${handleCount})`); + } + }); + }); + itAsync('cache-and-network refetch should run @client(always: true) resolvers when network request fails', (resolve, reject) => { const query = gql` query MixedQuery { From 89126b625b17b5ad38c647e91a901f92d4cc4f05 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Fri, 9 Jul 2021 11:44:15 -0400 Subject: [PATCH 319/380] Keep ObservableQuery initialFetchPolicy member private for now. https://github.com/apollographql/apollo-client/pull/8465#discussion_r666566193 --- src/core/ObservableQuery.ts | 2 +- src/core/__tests__/ObservableQuery.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/ObservableQuery.ts b/src/core/ObservableQuery.ts index d32122ee0b5..0ca64231bb8 100644 --- a/src/core/ObservableQuery.ts +++ b/src/core/ObservableQuery.ts @@ -60,7 +60,7 @@ export class ObservableQuery< // Original value of this.options.fetchPolicy (defaulting to "cache-first"), // from whenever the ObservableQuery was first created. - public initialFetchPolicy: WatchQueryFetchPolicy; + private initialFetchPolicy: WatchQueryFetchPolicy; private isTornDown: boolean; private queryManager: QueryManager; diff --git a/src/core/__tests__/ObservableQuery.ts b/src/core/__tests__/ObservableQuery.ts index d1b585fe71e..33a2ad5bbbb 100644 --- a/src/core/__tests__/ObservableQuery.ts +++ b/src/core/__tests__/ObservableQuery.ts @@ -1197,7 +1197,7 @@ describe('ObservableQuery', () => { }); expect(observable.options.fetchPolicy).toBe('cache-and-network'); - expect(observable.initialFetchPolicy).toBe('cache-and-network'); + expect(observable["initialFetchPolicy"]).toBe('cache-and-network'); subscribeAndCount(reject, observable, (handleCount, result) => { expect(result.error).toBeUndefined(); From 8a98eb95b62efe18c8708e5a3eb2bd508d4544cb Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Fri, 9 Jul 2021 11:57:29 -0400 Subject: [PATCH 320/380] Mention PR #8465 in CHANGELOG.md, as a "potentially disruptive" change. --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ec48754a2b..ac9171a2e77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,9 @@ - Log non-fatal `invariant.error` message when fields are missing from result objects written into `InMemoryCache`, rather than throwing an exception. While this change relaxes an exception to be merely an error message, which is usually a backwards-compatible change, the error messages are logged in more cases now than the exception was previously thrown, and those new error messages may be worth investigating to discover potential problems in your application. The errors are not displayed for `@client`-only fields, so adding `@client` is one way to handle/hide the errors for local-only fields. Another general strategy is to use a more precise query to write specific subsets of data into the cache, rather than reusing a larger query that contains fields not present in the written `data`.
[@benjamn](https://github.com/benjamn) in [#8416](https://github.com/apollographql/apollo-client/pull/8416) +- The [`nextFetchPolicy`](https://github.com/apollographql/apollo-client/pull/6893) option for `client.watchQuery` and `useQuery` will no longer be removed from the `options` object after it has been applied, and instead will continue to be applied any time `options.fetchPolicy` is reset to another value, until/unless the `options.nextFetchPolicy` property is removed from `options`.
+ [@benjamn](https://github.com/benjamn) in [#8465](https://github.com/apollographql/apollo-client/pull/8465) + ### Improvements - `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.
From 3cfe83c4366c672fa4bae6e1cfde513de7017527 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Fri, 9 Jul 2021 12:27:58 -0400 Subject: [PATCH 321/380] Bump @apollo/client npm version to 3.4.0-rc.18. --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index f03000c01fa..4e01e0cc72e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-rc.17", + "version": "3.4.0-rc.18", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 49dfcee46fa..2ce94e66e24 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-rc.17", + "version": "3.4.0-rc.18", "description": "A fully-featured caching GraphQL client.", "private": true, "keywords": [ From e9cf7c13af0e21b8c77d58bccf31c760a090b126 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Mon, 12 Jul 2021 15:13:42 -0400 Subject: [PATCH 322/380] Suppress `Missing cache result fields...` warnings unless `setLogVerbosity("debug")` configured (#8489) --- CHANGELOG.md | 3 +++ config/processInvariants.ts | 2 +- package-lock.json | 13 +++---------- package.json | 2 +- src/core/QueryManager.ts | 2 +- src/core/__tests__/QueryManager/index.ts | 11 +++-------- 6 files changed, 12 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ac9171a2e77..a703649e049 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -109,6 +109,9 @@ - The `InMemoryCache` version of the `cache.gc` method now supports additional options for removing non-essential (recomputable) result caching data.
[@benjamn](https://github.com/benjamn) in [#8421](https://github.com/apollographql/apollo-client/pull/8421) +- Suppress noisy `Missing cache result fields...` warnings by default unless `setLogVerbosity("debug")` called.
+ [@benjamn](https://github.com/benjamn) in [#8489](https://github.com/apollographql/apollo-client/pull/8489) + ### Documentation TBD diff --git a/config/processInvariants.ts b/config/processInvariants.ts index 0a26a01e9ee..6227acd5840 100644 --- a/config/processInvariants.ts +++ b/config/processInvariants.ts @@ -90,7 +90,7 @@ function transform(code: string, file: string) { if (node.callee.type === "MemberExpression" && isIdWithName(node.callee.object, "invariant") && - isIdWithName(node.callee.property, "log", "warn", "error")) { + isIdWithName(node.callee.property, "debug", "log", "warn", "error")) { if (isDEVLogicalAnd(path.parent.node)) { return; } diff --git a/package-lock.json b/package-lock.json index 4e01e0cc72e..304f39655a7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9075,18 +9075,11 @@ } }, "ts-invariant": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/ts-invariant/-/ts-invariant-0.8.2.tgz", - "integrity": "sha512-VI1ZSMW8soizP5dU8DsMbj/TncHf7bIUqavuE7FTeYeQat454HHurJ8wbfCnVWcDOMkyiBUWOW2ytew3xUxlRw==", + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/ts-invariant/-/ts-invariant-0.9.0.tgz", + "integrity": "sha512-+JqhKqywk+ue5JjAC6eTWe57mOIxYXypMUkBDStkAzvnlfkDJ1KGyeMuNRMwOt6GXzHSC1UT9JecowpZDmgXqA==", "requires": { "tslib": "^2.1.0" - }, - "dependencies": { - "tslib": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", - "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==" - } } }, "ts-jest": { diff --git a/package.json b/package.json index 2ce94e66e24..3a2498cb88b 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,7 @@ "optimism": "^0.16.1", "prop-types": "^15.7.2", "symbol-observable": "^4.0.0", - "ts-invariant": "^0.8.2", + "ts-invariant": "^0.9.0", "tslib": "^2.1.0", "zen-observable-ts": "^1.1.0" }, diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index 22221e5055f..a30d1028555 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -1349,7 +1349,7 @@ export class QueryManager { isNonEmptyArray(diff.missing) && !equal(data, {}) && !returnPartialData) { - invariant.warn(`Missing cache result fields: ${ + invariant.debug(`Missing cache result fields: ${ diff.missing.map(m => m.path.join('.')).join(', ') }`, diff.missing); } diff --git a/src/core/__tests__/QueryManager/index.ts b/src/core/__tests__/QueryManager/index.ts index 8c30b7ced94..035e0b6acc7 100644 --- a/src/core/__tests__/QueryManager/index.ts +++ b/src/core/__tests__/QueryManager/index.ts @@ -1350,7 +1350,6 @@ describe('QueryManager', () => { }); itAsync('supports cache-only fetchPolicy fetching only cached data', (resolve, reject) => { - const spy = jest.spyOn(console, "warn").mockImplementation(); const primeQuery = gql` query primeQuery { luke: people_one(id: 1) { @@ -1395,13 +1394,9 @@ describe('QueryManager', () => { return handle.result().then(result => { expect(result.data['luke'].name).toBe('Luke Skywalker'); expect(result.data).not.toHaveProperty('vader'); - expect(spy).toHaveBeenCalledTimes(1); }); }) - .finally(() => { - spy.mockRestore(); - }) - .then(resolve, reject) + .then(resolve, reject); }); itAsync('runs a mutation', (resolve, reject) => { @@ -5785,8 +5780,8 @@ describe('QueryManager', () => { let verbosity: ReturnType; let spy: any; beforeEach(() => { - verbosity = setVerbosity("warn"); - spy = jest.spyOn(console, "warn").mockImplementation(); + verbosity = setVerbosity("debug"); + spy = jest.spyOn(console, "debug").mockImplementation(); }); afterEach(() => { From 5a33ab9ea91993f6e9a1a92a2468ddd323e71a3d Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Mon, 12 Jul 2021 15:20:43 -0400 Subject: [PATCH 323/380] Bump @apollo/client npm version to 3.4.0-rc.19. --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8694b0d16c5..c911c2dc968 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-rc.18", + "version": "3.4.0-rc.19", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 698c637dd5f..3b988cd7db5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-rc.18", + "version": "3.4.0-rc.19", "description": "A fully-featured caching GraphQL client.", "private": true, "keywords": [ From 8d41184acf021bdfc66070a956e77c7fe1ea1ac3 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 13 Jul 2021 10:01:50 -0400 Subject: [PATCH 324/380] Bump tslib to latest version, 2.3.0. --- package-lock.json | 6 +++--- package.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index c911c2dc968..1ec7d21abeb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9147,9 +9147,9 @@ } }, "tslib": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.1.0.tgz", - "integrity": "sha512-hcVC3wYEziELGGmEEXue7D75zbwIIVUMWAVbHItGPx0ziyXxrOMQx4rQEVEV45Ut/1IotuEvwqPopzIOkDMf0A==" + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==" }, "tunnel-agent": { "version": "0.6.0", diff --git a/package.json b/package.json index 3b988cd7db5..28cf0c96ef2 100644 --- a/package.json +++ b/package.json @@ -82,7 +82,7 @@ "prop-types": "^15.7.2", "symbol-observable": "^4.0.0", "ts-invariant": "^0.9.0", - "tslib": "^2.1.0", + "tslib": "^2.3.0", "zen-observable-ts": "^1.1.0" }, "devDependencies": { From d83d207ce120c3b72f39822b156853c4556e0079 Mon Sep 17 00:00:00 2001 From: Andrei Alecu Date: Thu, 15 Jul 2021 22:06:53 +0300 Subject: [PATCH 325/380] Fix Fast Refresh/Live Reload interaction with React hooks (#7952) Closes #5870 --- CHANGELOG.md | 3 ++ src/react/hooks/useSubscription.ts | 20 +++++++++++--- src/react/hooks/utils/useAfterFastRefresh.ts | 29 ++++++++++++++++++++ src/react/hooks/utils/useBaseQuery.ts | 17 ++++++++++-- 4 files changed, 62 insertions(+), 7 deletions(-) create mode 100644 src/react/hooks/utils/useAfterFastRefresh.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a703649e049..5080702b183 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -112,6 +112,9 @@ - Suppress noisy `Missing cache result fields...` warnings by default unless `setLogVerbosity("debug")` called.
[@benjamn](https://github.com/benjamn) in [#8489](https://github.com/apollographql/apollo-client/pull/8489) +- Improve interaction between React hooks and React Fast Refresh in development.
+ [@andreialecu](https://github.com/andreialecu) in [#7952](https://github.com/apollographql/apollo-client/pull/7952) + ### Documentation TBD diff --git a/src/react/hooks/useSubscription.ts b/src/react/hooks/useSubscription.ts index d553b9fbef3..d0febf7d904 100644 --- a/src/react/hooks/useSubscription.ts +++ b/src/react/hooks/useSubscription.ts @@ -1,4 +1,4 @@ -import { useContext, useState, useRef, useEffect } from 'react'; +import { useContext, useState, useRef, useEffect, useReducer } from 'react'; import { DocumentNode } from 'graphql'; import { TypedDocumentNode } from '@graphql-typed-document-node/core'; @@ -6,19 +6,21 @@ import { SubscriptionHookOptions } from '../types/types'; import { SubscriptionData } from '../data'; import { OperationVariables } from '../../core'; import { getApolloContext } from '../context'; +import { useAfterFastRefresh } from './utils/useAfterFastRefresh'; export function useSubscription( subscription: DocumentNode | TypedDocumentNode, options?: SubscriptionHookOptions ) { + const [, forceUpdate] = useReducer(x => x + 1, 0); const context = useContext(getApolloContext()); const updatedOptions = options ? { ...options, subscription } : { subscription }; const [result, setResult] = useState({ loading: !updatedOptions.skip, - error: undefined, - data: undefined + error: void 0, + data: void 0, }); const subscriptionDataRef = useRef>(); @@ -37,8 +39,18 @@ export function useSubscription( subscriptionData.setOptions(updatedOptions, true); subscriptionData.context = context; + if (__DEV__) { + // ensure we run an update after refreshing so that we can resubscribe + useAfterFastRefresh(forceUpdate); + } + useEffect(() => subscriptionData.afterExecute()); - useEffect(() => subscriptionData.cleanup.bind(subscriptionData), []); + useEffect(() => { + return () => { + subscriptionData.cleanup(); + subscriptionDataRef.current = void 0; + }; + }, []); return subscriptionData.execute(result); } diff --git a/src/react/hooks/utils/useAfterFastRefresh.ts b/src/react/hooks/utils/useAfterFastRefresh.ts new file mode 100644 index 00000000000..de8742f398e --- /dev/null +++ b/src/react/hooks/utils/useAfterFastRefresh.ts @@ -0,0 +1,29 @@ +import { useEffect, useRef } from "react"; + +/** + * This hook allows running a function only immediately after a react + * fast refresh or live reload. + * + * Useful in order to ensure that we can reinitialize things that have been + * disposed by cleanup functions in `useEffect`. + * @param effectFn a function to run immediately after a fast refresh + */ +export function useAfterFastRefresh(effectFn: () => unknown) { + if (__DEV__) { + const didRefresh = useRef(false); + useEffect(() => { + return () => { + // Detect fast refresh, only runs multiple times in fast refresh + didRefresh.current = true; + }; + }, []); + + useEffect(() => { + if (didRefresh.current === true) { + // This block only runs after a fast refresh + didRefresh.current = false; + effectFn(); + } + }, []) + } +} diff --git a/src/react/hooks/utils/useBaseQuery.ts b/src/react/hooks/utils/useBaseQuery.ts index 9af5aa7e260..2f071d3a29d 100644 --- a/src/react/hooks/utils/useBaseQuery.ts +++ b/src/react/hooks/utils/useBaseQuery.ts @@ -12,6 +12,7 @@ import { QueryData } from '../../data'; import { useDeepMemo } from './useDeepMemo'; import { OperationVariables } from '../../../core'; import { getApolloContext } from '../../context'; +import { useAfterFastRefresh } from './useAfterFastRefresh'; export function useBaseQuery( query: DocumentNode | TypedDocumentNode, @@ -54,8 +55,8 @@ export function useBaseQuery( const memo = { options: { ...updatedOptions, - onError: undefined, - onCompleted: undefined + onError: void 0, + onCompleted: void 0 } as QueryHookOptions, context, tick @@ -70,8 +71,18 @@ export function useBaseQuery( ? (result as QueryTuple)[1] : (result as QueryResult); + if (__DEV__) { + // ensure we run an update after refreshing so that we reinitialize + useAfterFastRefresh(forceUpdate); + } + useEffect(() => { - return () => queryData.cleanup(); + return () => { + queryData.cleanup(); + // this effect can run multiple times during a fast-refresh + // so make sure we clean up the ref + queryDataRef.current = void 0; + } }, []); useEffect(() => queryData.afterExecute({ lazy }), [ From 0c0da1644e3d76d68da3aa5964008537fd5d1c37 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Thu, 15 Jul 2021 15:32:25 -0400 Subject: [PATCH 326/380] Bump @apollo/client npm version to 3.4.0-rc.20. --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1ec7d21abeb..18b8c115b45 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-rc.19", + "version": "3.4.0-rc.20", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 28cf0c96ef2..9dd0067a4d4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-rc.19", + "version": "3.4.0-rc.20", "description": "A fully-featured caching GraphQL client.", "private": true, "keywords": [ From 4a2e38cc709c7c22bfd93fdb5fe666bafc3c8751 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Fri, 16 Jul 2021 12:24:36 -0400 Subject: [PATCH 327/380] Avoid treating { __ref } objects returned by applyMerges as StoreObjects. --- src/cache/inmemory/__tests__/writeToStore.ts | 160 +++++++++++++++++++ src/cache/inmemory/policies.ts | 7 + src/cache/inmemory/writeToStore.ts | 13 +- 3 files changed, 178 insertions(+), 2 deletions(-) diff --git a/src/cache/inmemory/__tests__/writeToStore.ts b/src/cache/inmemory/__tests__/writeToStore.ts index 2abed99d149..ab3f31036e6 100644 --- a/src/cache/inmemory/__tests__/writeToStore.ts +++ b/src/cache/inmemory/__tests__/writeToStore.ts @@ -13,6 +13,8 @@ import { storeKeyNameFromField, makeReference, isReference, + Reference, + StoreObject, } from '../../../utilities/graphql/storeUtils'; import { addTypenameToDocument } from '../../../utilities/graphql/transform'; import { cloneDeep } from '../../../utilities/common/cloneDeep'; @@ -2267,6 +2269,164 @@ describe('writing to the store', () => { }); }); + it("should not merge { __ref } as StoreObject when mergeObjects used", () => { + const merges: Array<{ + existing: Reference | undefined; + incoming: Reference | StoreObject; + merged: Reference; + }> = []; + + const cache = new InMemoryCache({ + typePolicies: { + Account: { + merge(existing, incoming, { mergeObjects }) { + const merged = mergeObjects(existing, incoming); + merges.push({ existing, incoming, merged }); + debugger; + return merged; + }, + }, + }, + }); + + const contactLocationQuery = gql` + query { + account { + contact + location + } + } + `; + + const contactOnlyQuery = gql` + query { + account { + contact + } + } + `; + + const locationOnlyQuery = gql` + query { + account { + location + } + } + `; + + cache.writeQuery({ + query: contactLocationQuery, + data: { + account: { + __typename: "Account", + contact: "billing@example.com", + location: "Exampleville, Ohio", + }, + }, + }); + + expect(cache.extract()).toEqual({ + ROOT_QUERY: { + __typename: "Query", + account: { + __typename: "Account", + contact: "billing@example.com", + location: "Exampleville, Ohio", + }, + }, + }); + + cache.writeQuery({ + query: contactOnlyQuery, + data: { + account: { + __typename: "Account", + id: 12345, + contact: "support@example.com", + }, + }, + }); + + expect(cache.extract()).toEqual({ + "Account:12345": { + __typename: "Account", + id: 12345, + contact: "support@example.com", + location: "Exampleville, Ohio", + }, + ROOT_QUERY: { + __typename: "Query", + account: { + __ref: "Account:12345", + }, + }, + }); + + cache.writeQuery({ + query: locationOnlyQuery, + data: { + account: { + __typename: "Account", + location: "Nowhere, New Mexico", + }, + }, + }); + + expect(cache.extract()).toEqual({ + "Account:12345": { + __typename: "Account", + id: 12345, + contact: "support@example.com", + location: "Nowhere, New Mexico", + }, + ROOT_QUERY: { + __typename: "Query", + account: { + __ref: "Account:12345", + }, + }, + }); + + expect(merges).toEqual([ + { + existing: void 0, + incoming: { + __typename: "Account", + contact: "billing@example.com", + location: "Exampleville, Ohio", + }, + merged: { + __typename: "Account", + contact: "billing@example.com", + location: "Exampleville, Ohio", + }, + }, + + { + existing: { + __typename: "Account", + contact: "billing@example.com", + location: "Exampleville, Ohio", + }, + incoming: { + __ref: "Account:12345", + }, + merged: { + __ref: "Account:12345", + }, + }, + + { + existing: { __ref: "Account:12345" }, + incoming: { + __typename: "Account", + location: "Nowhere, New Mexico", + }, + merged: { __ref: "Account:12345" }, + } + ]); + }); + it('should not deep-freeze scalar objects', () => { const query = gql` query { diff --git a/src/cache/inmemory/policies.ts b/src/cache/inmemory/policies.ts index f32c906ef2a..1242ecd298b 100644 --- a/src/cache/inmemory/policies.ts +++ b/src/cache/inmemory/policies.ts @@ -927,12 +927,19 @@ function makeMergeObjectsFunction( if (isReference(existing) && storeValueIsStoreObject(incoming)) { + // Update the normalized EntityStore for the entity identified by + // existing.__ref, preferring/overwriting any fields contributed by the + // newer incoming StoreObject. store.merge(existing.__ref, incoming); return existing; } if (storeValueIsStoreObject(existing) && isReference(incoming)) { + // Update the normalized EntityStore for the entity identified by + // incoming.__ref, taking fields from the older existing object only if + // those fields are not already present in the newer StoreObject + // identified by incoming.__ref. store.merge(existing, incoming.__ref); return incoming; } diff --git a/src/cache/inmemory/writeToStore.ts b/src/cache/inmemory/writeToStore.ts index 47f32692e0d..087334edd1e 100644 --- a/src/cache/inmemory/writeToStore.ts +++ b/src/cache/inmemory/writeToStore.ts @@ -108,7 +108,16 @@ export class StoreWriter { const entityRef = makeReference(dataId); if (mergeTree.map.size) { - fields = this.applyMerges(mergeTree, entityRef, fields, context); + const applied = this.applyMerges(mergeTree, entityRef, fields, context); + if (isReference(applied)) { + // Assume References returned by applyMerges have already been merged + // into the store. See makeMergeObjectsFunction in policies.ts for an + // example of how this can happen. + return; + } + // Otherwise, applyMerges returned a StoreObject, whose fields we should + // merge into the store (see store.merge statement below). + fields = applied; } if (__DEV__ && !context.overwrite) { @@ -414,7 +423,7 @@ export class StoreWriter { incoming: T, context: WriteContext, getStorageArgs?: Parameters, - ): T { + ): T | Reference { if (mergeTree.map.size && !isReference(incoming)) { const e: StoreObject | Reference | undefined = ( // Items in the same position in different arrays are not From 97a1d74c629d83da66f439bb1ba171b2c909a1b5 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Fri, 16 Jul 2021 12:48:30 -0400 Subject: [PATCH 328/380] Traverse any other fields of { __ref } objects in findChildRefIds. --- src/cache/inmemory/__tests__/entityStore.ts | 70 +++++++++++++++++++++ src/cache/inmemory/entityStore.ts | 24 +++++-- 2 files changed, 89 insertions(+), 5 deletions(-) diff --git a/src/cache/inmemory/__tests__/entityStore.ts b/src/cache/inmemory/__tests__/entityStore.ts index 7a57ab251c4..2f41c16440d 100644 --- a/src/cache/inmemory/__tests__/entityStore.ts +++ b/src/cache/inmemory/__tests__/entityStore.ts @@ -964,6 +964,76 @@ describe('EntityStore', () => { expect(cache2.extract()).toEqual({}); }); + it("cache.gc is not confused by StoreObjects with stray __ref fields", () => { + const cache = new InMemoryCache({ + typePolicies: { + Person: { + keyFields: ["name"], + }, + }, + }); + + const query = gql` + query { + parent { + name + child { + name + } + } + } + `; + + const data = { + parent: { + __typename: "Person", + name: "Will Smith", + child: { + __typename: "Person", + name: "Jaden Smith", + }, + }, + }; + + cache.writeQuery({ query, data }); + + expect(cache.gc()).toEqual([]); + + const willId = cache.identify(data.parent)!; + const store = cache["data"]; + const storeRootData = store["data"]; + // Hacky way of injecting a stray __ref field into the Will Smith Person + // object, clearing store.refs (which was populated by the previous GC). + storeRootData[willId]!.__ref = willId; + store["refs"] = Object.create(null); + + expect(cache.extract()).toEqual({ + 'Person:{"name":"Jaden Smith"}': { + __typename: "Person", + name: "Jaden Smith", + }, + 'Person:{"name":"Will Smith"}': { + __typename: "Person", + name: "Will Smith", + child: { + __ref: 'Person:{"name":"Jaden Smith"}', + }, + // This is the bogus line that makes this Person object look like a + // Reference object to the garbage collector. + __ref: 'Person:{"name":"Will Smith"}', + }, + ROOT_QUERY: { + __typename: "Query", + parent: { + __ref: 'Person:{"name":"Will Smith"}', + }, + }, + }); + + // Ensure the garbage collector is not confused by the stray __ref. + expect(cache.gc()).toEqual([]); + }); + it("allows evicting specific fields", () => { const query: DocumentNode = gql` query { diff --git a/src/cache/inmemory/entityStore.ts b/src/cache/inmemory/entityStore.ts index 7d9eec0292d..1d7435eb34c 100644 --- a/src/cache/inmemory/entityStore.ts +++ b/src/cache/inmemory/entityStore.ts @@ -417,18 +417,32 @@ export abstract class EntityStore implements NormalizedCache { public findChildRefIds(dataId: string): Record { if (!hasOwn.call(this.refs, dataId)) { const found = this.refs[dataId] = Object.create(null); - const workSet = new Set([this.data[dataId]]); + const root = this.data[dataId]; + if (!root) return found; + + const workSet = new Set>([root]); // Within the store, only arrays and objects can contain child entity // references, so we can prune the traversal using this predicate: workSet.forEach(obj => { if (isReference(obj)) { found[obj.__ref] = true; - } else if (isNonNullObject(obj)) { - Object.values(obj!) + // In rare cases, a { __ref } Reference object may have other fields. + // This often indicates a mismerging of References with StoreObjects, + // but garbage collection should not be fooled by a stray __ref + // property in a StoreObject (ignoring all the other fields just + // because the StoreObject looks like a Reference). To avoid this + // premature termination of findChildRefIds recursion, we fall through + // to the code below, which will handle any other properties of obj. + } + if (isNonNullObject(obj)) { + Object.keys(obj).forEach(key => { + const child = obj[key]; // No need to add primitive values to the workSet, since they cannot // contain reference objects. - .filter(isNonNullObject) - .forEach(workSet.add, workSet); + if (isNonNullObject(child)) { + workSet.add(child); + } + }); } }); } From 4832e87c49b57ea911a53f8c974d0163c2a887fc Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Fri, 16 Jul 2021 12:48:54 -0400 Subject: [PATCH 329/380] Tolerate surprise Reference arguments in EntityStore#merge. --- src/cache/inmemory/__tests__/entityStore.ts | 75 ++++++++++++++++++++- src/cache/inmemory/entityStore.ts | 4 ++ 2 files changed, 78 insertions(+), 1 deletion(-) diff --git a/src/cache/inmemory/__tests__/entityStore.ts b/src/cache/inmemory/__tests__/entityStore.ts index 2f41c16440d..a02febc9dfe 100644 --- a/src/cache/inmemory/__tests__/entityStore.ts +++ b/src/cache/inmemory/__tests__/entityStore.ts @@ -5,7 +5,7 @@ import { DocumentNode } from 'graphql'; import { StoreObject } from '../types'; import { ApolloCache } from '../../core/cache'; import { Cache } from '../../core/types/Cache'; -import { Reference, makeReference, isReference } from '../../../utilities/graphql/storeUtils'; +import { Reference, makeReference, isReference, StoreValue } from '../../../utilities/graphql/storeUtils'; import { MissingFieldError } from '../..'; import { TypedDocumentNode } from '@graphql-typed-document-node/core'; @@ -2593,4 +2593,77 @@ describe('EntityStore', () => { "1449373321", ]); }); + + it("Refuses to merge { __ref } objects as StoreObjects", () => { + const cache = new InMemoryCache({ + typePolicies: { + Query: { + fields: { + book: { + keyArgs: ["isbn"], + }, + }, + }, + + Book: { + keyFields: ["isbn"], + }, + }, + }); + + const store = cache["data"]; + + const query = gql` + query Book($isbn: string) { + book(isbn: $isbn) { + title + } + } + `; + + const data = { + book: { + __typename: "Book", + isbn: "1449373321", + title: "Designing Data-Intensive Applications", + }, + }; + + cache.writeQuery({ + query, + data, + variables: { + isbn: data.book.isbn, + }, + }); + + const bookId = cache.identify(data.book)!; + + store.merge( + bookId, + makeReference(bookId) as StoreValue as StoreObject, + ); + + const snapshot = cache.extract(); + expect(snapshot).toEqual({ + ROOT_QUERY: { + __typename: "Query", + 'book:{"isbn":"1449373321"}': { + __ref: 'Book:{"isbn":"1449373321"}', + }, + }, + 'Book:{"isbn":"1449373321"}': { + __typename: "Book", + isbn: "1449373321", + title: "Designing Data-Intensive Applications", + }, + }); + + store.merge( + makeReference(bookId) as StoreValue as StoreObject, + bookId, + ); + + expect(cache.extract()).toEqual(snapshot); + }); }); diff --git a/src/cache/inmemory/entityStore.ts b/src/cache/inmemory/entityStore.ts index 1d7435eb34c..ff7c6f62bec 100644 --- a/src/cache/inmemory/entityStore.ts +++ b/src/cache/inmemory/entityStore.ts @@ -102,6 +102,10 @@ export abstract class EntityStore implements NormalizedCache { ): void { let dataId: string | undefined; + // Convert unexpected references to ID strings. + if (isReference(older)) older = older.__ref; + if (isReference(newer)) newer = newer.__ref; + const existing: StoreObject | undefined = typeof older === "string" ? this.lookup(dataId = older) From e359e973cb938a719ec3468135ac994b355ea774 Mon Sep 17 00:00:00 2001 From: Hugh Willson Date: Fri, 16 Jul 2021 16:35:22 -0400 Subject: [PATCH 330/380] Note that mutate/useMutation with @client is not supported in AC3 (#8504) This has been a pretty large source of confusion when people move from local resolvers to field policies. We'll probably want to expand on this much further, and show people how to switch from using mutate/useMutation with @client to equivalent writeQuery/writeFragment/reactive variable functionality, but this quick note will hopefully serve as a start to help clarify things. --- docs/source/local-state/managing-state-with-field-policies.mdx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/source/local-state/managing-state-with-field-policies.mdx b/docs/source/local-state/managing-state-with-field-policies.mdx index 68428a8b3b2..0afb3aeb74d 100644 --- a/docs/source/local-state/managing-state-with-field-policies.mdx +++ b/docs/source/local-state/managing-state-with-field-policies.mdx @@ -97,6 +97,8 @@ You can use Apollo Client to query local state, regardless of how you _store_ th * [Reactive variables](#storing-local-state-in-reactive-variables) * [The Apollo Client cache itself](#storing-local-state-in-the-cache) +> **Note:** Apollo Client >= 3 no longer recommends the [local resolver](/local-state/local-resolvers) approach of using `client.mutate` / `useMutation` combined with an `@client` mutation operation, to [update local state](/local-state/local-resolvers/#local-resolvers). If you want to update local state, we recommend using [`writeQuery`](/caching/cache-interaction/#writequery), [`writeFragment`](/caching/cache-interaction/#writefragment), or [reactive variables](/local-state/reactive-variables/). + ### Storing local state in reactive variables Apollo Client [reactive variables](./reactive-variables) are great for representing local state: From 372a6904066dac765a6410259d9ad8db639fcb01 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Mon, 19 Jul 2021 09:56:58 -0400 Subject: [PATCH 331/380] Bump @apollo/client npm version to 3.4.0-rc.21. --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 36f8ae51664..11076985f43 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-rc.20", + "version": "3.4.0-rc.21", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 784c03eee08..d577b3cfa1b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-rc.20", + "version": "3.4.0-rc.21", "description": "A fully-featured caching GraphQL client.", "private": true, "keywords": [ From 92520dd0e75733caa9ab0998e19142e738afa873 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Wed, 21 Jul 2021 13:31:27 -0400 Subject: [PATCH 332/380] Default to current object for `readField` only when `from` omitted. (#8508) Should help with #8499. --- CHANGELOG.md | 3 + src/__tests__/__snapshots__/exports.ts.snap | 3 + .../__tests__/__snapshots__/policies.ts.snap | 23 +++++++ src/cache/inmemory/__tests__/policies.ts | 66 ++++++++++++++++++- src/cache/inmemory/policies.ts | 35 ++++++++-- src/utilities/testing/index.ts | 3 +- src/utilities/testing/mocking/mockLink.ts | 12 ++-- src/utilities/testing/stringifyForDisplay.ts | 8 +++ src/utilities/testing/withConsoleSpy.ts | 49 ++++++++++++++ src/utilities/testing/withErrorSpy.ts | 21 ------ 10 files changed, 186 insertions(+), 37 deletions(-) create mode 100644 src/utilities/testing/stringifyForDisplay.ts create mode 100644 src/utilities/testing/withConsoleSpy.ts delete mode 100644 src/utilities/testing/withErrorSpy.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5080702b183..5a67c6d4616 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,9 @@ - The [`nextFetchPolicy`](https://github.com/apollographql/apollo-client/pull/6893) option for `client.watchQuery` and `useQuery` will no longer be removed from the `options` object after it has been applied, and instead will continue to be applied any time `options.fetchPolicy` is reset to another value, until/unless the `options.nextFetchPolicy` property is removed from `options`.
[@benjamn](https://github.com/benjamn) in [#8465](https://github.com/apollographql/apollo-client/pull/8465) +- Make `readField` default to reading from current object only when the `from` option/argument is actually omitted, not when `from` is passed to `readField` with an undefined value. A warning will be printed when this situation occurs.
+ [@benjamn](https://github.com/benjamn) in [#8508](https://github.com/apollographql/apollo-client/pull/8508) + ### Improvements - `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.
diff --git a/src/__tests__/__snapshots__/exports.ts.snap b/src/__tests__/__snapshots__/exports.ts.snap index f43c18e7b25..90edc0545ba 100644 --- a/src/__tests__/__snapshots__/exports.ts.snap +++ b/src/__tests__/__snapshots__/exports.ts.snap @@ -312,9 +312,12 @@ Array [ "itAsync", "mockObservableLink", "mockSingleLink", + "stringifyForDisplay", "stripSymbols", "subscribeAndCount", "withErrorSpy", + "withLogSpy", + "withWarningSpy", ] `; diff --git a/src/cache/inmemory/__tests__/__snapshots__/policies.ts.snap b/src/cache/inmemory/__tests__/__snapshots__/policies.ts.snap index d414466e566..7db3045a6a0 100644 --- a/src/cache/inmemory/__tests__/__snapshots__/policies.ts.snap +++ b/src/cache/inmemory/__tests__/__snapshots__/policies.ts.snap @@ -1389,6 +1389,29 @@ exports[`type policies field policies runs nested merge functions as well as anc } `; +exports[`type policies readField warns if explicitly passed undefined \`from\` option 1`] = ` +[MockFunction] { + "calls": Array [ + Array [ + "Undefined 'from' passed to readField with arguments [{\\"fieldName\\":\\"firstName\\",\\"from\\":}]", + ], + Array [ + "Undefined 'from' passed to readField with arguments [\\"lastName\\",]", + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + Object { + "type": "return", + "value": undefined, + }, + ], +} +`; + exports[`type policies support inheritance 1`] = ` Object { "Cobra:{\\"tagId\\":\\"Egypt30BC\\"}": Object { diff --git a/src/cache/inmemory/__tests__/policies.ts b/src/cache/inmemory/__tests__/policies.ts index 3fe2c070e51..6ba256d2d7e 100644 --- a/src/cache/inmemory/__tests__/policies.ts +++ b/src/cache/inmemory/__tests__/policies.ts @@ -9,7 +9,7 @@ import { MockLink } from '../../../utilities/testing/mocking/mockLink'; import subscribeAndCount from '../../../utilities/testing/subscribeAndCount'; import { itAsync } from '../../../utilities/testing/itAsync'; import { FieldPolicy, StorageType } from "../policies"; -import { withErrorSpy } from "../../../testing"; +import { withErrorSpy, withWarningSpy } from "../../../testing"; function reverse(s: string) { return s.split("").reverse().join(""); @@ -5051,6 +5051,70 @@ describe("type policies", function () { }); }); + withWarningSpy(it, "readField warns if explicitly passed undefined `from` option", function () { + const cache = new InMemoryCache({ + typePolicies: { + Query: { + fields: { + fullNameWithDefaults(_, { readField }) { + return `${ + readField({ + fieldName: "firstName", + }) + } ${ + readField("lastName") + }`; + }, + + fullNameWithVoids(_, { readField }) { + return `${ + readField({ + fieldName: "firstName", + // If options.from is explicitly passed but undefined, + // readField should not default to reading from the current + // object (see issue #8499). + from: void 0, + }) + } ${ + // Likewise for the shorthand version of readField. + readField("lastName", void 0) + }`; + }, + }, + }, + }, + }); + + const firstNameLastNameQuery = gql` + query { + firstName + lastName + } + `; + + const fullNamesQuery = gql` + query { + fullNameWithVoids + fullNameWithDefaults + } + `; + + cache.writeQuery({ + query: firstNameLastNameQuery, + data: { + firstName: "Alan", + lastName: "Turing", + }, + }); + + expect(cache.readQuery({ + query: fullNamesQuery, + })).toEqual({ + fullNameWithDefaults: "Alan Turing", + fullNameWithVoids: "undefined undefined", + }); + }); + it("can return existing object from merge function (issue #6245)", function () { const cache = new InMemoryCache({ typePolicies: { diff --git a/src/cache/inmemory/policies.ts b/src/cache/inmemory/policies.ts index 1242ecd298b..67c2549787a 100644 --- a/src/cache/inmemory/policies.ts +++ b/src/cache/inmemory/policies.ts @@ -52,6 +52,7 @@ import { WriteContext } from './writeToStore'; // used by getStoreKeyName. This function is used when computing storeFieldName // strings (when no keyArgs has been configured for a field). import { canonicalStringify } from './object-canon'; +import { stringifyForDisplay } from '../../testing'; getStoreKeyName.setStringify(canonicalStringify); export type TypePolicies = { @@ -882,14 +883,36 @@ function makeFieldFunctionOptions( fieldNameOrOptions: string | ReadFieldOptions, from?: StoreObject | Reference, ) { - const options: ReadFieldOptions = - typeof fieldNameOrOptions === "string" ? { + let options: ReadFieldOptions; + if (typeof fieldNameOrOptions === "string") { + options = { fieldName: fieldNameOrOptions, - from, - } : { ...fieldNameOrOptions }; + // Default to objectOrReference only when no second argument was + // passed for the from parameter, not when undefined is explicitly + // passed as the second argument. + from: arguments.length > 1 ? from : objectOrReference, + }; + } else if (isNonNullObject(fieldNameOrOptions)) { + options = { ...fieldNameOrOptions }; + // Default to objectOrReference only when fieldNameOrOptions.from is + // actually omitted, rather than just undefined. + if (!hasOwn.call(fieldNameOrOptions, "from")) { + options.from = objectOrReference; + } + } else { + invariant.warn(`Unexpected readField arguments: ${ + stringifyForDisplay(Array.from(arguments)) + }`); + // The readField helper function returns undefined for any missing + // fields, so it should also return undefined if the arguments were not + // of a type we expected. + return; + } - if (void 0 === options.from) { - options.from = objectOrReference; + if (__DEV__ && options.from === void 0) { + invariant.warn(`Undefined 'from' passed to readField with arguments ${ + stringifyForDisplay(Array.from(arguments)) + }`); } if (void 0 === options.variables) { diff --git a/src/utilities/testing/index.ts b/src/utilities/testing/index.ts index 668557783e9..c0e5c1daa51 100644 --- a/src/utilities/testing/index.ts +++ b/src/utilities/testing/index.ts @@ -13,4 +13,5 @@ export { createMockClient } from './mocking/mockClient'; export { stripSymbols } from './stripSymbols'; export { default as subscribeAndCount } from './subscribeAndCount'; export { itAsync } from './itAsync'; -export { withErrorSpy } from './withErrorSpy'; +export * from './withConsoleSpy'; +export * from './stringifyForDisplay'; diff --git a/src/utilities/testing/mocking/mockLink.ts b/src/utilities/testing/mocking/mockLink.ts index 7b3a24b49ba..4812177b4b7 100644 --- a/src/utilities/testing/mocking/mockLink.ts +++ b/src/utilities/testing/mocking/mockLink.ts @@ -15,17 +15,13 @@ import { removeClientSetsFromDocument, removeConnectionDirectiveFromDocument, cloneDeep, - makeUniqueId, } from '../../../utilities'; -export type ResultFunction = () => T; +import { + stringifyForDisplay, +} from '../../../testing'; -function stringifyForDisplay(value: any): string { - const undefId = makeUniqueId("stringifyForDisplay"); - return JSON.stringify(value, (key, value) => { - return value === void 0 ? undefId : value; - }).split(JSON.stringify(undefId)).join(""); -} +export type ResultFunction = () => T; export interface MockedResponse> { request: GraphQLRequest; diff --git a/src/utilities/testing/stringifyForDisplay.ts b/src/utilities/testing/stringifyForDisplay.ts new file mode 100644 index 00000000000..4eb8a724238 --- /dev/null +++ b/src/utilities/testing/stringifyForDisplay.ts @@ -0,0 +1,8 @@ +import { makeUniqueId } from "../common/makeUniqueId"; + +export function stringifyForDisplay(value: any): string { + const undefId = makeUniqueId("stringifyForDisplay"); + return JSON.stringify(value, (key, value) => { + return value === void 0 ? undefId : value; + }).split(JSON.stringify(undefId)).join(""); +} diff --git a/src/utilities/testing/withConsoleSpy.ts b/src/utilities/testing/withConsoleSpy.ts new file mode 100644 index 00000000000..171a6eac2a5 --- /dev/null +++ b/src/utilities/testing/withConsoleSpy.ts @@ -0,0 +1,49 @@ +function wrapTestFunction( + fn: (...args: any[]) => any, + consoleMethodName: "log" | "warn" | "error", +) { + return function () { + const args = arguments; + const spy = jest.spyOn(console, consoleMethodName); + spy.mockImplementation(() => {}); + return new Promise(resolve => { + resolve(fn?.apply(this, args)); + }).finally(() => { + expect(spy).toMatchSnapshot(); + spy.mockReset(); + }); + }; +} + +export function withErrorSpy< + TArgs extends any[], + TResult, +>( + it: (...args: TArgs) => TResult, + ...args: TArgs +) { + args[1] = wrapTestFunction(args[1], "error"); + return it(...args); +} + +export function withWarningSpy< + TArgs extends any[], + TResult, +>( + it: (...args: TArgs) => TResult, + ...args: TArgs +) { + args[1] = wrapTestFunction(args[1], "warn"); + return it(...args); +} + +export function withLogSpy< + TArgs extends any[], + TResult, +>( + it: (...args: TArgs) => TResult, + ...args: TArgs +) { + args[1] = wrapTestFunction(args[1], "log"); + return it(...args); +} diff --git a/src/utilities/testing/withErrorSpy.ts b/src/utilities/testing/withErrorSpy.ts deleted file mode 100644 index 2c39e8c1f91..00000000000 --- a/src/utilities/testing/withErrorSpy.ts +++ /dev/null @@ -1,21 +0,0 @@ -export function withErrorSpy< - TArgs extends any[], - TResult, ->( - it: (...args: TArgs) => TResult, - ...args: TArgs -) { - const fn = args[1]; - args[1] = function () { - const args = arguments; - const errorSpy = jest.spyOn(console, 'error'); - errorSpy.mockImplementation(() => {}); - return new Promise(resolve => { - resolve(fn?.apply(this, args)); - }).finally(() => { - expect(errorSpy).toMatchSnapshot(); - errorSpy.mockReset(); - }); - }; - return it(...args); -} From f02ea7d075e1d62efe32bc56690eab90bf687f90 Mon Sep 17 00:00:00 2001 From: Stephen Barlow Date: Wed, 21 Jul 2021 14:31:50 -0700 Subject: [PATCH 333/380] Create caching overview and pull content from configuration article (#8516) * Create a caching overview article and pull some content from configuration into it * Incorporate feedback from @benjamn and add next steps --- docs/gatsby-config.js | 1 + docs/source/caching/cache-configuration.mdx | 167 +++------------ docs/source/caching/cache-interaction.md | 24 +-- docs/source/caching/overview.mdx | 224 ++++++++++++++++++++ 4 files changed, 267 insertions(+), 149 deletions(-) create mode 100644 docs/source/caching/overview.mdx diff --git a/docs/gatsby-config.js b/docs/gatsby-config.js index 24a8301297c..830114cb651 100644 --- a/docs/gatsby-config.js +++ b/docs/gatsby-config.js @@ -37,6 +37,7 @@ module.exports = { 'data/operation-best-practices' ], Caching: [ + 'caching/overview', 'caching/cache-configuration', 'caching/cache-interaction', 'caching/garbage-collection', diff --git a/docs/source/caching/cache-configuration.mdx b/docs/source/caching/cache-configuration.mdx index b334513693b..cfd040a21a3 100644 --- a/docs/source/caching/cache-configuration.mdx +++ b/docs/source/caching/cache-configuration.mdx @@ -1,12 +1,10 @@ --- -title: Configuring the cache +title: Configuring the Apollo Client cache sidebar_title: Configuration --- import {ExpansionPanel} from 'gatsby-theme-apollo-docs'; -Apollo Client stores the results of its GraphQL queries in a normalized, in-memory cache. This enables your client to respond to future queries for the same data without sending unnecessary network requests. - This article describes cache setup and configuration. To learn how to interact with cached data, see [Reading and writing data to the cache](./cache-interaction). ## Installation @@ -41,7 +39,7 @@ Although the cache's default behavior is suitable for a wide variety of applicat To customize cache behavior, provide an `options` object to the `InMemoryCache` constructor. This object supports the following fields: - +
@@ -139,52 +137,25 @@ Deprecated in favor of the `keyFields` option of the [`TypePolicy` object](#type
Name /
Type
+## Customizing cache IDs -## Data normalization - -The `InMemoryCache` **normalizes** query response objects before it saves them to its internal data store. Normalization involves the following steps: - -1. The cache [generates a unique ID](#generating-unique-identifiers) for every identifiable object included in the response. -2. The cache stores the objects by ID in a flat lookup table. -3. Whenever an incoming object is stored with the same ID as an _existing_ object, the fields of those objects are _merged_. - * If the incoming object and the existing object share any fields, the incoming object _overwrites_ the cached values for those fields. - * Fields that appear in _only_ the existing object or _only_ the incoming object are preserved. - -Normalization constructs a partial copy of your data graph on your client, in a format that's optimized for reading and updating the graph as your application changes state. - -### Generating unique identifiers - ->In Apollo Client 3 and later, the `InMemoryCache` never creates a fallback, "fake" identifier for an object when identifier generation fails or is disabled. +You can customize how the `InMemoryCache` generates cache IDs for individual types in your schema ([see the default behavior](./overview/#2-generate-cache-ids)). This is helpful especially if a type uses a field (or fields!) _besides_ `id` or `_id` as its unique identifier. -#### Default identifier generation - -By default, the `InMemoryCache` generates a unique identifier for any object that includes a `__typename` field. To do so, it combines the object's `__typename` with its `id` or `_id` field (whichever is defined). These two values are separated by a colon (`:`). - -For example, an object with a `__typename` of `Task` and an `id` of `14` is assigned a default identifier of `Task:14`. - -#### Customizing identifier generation by type - -If one of your types defines its primary key with a field _besides_ `id` or `_id`, you can customize how the `InMemoryCache` generates unique identifiers for that type. To do so, you define `TypePolicy` for the type. You specify all of your cache's `typePolicies` in [the `options` object you provide to the `InMemoryCache` constructor](#configuration-options). +To accomplish this, you define a `TypePolicy` for each type you want to customize. You specify all of your cache's `typePolicies` in [the `options` object you provide to the `InMemoryCache` constructor](#configuration-options). Include a `keyFields` field in relevant `TypePolicy` objects, like so: ```ts const cache = new InMemoryCache({ typePolicies: { - AllProducts: { - // Singleton types that have no identifying field can use an empty - // array for their keyFields. - keyFields: [], - }, Product: { - // In most inventory management systems, a single UPC code uniquely - // identifies any product. + // In an inventory management system, products might be identified + // by their UPC. keyFields: ["upc"], }, Person: { - // In some user account systems, names or emails alone do not have to - // be unique, but the combination of a person's name and email is - // uniquely identifying. + // In a user account system, the combination of a person's name AND email + // address might uniquely identify them. keyFields: ["name", "email"], }, Book: { @@ -192,31 +163,38 @@ const cache = new InMemoryCache({ // include those nested keyFields by using a nested array of strings: keyFields: ["title", "author", ["name"]], }, + AllProducts: { + // Singleton types that have no identifying field can use an empty + // array for their keyFields. + keyFields: [], + }, }, }); ``` -This example shows three `typePolicies`: one for a `Product` type, one for a `Person` type, and one for a `Book` type. Each `TypePolicy`'s `keyFields` array defines which fields on the type _together_ represent the type's primary key. - -The `Book` type above uses a _subfield_ as part of its primary key. The `["name"]` item indicates that the `name` field of the _previous_ field in the array (`author`) is part of the primary key. The `Book`'s `author` field must be an object that includes a `name` field for this to be valid. +This example shows a variety of `typePolicies` with different `keyFields`: -In the example above, the resulting identifier string for a `Book` object has the following structure: - -``` -Book:{"title":"Fahrenheit 451","author":{"name":"Ray Bradbury"}} -``` +* The `Product` type uses its `upc` field as its identifying field. +* The `Person` type uses the combination of both its `name` _and_ `email` fields. +* The `Book` type includes a _subfield_ as part of its cache ID. + * The `["name"]` item indicates that the `name` field of the _previous_ field in the array (`author`) is part of the cache ID. The `Book`'s `author` field must be an object that includes a `name` field for this to be valid. + * A valid cache ID for the `Book` type has the following structure: + ``` + Book:{"title":"Fahrenheit 451","author":{"name":"Ray Bradbury"}} + ``` +* The `AllProducts` type illustrates a special strategy for a **singleton** type. If the cache will only ever contain one `AllProducts` object and that object has _no_ identifying fields, you can provide an empty array for its `keyFields`. -An object's primary key fields are always listed in the same order to ensure uniqueness. +If an object has multiple `keyFields`, the cache ID always lists those fields in the same order to ensure uniqueness. Note that these `keyFields` strings always refer to the actual field names as defined in your schema, meaning the ID computation is not sensitive to [field aliases](https://www.apollographql.com/docs/resources/graphql-glossary/#alias). -#### Calculating an object's identifier +### Calculating an object's cache ID -If you define a custom identifier that uses multiple fields, it can be challenging to calculate and provide that identifier to methods that require it (such as `cache.readFragment`). +If you define a custom cache ID that uses multiple fields, it can be challenging to calculate and provide that ID to methods that require it (such as `cache.readFragment`). -To help with this, you can use the `cache.identify` method to calculate the identifier for any normalized object you fetch from your cache. See [Obtaining an object's custom ID](./cache-interaction/#obtaining-an-objects-custom-id). +To help with this, you can use the `cache.identify` method to calculate the cache ID for any normalized object you fetch from your cache. See [Obtaining an object's custom ID](./cache-interaction/#obtaining-an-objects-cache-id). -#### Customizing identifier generation globally +### Customizing identifier generation globally If you need to define a single fallback `keyFields` function that isn't specific to any particular `__typename`, you can use the `dataIdFromObject` function that was introduced in Apollo Client 2.x: @@ -242,95 +220,10 @@ Notice that the above function still uses different logic to generate keys based You can instruct the `InMemoryCache` _not_ to normalize objects of a certain type. This can be useful for metrics and other transient data that's identified by a timestamp and never receives updates. -To disable normalization for a type, define a `TypePolicy` for the type (as shown in [Customizing identifier generation by type](#customizing-identifier-generation-by-type)) and set the policy's `keyFields` field to `false`. +To disable normalization for a type, define a `TypePolicy` for the type (as shown in [Customizing cache IDs](#customizing-cache-ids)) and set the policy's `keyFields` field to `false`. Objects that are not normalized are instead embedded within their _parent_ object in the cache. You can't access these objects directly, but you can access them via their parent. -## Visualizing the cache - -To help understand the structure of your cached data, we strongly recommend installing the [Apollo Client Devtools](../development-testing/developer-tooling/#apollo-client-devtools). - -This browser extension includes an inspector that enables you to view all of the normalized objects contained in your cache: - -The Cache tab of the Apollo Client Devtools - -### Example - -Let's say we use Apollo Client to run the following query on the [SWAPI demo API](https://github.com/graphql/swapi-graphql): - -```graphql -query { - allPeople(first:3) { # Return the first 3 items - people { - id - name - homeworld { - id - name - } - } - } -} -``` - -This query returns the following result of three `Person` objects, each with a corresponding `homeworld` (a `Planet` object): - - - -```json -{ - "data": { - "allPeople": { - "people": [ - { - "__typename": "Person", - "id": "cGVvcGxlOjE=", - "name": "Luke Skywalker", - "homeworld": { - "__typename": "Planet", - "id": "cGxhbmV0czox", - "name": "Tatooine" - } - }, - { - "__typename": "Person", - "id": "cGVvcGxlOjI=", - "name": "C-3PO", - "homeworld": { - "__typename": "Planet", - "id": "cGxhbmV0czox", - "name": "Tatooine" - } - }, - { - "__typename": "Person", - "id": "cGVvcGxlOjM=", - "name": "R2-D2", - "homeworld": { - "__typename": "Planet", - "id": "cGxhbmV0czo4", - "name": "Naboo" - } - } - ] - } - } -} -``` - - - -> Notice that each object in the result includes a `__typename` field, even though our query string _didn't_ include this field. That's because Apollo Client _automatically_ queries for every object's `__typename` field. - -After the result is cached, we can view the state of our cache in the Apollo Client Devtools: - -The Cache tab of the Apollo Client Devtools - -Our cache now contains five normalized objects (in addition to the `ROOT_QUERY` object): three `Person` objects and two `Planet` objects. - -**Why do we only have two `Planet` objects?** Because two of the three returned `Person` objects have the same `homeworld`. By [normalizing data](#data-normalization) like this, Apollo Client can cache a single copy of an object, and multiple _other_ objects can include _references_ to it (see the `__ref` field of the object in the screenshot above). - - ## `TypePolicy` fields To customize how the cache interacts with specific types in your schema, you can provide an object mapping `__typename` strings to `TypePolicy` objects when you create a new `InMemoryCache` object. diff --git a/docs/source/caching/cache-interaction.md b/docs/source/caching/cache-interaction.md index 76d9b018593..d071f39748c 100644 --- a/docs/source/caching/cache-interaction.md +++ b/docs/source/caching/cache-interaction.md @@ -147,7 +147,7 @@ This example fetches the same data as [the example for `readQuery`](#readquery) ```js const todo = client.readFragment({ - id: 'Todo:5', // The value of the to-do item's unique identifier + id: 'Todo:5', // The value of the to-do item's cache ID fragment: gql` fragment MyTodo on Todo { id @@ -158,7 +158,7 @@ const todo = client.readFragment({ }); ``` -Unlike `readQuery`, `readFragment` requires an `id` option. This option specifies the unique identifier for the object in your cache. [By default](cache-configuration/#default-identifier-generation), this identifier has the format `<_typename>:` (which is why we provide `Todo:5` above). You can [customize this identifier](./cache-configuration/#customizing-identifier-generation-by-type). +Unlike `readQuery`, `readFragment` requires an `id` option. This option specifies the cache ID for the object in your cache. [By default](./overview/#2-generate-cache-ids), cache IDs have the format `<_typename>:` (which is why we provide `Todo:5` above). You can [customize this ID](./cache-configuration/#customizing-cache-ids). In the example above, `readFragment` returns `null` if no `Todo` object with ID `5` exists in the cache, or if the object exists but is missing a value for either `text` or `completed`. @@ -240,7 +240,7 @@ The `modify` method of `InMemoryCache` enables you to directly modify the values Canonically documented in the [API reference](../api/cache/InMemoryCache/#modify), the `modify` method takes the following parameters: -* The ID of a cached object to modify (which we recommend obtaining with [`cache.identify`](#obtaining-an-objects-custom-id)) +* The ID of a cached object to modify (which we recommend obtaining with [`cache.identify`](#obtaining-an-objects-cache-id)) * A map of **modifier functions** to execute (one for each field to modify) * Optional `broadcast` and `optimistic` boolean values to customize behavior @@ -266,7 +266,7 @@ cache.modify({ When you define a modifier function for a field that contains a scalar, an enum, or a list of these base types, the modifier function is passed the exact existing value for the field. For example, if you define a modifier function for an object's `quantity` field that has current value `5`, your modifier function is passed the value `5`. -**However**, when you define a modifier function for a field that contains an object type or a list of objects, those objects are represented as **references**. Each reference points to its corresponding object in the cache by its identifier. If you return a _different_ reference in your modifier function, you change _which_ other cached object is contained in this field. You _don't_ modify the original cached object's data. +**However**, when you define a modifier function for a field that contains an object type or a list of objects, those objects are represented as **references**. Each reference points to its corresponding object in the cache by its cache ID. If you return a _different_ reference in your modifier function, you change _which_ other cached object is contained in this field. You _don't_ modify the original cached object's data. ### Modifier function utilities @@ -295,7 +295,7 @@ cache.modify({ Let's break this down: -* In the `id` field, we use [`cache.identify`](#obtaining-an-objects-custom-id) to obtain the identifier of the cached `Post` object we want to remove a comment from. +* In the `id` field, we use [`cache.identify`](#obtaining-an-objects-cache-id) to obtain the cache ID of the cached `Post` object we want to remove a comment from. * In the `fields` field, we provide an object that lists our modifier functions. In this case, we define a single modifier function (for the `comments` field). @@ -431,9 +431,9 @@ cache.modify({ When using this form of `cache.modify`, you can determine the individual field names using `details.fieldName`. This technique works for any modifier function, not just those that return `INVALIDATE`. -## Obtaining an object's custom ID +## Obtaining an object's cache ID -If a type in your cache uses a [custom identifier](./cache-configuration/#customizing-identifier-generation-by-type) (or even if it doesn't), you can use the `cache.identify` method to obtain the identifier for an object of that type. This method takes an object and computes its ID based on both its `__typename` and its identifier field(s). This means you don't have to keep track of which fields make up each type's identifier. +If a type in your cache uses a [custom cache ID](./cache-configuration/#customizing-cache-ids) (or even if it doesn't), you can use the `cache.identify` method to obtain the cache ID for an object of that type. This method takes an object and computes its ID based on both its `__typename` and its identifier field(s). This means you don't have to keep track of which fields make up each type's cache ID. ### Example @@ -442,7 +442,7 @@ Let's say we have a JavaScript representation of a cached GraphQL object, like t ```js{3} const invisibleManBook = { __typename: 'Book', - isbn: '9780679601395', // This type's custom identifier + isbn: '9780679601395', // The key field for this type's cache ID title: 'Invisible Man', author: { __typename: 'Author', @@ -451,9 +451,9 @@ const invisibleManBook = { }; ``` -If we want to interact with this object in our cache with methods like [`writeFragment`](#writefragment) or [`cache.modify`](#using-cachemodify), we need the object's identifier. Our `Book` type's identifier appears to be custom, because the `id` field isn't present. +If we want to interact with this object in our cache with methods like [`writeFragment`](#writefragment) or [`cache.modify`](#using-cachemodify), we need the object's cache ID. Our `Book` type's cache ID appears to be custom, because the `id` field isn't present. -Instead of needing to look up that our `Book` type uses the `isbn` field as its identifier, we can use the `cache.identify` method, like so: +Instead of needing to look up that our `Book` type uses the `isbn` field for its cache ID, we can use the `cache.identify` method, like so: ```js{8} const bookYearFragment = gql` @@ -471,6 +471,6 @@ const fragmentResult = cache.writeFragment({ }); ``` -The cache knows that the `Book` type uses the `isbn` field for its identifier, so `cache.identify` can correctly populate the `id` field above. +The cache knows that the `Book` type uses the `isbn` field for its cache ID, so `cache.identify` can correctly populate the `id` field above. -This example is straightforward because our custom identifier uses a single field (`isbn`). But custom identifiers can consist of _multiple_ fields (such as both `isbn` _and_ `title`). This makes it much more challenging and repetitive to specify an object's custom ID _without_ using `cache.identify`. +This example is straightforward because our cache ID uses a single field (`isbn`). But custom cache IDs can consist of _multiple_ fields (such as both `isbn` _and_ `title`). This makes it much more challenging and repetitive to specify an object's cache ID _without_ using `cache.identify`. diff --git a/docs/source/caching/overview.mdx b/docs/source/caching/overview.mdx new file mode 100644 index 00000000000..d3cfa7bbe8e --- /dev/null +++ b/docs/source/caching/overview.mdx @@ -0,0 +1,224 @@ +--- +title: Caching in Apollo Client +description: Overview +sidebar_title: Overview +--- + +import {ExpansionPanel} from 'gatsby-theme-apollo-docs'; + +Apollo Client stores the results of your GraphQL queries in a local, [normalized](#data-normalization), in-memory cache. This enables Apollo Client to respond almost immediately to queries for already-cached data, without even sending a network request. + +For example, the _first_ time your app queries for a `Book` object with id `5`, the flow looks like this: + +```mermaid +sequenceDiagram + Apollo Client->>InMemoryCache: Queries for Book with id 5 + Note over InMemoryCache: Book:5 not found in cache! + InMemoryCache->>GraphQL Server: Query sent to server + GraphQL Server->>InMemoryCache: Server responds with Book + Note over InMemoryCache: Book:5 is cached + InMemoryCache->>Apollo Client: Returns Book +``` + +And each _later_ time your app queries for that same object, the flow looks like this instead: + +```mermaid +sequenceDiagram + Apollo Client->>InMemoryCache: Queries for Book with id 5 + Note over InMemoryCache: Book:5 found in cache! + InMemoryCache->>Apollo Client: Returns Book + Note over GraphQL Server: (Server is never queried) +``` + +The Apollo Client cache is highly configurable. You can customize its behavior for individual types and fields in your schema, and you can even use it to store and interact with local data that _isn't_ fetched from your GraphQL server. + +## How is data stored? + +Apollo Client's `InMemoryCache` maintains a **flat lookup table** of objects that can reference each other. These objects accumulate field information from objects that are returned by your GraphQL queries. A single cached object might include fields returned by multiple queries, if those queries fetch _different_ fields of the _same_ object. + +The cache is flat, but objects returned by a GraphQL query often _aren't_! In fact, their nesting can be arbitrarily deep. Take a look at this example query response: + +```json +{ + "data": { + "person": { + "__typename": "Person", + "id": "cGVvcGxlOjE=", + "name": "Luke Skywalker", + "homeworld": { + "__typename": "Planet", + "id": "cGxhbmV0czox", + "name": "Tatooine" + } + } + } +} +``` + +This response contains a `Person` object, which in turn contains a `Planet` object in its `homeworld` field. + +So how does the `InMemoryCache` store _hierarchical_ data in a _flat_ lookup table? Before storing this data, the cache needs to **normalize** it. + +### Data normalization + +Whenever the Apollo Client cache receives query response data, it does the following: + +#### 1. Identify objects + +First, the cache identifies all of the distinct objects included in a query response. In [the example above](#how-is-data-stored), there are two objects: a `Person` with `id` `cGVvcGxlOjE=`, and a `Planet` with `id` `cGxhbmV0czox`. + +#### 2. Generate cache IDs + +Second, the cache generates a **cache ID** for each identified object. A cache ID uniquely identifies a particular object while it's in the `InMemoryCache`. + +By default, an object's cache ID is the concatenation of the object's `__typename` and `id` (or `_id`) fields, separated by a colon (`:`). + + + +So, the default cache IDs for the objects in [the example above](#how-is-data-stored) are: + +* `Person:cGVvcGxlOjE=` +* `Planet:cGxhbmV0czox` + +> You can customize the cache ID format for a particular object type. See [Customizing cache IDs](./cache-configuration/#customizing-cache-ids). + +If the cache _can't_ generate a cache ID for a particular object (for example, if no `__typename` field is present), that object is cached directly inside its _parent_ object and it must be referenced via the parent (this means the cache isn't always _completely_ flat). + +#### 3. Replace object fields with references + +Third, the cache takes each field that contains an object and replaces its value with a **reference** to the appropriate object. + +For example, here's the `Person` object from [the example above]() _before_ reference replacement: + +```json{5-9} +{ + "__typename": "Person", + "id": "cGVvcGxlOjE=", + "name": "Luke Skywalker", + "homeworld": { + "__typename": "Planet", + "id": "cGxhbmV0czox", + "name": "Tatooine" + } +} +``` + +And here it is _after_ replacement: + +```json{5-7} +{ + "__typename": "Person", + "id": "cGVvcGxlOjE=", + "name": "Luke Skywalker", + "homeworld": { + "__ref": "Planet:cGxhbmV0czox" + } +} +``` + +The `homeworld` field now contains a reference to the appropriate normalized `Planet` object. + +> This replacement does _not_ occur for a particular object if [the previous step](#2-generate-cache-ids) failed to generate a cache ID for that object. Instead, the original object remains. + +Later, if you query for _another_ `Person` who has the same `homeworld`, that normalized `Person` object will contain a reference to the _same_ cached object! Normalization can dramatically reduce data duplication, and it also helps your local data stay up to date with your server. + +#### 4. Store normalized objects + +The resulting objects are all stored in the cache's flat lookup table. + +Whenever an incoming object has the same cache ID as an _existing_ cached object, the fields of those objects are _merged_: + +* If the incoming object and the existing object share any fields, the incoming object _overwrites_ the cached values for those fields. +* Fields that appear in _only_ the existing object or _only_ the incoming object are preserved. + +Normalization constructs a partial copy of your data graph on your client, in a format that's optimized for reading and updating as your app's state changes. + +## Visualizing the cache + +To help understand the structure of your cached data, we strongly recommend installing the [Apollo Client Devtools](../development-testing/developer-tooling/#apollo-client-devtools). + +This browser extension includes an inspector that enables you to view all of the normalized objects contained in your cache: + +The Cache tab of the Apollo Client Devtools + +### Example + +Let's say we use Apollo Client to run the following query on the [SWAPI demo API](https://github.com/graphql/swapi-graphql): + +```graphql +query { + allPeople(first:3) { # Return the first 3 items + people { + id + name + homeworld { + id + name + } + } + } +} +``` + +This query returns the following result of three `Person` objects, each with a corresponding `homeworld` (a `Planet` object): + + + +```json +{ + "data": { + "allPeople": { + "people": [ + { + "__typename": "Person", + "id": "cGVvcGxlOjE=", + "name": "Luke Skywalker", + "homeworld": { + "__typename": "Planet", + "id": "cGxhbmV0czox", + "name": "Tatooine" + } + }, + { + "__typename": "Person", + "id": "cGVvcGxlOjI=", + "name": "C-3PO", + "homeworld": { + "__typename": "Planet", + "id": "cGxhbmV0czox", + "name": "Tatooine" + } + }, + { + "__typename": "Person", + "id": "cGVvcGxlOjM=", + "name": "R2-D2", + "homeworld": { + "__typename": "Planet", + "id": "cGxhbmV0czo4", + "name": "Naboo" + } + } + ] + } + } +} +``` + + + +> Notice that each object in the result includes a `__typename` field, even though our query string _didn't_ include this field. That's because Apollo Client _automatically_ queries for every object's `__typename` field. + +After the result is cached, we can view the state of our cache in the Apollo Client Devtools: + +The Cache tab of the Apollo Client Devtools + +Our cache now contains five normalized objects (in addition to the `ROOT_QUERY` object): three `Person` objects and two `Planet` objects. + +**Why do we only have two `Planet` objects?** Because two of the three returned `Person` objects have the same `homeworld`. By [normalizing data](#data-normalization) like this, Apollo Client can cache a single copy of an object, and multiple _other_ objects can include _references_ to it (see the `__ref` field of the object in the screenshot above). + +## Next steps + +Now that you have a basic understanding of how Apollo Client's cache works, learn more about how to [install and configure it](./cache-configuration/). + +Then, you can learn how to [read and write data to your cache](./cache-interaction/) directly, without executing a query against your server. This is a powerful option for [local state management](../local-state/local-state-management/). From f35c5a861f3afe86721de8b36719bb72c77cac8d Mon Sep 17 00:00:00 2001 From: Stephen Barlow Date: Mon, 17 May 2021 17:53:46 -0700 Subject: [PATCH 334/380] WIP on updates to mutation article --- docs/source/data/mutations.mdx | 193 ++++++++++----------------------- 1 file changed, 60 insertions(+), 133 deletions(-) diff --git a/docs/source/data/mutations.mdx b/docs/source/data/mutations.mdx index 7e768d89f28..526be52261e 100644 --- a/docs/source/data/mutations.mdx +++ b/docs/source/data/mutations.mdx @@ -6,8 +6,8 @@ description: Update data with the useMutation hook import MutationOptions from '../../shared/mutation-options.mdx'; import MutationResult from '../../shared/mutation-result.mdx'; -Now that we've [learned how to fetch data](queries/) from our backend with Apollo Client, -the natural next step is to learn how to update that data with **mutations**. +Now that we've [learned how to fetch data](queries/) from our backend with Apollo Client, the natural next step is to learn how to update that data with **mutations**. + This article demonstrates how to send updates to your GraphQL server with the `useMutation` hook. You'll also learn how to update the Apollo Client cache after executing a mutation, and how to track loading and error states for a mutation. @@ -23,21 +23,42 @@ This article also assumes that you've already set up Apollo Client and have wrap ## Executing a mutation -The `useMutation` [React hook](https://reactjs.org/docs/hooks-intro.html) is the primary API for executing mutations in an Apollo application. To run a mutation, you first call `useMutation` within a React component and pass it a GraphQL string that represents the mutation. When your component renders, `useMutation` returns a tuple that includes: +The `useMutation` [React hook](https://reactjs.org/docs/hooks-intro.html) is the primary API for executing mutations in an Apollo application. + +To run a mutation, you first call `useMutation` within a React component and pass it a GraphQL string that represents the mutation: + + +```jsx{8}:title=my-component.jsx +import { gql, useMutation } from '@apollo/client'; + +const MY_MUTATION = gql` + # Define mutation here +`; + +function MyComponent() { + const [mutateFunction, { data, loading, error }] = useMutation(MY_MUTATION); +} +``` + + When your component renders, `useMutation` returns a tuple that includes: * A **mutate function** that you can call at any time to execute the mutation -* An object with fields that represent the current status of the mutation's execution + * Unlike `useQuery`, `useMutation` _doesn't_ execute its operation automatically. Instead, you call this function. +* An object with fields that represent the current status of the mutation's execution (`data`, `loading`, etc.) + * This object is similar to the object returned by the `useQuery` hook. For details, see [Result](#result). -Let's look at an example. First, we'll create a GraphQL mutation named `ADD_TODO`, which represents adding an item to a to-do list. Remember to wrap GraphQL strings in the `gql` function to parse them into query documents: +### Example -```jsx +Let's say we're creating a to-do list application and we want the user to be able to add items to their list. First, we'll create a corresponding GraphQL mutation named `ADD_TODO`. Remember to wrap GraphQL strings in the `gql` function to parse them into query documents: + +```jsx:title=add-todo.jsx import { gql, useMutation } from '@apollo/client'; const ADD_TODO = gql` - mutation AddTodo($type: String!) { - addTodo(type: $type) { + mutation AddTodo($text: String!) { + addTodo(text: $text) { id - type + text } } `; @@ -46,17 +67,20 @@ const ADD_TODO = gql` Next, we'll create a component named `AddTodo` that represents the submission form for the to-do list. Inside it, we'll pass our `ADD_TODO` mutation to the `useMutation` hook: -```jsx:title=index.js +```jsx{3,13}:title=add-todo.jsx function AddTodo() { let input; - const [addTodo, { data }] = useMutation(ADD_TODO); + const [addTodo, { data, loading, error }] = useMutation(ADD_TODO); + + if (loading) return 'Submitting...'; + if (error) return `Submission error! ${error.message}`; return (
{ e.preventDefault(); - addTodo({ variables: { type: input.value } }); + addTodo({ variables: { text: input.value } }); input.value = ''; }} > @@ -72,93 +96,50 @@ function AddTodo() { } ``` -### Calling the mutate function +In this example, our `form`'s `onSubmit` handler calls the **mutate function** (named `addTodo`) that's returned by the `useMutation` hook. This tells Apollo Client to execute the mutation by sending it to our GraphQL server. -The `useMutation` hook does _not_ automatically execute the mutation you -pass it when the component renders. Instead, it returns a tuple with a **mutate function** in its first position (which we assign to `addTodo` in the example above). You then call the mutate function -at any time to instruct Apollo Client to execute the mutation. In the example above, we call `addTodo` when the user submits the form. +> Note that this behavior differs from [`useQuery`](./queries/), which executes its operation as soon as its component renders. This is because mutations are more commonly executed in response to a user action, such as submitting a form. ### Providing options -Both `useMutation` itself and the mutate function accept options that are described in the [API reference](../api/react/hooks/#usemutation). Any options you provide to a mutate function _override_ corresponding options -you previously provided to `useMutation`. In the example above, we provide the -`variables` option to `addTodo`, which enables us to specify any GraphQL variables that the mutation requires. - +Both `useMutation` itself and the mutate function accept options that are described in the [API reference](../api/react/hooks/#usemutation). Any options you provide to a mutate function _override_ corresponding options you previously provided to `useMutation`. -### Tracking mutation status +In the example above, we provide the `variables` option to `addTodo`, which enables us to specify any GraphQL variables that the mutation requires (in this case, the `text` of the to-do item). -In addition to a mutate function, the `useMutation` hook returns an object that -represents the current state of the mutation's execution. The fields of this -object (fully documented in the [API reference](../api/react/hooks/)) include booleans that indicate whether the mutate function has been `called` yet, and whether the mutation's result is currently `loading`. -## Updating the cache after a mutation +### Tracking mutation status -When you execute a mutation, you modify back-end data. If that data -is also present in your [Apollo Client cache](../caching/cache-configuration/), -you might need to update your cache to reflect the result of the mutation. -This depends on whether the mutation _updates a single existing entity_. +In addition to a mutate function, the `useMutation` hook returns an object that represents the current state of the mutation's execution. The fields of this object (listed in [Result](#result)) include booleans that indicate whether the mutate function has been `called` yet, and whether the mutation's result is currently `loading`. -### Updating a single existing entity +[The example above](#example) destructures the `loading` and `error` fields from this object to render the `AddTodo` component differently depending on the mutation's current status. -If a mutation updates a single existing entity, Apollo Client can automatically -update that entity's value in its cache when the mutation returns. To do so, -the mutation must return the `id` of the modified entity, along with the values -of the fields that were modified. Conveniently, mutations do this by default in -Apollo Client. +> The `useMutation` hook also supports `onCompleted` and `onError` options if you prefer to use callbacks. [See the API reference.](../api/react/hooks/#options-2) -Let's look at an example that enables us to modify the value of any existing -item in our to-do list: +## Updating the cache after a mutation -```jsx -const UPDATE_TODO = gql` - mutation UpdateTodo($id: String!, $type: String!) { - updateTodo(id: $id, type: $type) { - id - type - } - } -`; +When you execute a mutation, you modify back-end data. You often then want to update your [Apollo Client cache](../caching/cache-configuration/) to reflect that back-end modification. -function Todos() { - const { loading, error, data } = useQuery(GET_TODOS); - const [updateTodo] = useMutation(UPDATE_TODO); +### Include updates in mutation responses - if (loading) return

Loading...

; - if (error) return

Error :(

; +In most cases, a mutation response should include any object(s) the mutation modified. This enables Apollo Client to normalize those objects and cache them according to their `id` and `__typename` fields ([by default](../caching/cache-configuration/#generating-unique-identifiers)). - return data.todos.map(({ id, type }) => { - let input; +[In the example above](#example), our `ADD_TODO` mutation might return a `Todo` object with the following structure: - return ( -
-

{type}

- { - e.preventDefault(); - updateTodo({ variables: { id, type: input.value } }); - input.value = ''; - }} - > - { - input = node; - }} - /> - - -
- ); - }); +```json +{ + "__typename": "Todo", + "id": "5", + "text": "Buy grapes 🍇" } ``` -If you execute the `UPDATE_TODO` mutation using this component, the mutation -returns both the `id` of the modified to-do item and the item's new `type`. -Because Apollo Client caches entities by `id`, it knows how to automatically -update the corresponding entity in its cache. The application's UI also updates -immediately to reflect changes in the cache. +> Apollo Client automatically adds the `__typename` field to your queries and mutations by default. -### Making all other cache updates +Upon receiving this response object, Apollo Client caches it with key `Todo:5`. If a cached object _already_ exists with this key, Apollo Client overwrites any existing fields that are also included in the mutation response (other existing fields are preserved). + +Returning modified objects like this is a helpful first step to keeping your cache in sync with your backend. However, it isn't always sufficient. For example, a newly cached object isn't automatically added to any _list fields_ that should now include that object. To accomplish this, you can define an **update function**. + +### Refetch queries If a mutation modifies multiple entities, or if it creates or deletes entities, the Apollo Client cache is _not_ automatically updated to reflect the result of the mutation. To resolve this, your call to `useMutation` can include an **update function**. @@ -236,60 +217,6 @@ When the `ADD_TODO` mutation is run in the above example, the newly added and re Any changes you make to cached data inside of an update function are automatically broadcast to queries that are listening for changes to that data. Consequently, your application's UI will update to reflect newly cached values. -## Tracking loading and error states - -The `useMutation` hook provides mechanisms for tracking the loading and error -state of a mutation. - -Let's revisit the `Todos` component from [Updating a single existing entity](#updating-a-single-existing-entity): - -```jsx -function Todos() { - const { loading: queryLoading, error: queryError, data } = useQuery( - GET_TODOS, - ); - - const [ - updateTodo, - { loading: mutationLoading, error: mutationError }, - ] = useMutation(UPDATE_TODO); - - if (queryLoading) return

Loading...

; - if (queryError) return

Error :(

; - - return data.todos.map(({ id, type }) => { - let input; - - return ( -
-

{type}

-
{ - e.preventDefault(); - updateTodo({ variables: { id, type: input.value } }); - input.value = ''; - }} - > - { - input = node; - }} - /> - -
- {mutationLoading &&

Loading...

} - {mutationError &&

Error :( Please try again

} -
- ); - }); -} -``` - -As shown above, we can destructure the `loading` and `error` properties from -the result object returned by `useMutation` to track the mutation's state in our UI. The `useMutation` hook also supports `onCompleted` and `onError` options if you prefer to use callbacks. - -Learn about all of the fields returned by `useMutation` in the [API reference](../api/react/hooks/). - ## `useMutation` API Supported options and result fields for the `useMutation` hook are listed below. From 28015294859e6bc9ab7336f21ed8a14286704128 Mon Sep 17 00:00:00 2001 From: Stephen Barlow Date: Tue, 18 May 2021 15:31:25 -0700 Subject: [PATCH 335/380] Update mutation options table --- docs/shared/mutation-options.mdx | 265 +++++++++++++++++++++++++++++-- docs/source/data/mutations.mdx | 44 +++-- 2 files changed, 282 insertions(+), 27 deletions(-) diff --git a/docs/shared/mutation-options.mdx b/docs/shared/mutation-options.mdx index f6dc7efbb11..b2244890e3a 100644 --- a/docs/shared/mutation-options.mdx +++ b/docs/shared/mutation-options.mdx @@ -1,14 +1,251 @@ -| Option | Type | Description | -| - | - | - | -| `mutation` | DocumentNode | A GraphQL mutation document parsed into an AST by `graphql-tag`. **Optional** for the `useMutation` Hook since the mutation can be passed in as the first parameter to the Hook. **Required** for the `Mutation` component. | -| `variables` | { [key: string]: any } | An object containing all of the variables your mutation needs to execute | -| `update` | (cache: ApolloCache, mutationResult: FetchResult) | A function used to update the cache after a mutation occurs | -| `ignoreResults`| boolean | If true, the returned `data` property will not update with the mutation result. | -| `optimisticResponse` | Object | Provide a [mutation response](/performance/optimistic-ui/) before the result comes back from the server | -| `refetchQueries` | Array<string\|{ query: DocumentNode, variables?: TVariables}> \| ((mutationResult: FetchResult) => Array<string\|{ query: DocumentNode, variables?: TVariables}>) | An array or function that allows you to specify which queries you want to refetch after a mutation has occurred. Array values can either be queries (with optional variables) or just the string names of queries to be refetched. | -| `awaitRefetchQueries` | boolean | Queries refetched as part of `refetchQueries` are handled asynchronously, and are not waited on before the mutation is completed (resolved). Setting this to `true` will make sure refetched queries are completed before the mutation is considered done. `false` by default. | -| `onCompleted` | (data: TData) => void | A callback executed once your mutation successfully completes | -| `onError` | (error: ApolloError) => void | A callback executed in the event of an error. | -| `errorPolicy` | ErrorPolicy | How you want your component to handle network and GraphQL errors. Defaults to "none", which means we treat GraphQL errors as runtime errors. | -| `context` | Record<string, any> | Shared context between your component and your network interface (Apollo Link). | -| `client` | ApolloClient | An `ApolloClient` instance. By default `useMutation` / `Mutation` uses the client passed down via context, but a different client can be passed in. | + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Name /
Type
Description
+ +**Operation options** + +
+ +###### `mutation` + +`DocumentNode` + + +A GraphQL query string parsed into an AST with the `gql` template literal. + +**Optional** for the `useMutation` hook, because the query can be provided as the first parameter to the hook. **Required** for the `Mutation` component. +
+ +###### `variables` + +`{ [key: string]: any }` + + +An object containing all of the GraphQL variables your mutation requires to execute. + +Each key in the object corresponds to a variable name, and that key's value corresponds to the variable value. + +
+ +###### `errorPolicy` + +`ErrorPolicy` + + +Specifies how the mutation handles a response that returns both GraphQL errors and partial results. + +For details, see [GraphQL error policies](https://www.apollographql.com/docs/react/data/error-handling/#graphql-error-policies). + +The default value is `none`, meaning that the mutation result includes error details but _not_ partial results. + +
+ +###### `onCompleted` + +`(data: TData | {}) => void` + + +A callback function that's called when your mutation successfully completes with zero errors (or if `errorPolicy` is `ignore` and partial data is returned). + +This function is passed the mutation's result `data`. + +
+ +###### `onError` + +`(error: ApolloError) => void` + + +A callback function that's called when the mutation encounters one or more errors (unless `errorPolicy` is `ignore`). + +This function is passed an [`ApolloError`](https://github.com/apollographql/apollo-client/blob/d96f4578f89b933c281bb775a39503f6cdb59ee8/src/errors/index.ts#L36-L39) object that contains either a `networkError` object or a `graphQLErrors` array, depending on the error(s) that occurred. + +
+ +###### `refetchQueries` + +`Array<string\|{ query: DocumentNode, variables?: TVariables}> \| ((mutationResult: FetchResult) => Array<string\|{ query: DocumentNode, variables?: TVariables}>)` + + +An array or function that allows you to specify which queries you want to refetch after a mutation has occurred. Array values can either be queries (with optional variables) or just the string names of queries to be refetched. + +
+ +###### `awaitRefetchQueries` + +`boolean` + + +If `true`, makes sure all queries included in `refetchQueries` are completed before the mutation is considered complete. + +The default value is `false` (queries are refetched asynchronously). + +
+ +###### `ignoreResults` + +`boolean` + + +If `true`, the mutation's `data` property is not updated with the mutation's result. + +The default value is `false`. + +
+ +**Networking options** + +
+ +###### `notifyOnNetworkStatusChange` + +`boolean` + + +If `true`, the in-progress mutation's associated component re-renders whenever the network status changes or a network error occurs. + +The default value is `false`. + +
+ +###### `client` + +`ApolloClient` + + +The instance of `ApolloClient` to use to execute the mutation. + +By default, the instance that's passed down via context is used, but you can provide a different instance here. + +
+ +###### `context` + +`Record` + + +If you're using [Apollo Link](https://www.apollographql.com/docs/react/api/link/introduction/), this object is the initial value of the `context` object that's passed along your link chain. + +
+ +**Caching options** + +
+ +###### `update` + +`(cache: ApolloCache, mutationResult: FetchResult)` + + +A function used to update the cache after the mutation completes. + +
+ +###### `optimisticResponse` + +`Object` + + +If provided, Apollo Client caches this temporary (and potentially incorrect) response until the mutation completes, enabling more responsive UI updates. + +For more information, see [Optimistic mutation results](/performance/optimistic-ui/). + +
diff --git a/docs/source/data/mutations.mdx b/docs/source/data/mutations.mdx index 526be52261e..732c0bef180 100644 --- a/docs/source/data/mutations.mdx +++ b/docs/source/data/mutations.mdx @@ -1,25 +1,23 @@ --- title: Mutations -description: Update data with the useMutation hook +description: Modify data with the useMutation hook --- import MutationOptions from '../../shared/mutation-options.mdx'; import MutationResult from '../../shared/mutation-result.mdx'; -Now that we've [learned how to fetch data](queries/) from our backend with Apollo Client, the natural next step is to learn how to update that data with **mutations**. +Now that we've [learned how to query data](queries/) from our backend with Apollo Client, the natural next step is to learn how to _modify_ back-end data with **mutations**. -This article demonstrates how to send updates to your GraphQL server with the -`useMutation` hook. You'll also learn how to update the Apollo Client cache -after executing a mutation, and how to track loading and error states for a mutation. +This article demonstrates how to send updates to your GraphQL server with the `useMutation` hook. You'll also learn how to update the Apollo Client cache after executing a mutation, and how to track loading and error states for a mutation. + +> To follow along with the examples below, open up our [starter project](https://codesandbox.io/s/mutations-example-app-start-gm7i5) and [sample GraphQL server](https://codesandbox.io/s/mutations-example-app-server-sxewr) on CodeSandbox. You can view the completed version of the app [here](https://codesandbox.io/s/mutations-example-app-final-tjoje). ## Prerequisites This article assumes you're familiar with building basic GraphQL mutations. If you need a refresher, we recommend that you [read this guide](http://graphql.org/learn/queries/#mutations). -This article also assumes that you've already set up Apollo Client and have wrapped your React app in an `ApolloProvider` component. Read our [getting started guide](../get-started/) if you need help with either of those steps. - -> To follow along with the examples below, open up our [starter project](https://codesandbox.io/s/mutations-example-app-start-gm7i5) and [sample GraphQL server](https://codesandbox.io/s/mutations-example-app-server-sxewr) on CodeSandbox. You can view the completed version of the app [here](https://codesandbox.io/s/mutations-example-app-final-tjoje). +This article also assumes that you've already set up Apollo Client and have wrapped your React app in an `ApolloProvider` component. For help with those steps, [get started](../get-started/). ## Executing a mutation @@ -43,7 +41,7 @@ function MyComponent() { When your component renders, `useMutation` returns a tuple that includes: * A **mutate function** that you can call at any time to execute the mutation - * Unlike `useQuery`, `useMutation` _doesn't_ execute its operation automatically. Instead, you call this function. + * Unlike `useQuery`, `useMutation` _doesn't_ execute its operation automatically. Instead, you call the mutate function. * An object with fields that represent the current status of the mutation's execution (`data`, `loading`, etc.) * This object is similar to the object returned by the `useQuery` hook. For details, see [Result](#result). @@ -98,20 +96,40 @@ function AddTodo() { In this example, our `form`'s `onSubmit` handler calls the **mutate function** (named `addTodo`) that's returned by the `useMutation` hook. This tells Apollo Client to execute the mutation by sending it to our GraphQL server. -> Note that this behavior differs from [`useQuery`](./queries/), which executes its operation as soon as its component renders. This is because mutations are more commonly executed in response to a user action, such as submitting a form. +> Note that this behavior differs from [`useQuery`](./queries/), which executes its operation as soon as its component renders. This is because mutations are more commonly executed in response to a user action (such as submitting a form in this case). ### Providing options -Both `useMutation` itself and the mutate function accept options that are described in the [API reference](../api/react/hooks/#usemutation). Any options you provide to a mutate function _override_ corresponding options you previously provided to `useMutation`. +The `useMutation` hook accepts an `options` object as its second parameter. Here's an example that provides the `onCompleted` option to take an action after the mutation completes successfully: -In the example above, we provide the `variables` option to `addTodo`, which enables us to specify any GraphQL variables that the mutation requires (in this case, the `text` of the to-do item). +```js +const [addTodo, { data, loading, error }] = useMutation(ADD_TODO, { + onCompleted: (data) => console.log('Complete!') +}); +``` + +> All supported options are listed in [Options](#options). +You can _also_ provide these options to your mutate function, as demonstrated in [the example above](#example): + +```js +addTodo({ variables: { text: input.value } }); +``` + +Here, we're using the `variables` option to provide the values of any GraphQL variables that our mutation requires (specifically, the `text` of the created to-do item). + +> If you provide the same option to both `useMutation` _and_ your mutate function, the value you provide your mutate function takes precedence. ### Tracking mutation status In addition to a mutate function, the `useMutation` hook returns an object that represents the current state of the mutation's execution. The fields of this object (listed in [Result](#result)) include booleans that indicate whether the mutate function has been `called` yet, and whether the mutation's result is currently `loading`. -[The example above](#example) destructures the `loading` and `error` fields from this object to render the `AddTodo` component differently depending on the mutation's current status. +[The example above](#example) destructures the `loading` and `error` fields from this object to render the `AddTodo` component differently depending on the mutation's current status: + +```jsx +if (loading) return 'Submitting...'; +if (error) return `Submission error! ${error.message}`; +``` > The `useMutation` hook also supports `onCompleted` and `onError` options if you prefer to use callbacks. [See the API reference.](../api/react/hooks/#options-2) From 381066bb26aabafad3484ee94ac8190eb9a2c3d8 Mon Sep 17 00:00:00 2001 From: Stephen Barlow Date: Tue, 18 May 2021 15:51:08 -0700 Subject: [PATCH 336/380] Revisions to mutation-options table --- docs/shared/mutation-options.mdx | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/docs/shared/mutation-options.mdx b/docs/shared/mutation-options.mdx index b2244890e3a..de98b2ef855 100644 --- a/docs/shared/mutation-options.mdx +++ b/docs/shared/mutation-options.mdx @@ -1,4 +1,4 @@ - +
@@ -27,7 +27,7 @@ A GraphQL query string parsed into an AST with the `gql` template literal. -**Optional** for the `useMutation` hook, because the query can be provided as the first parameter to the hook. **Required** for the `Mutation` component. +**Optional** for the `useMutation` hook, because the mutation can be provided as the first parameter to the hook. **Required** for the `Mutation` component. @@ -106,12 +106,17 @@ This function is passed an [`ApolloError`](https://github.com/apollographql/apol ###### `refetchQueries` -`Array<string\|{ query: DocumentNode, variables?: TVariables}> \| ((mutationResult: FetchResult) => Array<string\|{ query: DocumentNode, variables?: TVariables}>)` +`Array | ((mutationResult: FetchResult) => Array)` @@ -221,12 +226,14 @@ If you're using [Apollo Link](https://www.apollographql.com/docs/react/api/link/ ###### `update` -`(cache: ApolloCache, mutationResult: FetchResult)` +`(cache: ApolloCache, mutationResult: FetchResult) => void` From 2916c5e951c82816272c44235dfcf73f6f359c32 Mon Sep 17 00:00:00 2001 From: Stephen Barlow Date: Tue, 18 May 2021 16:05:12 -0700 Subject: [PATCH 337/380] Updates to mutation-result --- docs/shared/mutation-result.mdx | 131 +++++++++++++++++++++++++++++--- 1 file changed, 121 insertions(+), 10 deletions(-) diff --git a/docs/shared/mutation-result.mdx b/docs/shared/mutation-result.mdx index b9ea2324a54..2e9948e9937 100644 --- a/docs/shared/mutation-result.mdx +++ b/docs/shared/mutation-result.mdx @@ -1,15 +1,126 @@ **Mutate function:** -| Property | Type | Description | -| - | - | - | -| `mutate` | (options?: MutationOptions) => Promise<FetchResult> | A function to trigger a mutation from your UI. You can optionally pass `awaitRefetchQueries`, `context`, `fetchPolicy`, `optimisticResponse`, `refetchQueries`, `update`, and `variables` in as options, which will override options/props passed to `useMutation` / `Mutation`. The function returns a promise that fulfills with your mutation result. | +
Name /
Type
-An array or function that allows you to specify which queries you want to refetch after a mutation has occurred. Array values can either be queries (with optional variables) or just the string names of queries to be refetched. +An array or a function that _returns_ an array that specifies which queries you want to refetch after the mutation occurs. + +Each array value can be either: + +* An object containing the `query` to execute, along with any `variables` +* A string indicating the operation name of the query to refetch
-A function used to update the cache after the mutation completes. +A function used to update the Apollo Client cache after the mutation completes. + +For more information, see [Updating the cache after a mutation](/data/mutations#updating-the-cache-after-a-mutation).
+ + + + + + + + + + + + + +
Name /
Type
Description
+ +###### `mutate` + +`(options?: MutationOptions) => Promise` + + +A function to trigger the mutation from your UI. You can optionally pass this function any of the following options: + +* `awaitRefetchQueries` +* `context` +* `fetchPolicy` +* `optimisticResponse` +* `refetchQueries` +* `update` +* `variables` + +Any option you pass here overrides any existing value for that option that you passed to `useMutation`. + +The mutate function returns a promise that fulfills with your mutation result. +
**Mutation result:** -| Property | Type | Description | -| - | - | - | -| `data` | TData | The data returned from your mutation. It can be undefined if `ignoreResults` is true. | -| `loading` | boolean | A boolean indicating whether your mutation is in flight | -| `error` | ApolloError | Any errors returned from the mutation | -| `called` | boolean | A boolean indicating if the mutate function has been called | -| `client` | ApolloClient | Your `ApolloClient` instance. Useful for invoking cache methods outside the context of the update function, such as `client.writeQuery` and `client.readQuery`. | + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Name /
Type
Description
+ +###### `data` + +`TData` + + +The data returned from your mutation. Can be `undefined` if `ignoreResults` is `true`. +
+ +###### `loading` + +`boolean` + + +If `true`, the mutation is currently in flight. +
+ +###### `error` + +`ApolloError` + + +If the mutation produces one or more errors, this object contains either an array of `graphQLErrors` or a single `networkError`. Otherwise, this value is `undefined`. + +For more information, see [Handling operation errors](https://www.apollographql.com/docs/react/data/error-handling/). + +
+ +###### `called` + +`boolean` + + +If `true`, the mutation's mutate function has been called. + +
+ +###### `client` + +`ApolloClient` + + +The instance of Apollo Client that executed the mutation. + +Can be useful for manually executing followup operations or writing data to the cache. + +
From 6b53f33569e4408ec82178ed926c9ae39c433cf7 Mon Sep 17 00:00:00 2001 From: Stephen Barlow Date: Wed, 19 May 2021 10:48:54 -0700 Subject: [PATCH 338/380] Prepare for 3.4 refetch edits --- docs/source/data/mutations.mdx | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/docs/source/data/mutations.mdx b/docs/source/data/mutations.mdx index 732c0bef180..6d6890fa138 100644 --- a/docs/source/data/mutations.mdx +++ b/docs/source/data/mutations.mdx @@ -135,9 +135,25 @@ if (error) return `Submission error! ${error.message}`; ## Updating the cache after a mutation -When you execute a mutation, you modify back-end data. You often then want to update your [Apollo Client cache](../caching/cache-configuration/) to reflect that back-end modification. +When you execute a mutation, you modify back-end data. You often then want to update your _locally cached_ data to reflect that back-end modification. For example, if you execute a mutation to add an item to your to-do list, you also want that item to appear in your local copy of the list. -### Include updates in mutation responses +### Available methods + +The most straightforward way to update your local data is to **refetch any queries** that might be affected by the mutation. However, this method requires additional network requests. + +If your mutation returns all of the objects and fields that it modified, you can **update the cache directly** _without_ making any network requests. However, this method increases in complexity as your mutations become more complex. + +If you're just getting started with Apollo Client, we recommend refetching queries to update your cached data. After you get that working, you can improve your app's performance by updating the cache directly instead. + +### Refetching queries + +TODO + +### Updating the cache directly + +TODO + +### Include modified objects in mutation responses In most cases, a mutation response should include any object(s) the mutation modified. This enables Apollo Client to normalize those objects and cache them according to their `id` and `__typename` fields ([by default](../caching/cache-configuration/#generating-unique-identifiers)). @@ -157,7 +173,7 @@ Upon receiving this response object, Apollo Client caches it with key `Todo:5`. Returning modified objects like this is a helpful first step to keeping your cache in sync with your backend. However, it isn't always sufficient. For example, a newly cached object isn't automatically added to any _list fields_ that should now include that object. To accomplish this, you can define an **update function**. -### Refetch queries +### The update function If a mutation modifies multiple entities, or if it creates or deletes entities, the Apollo Client cache is _not_ automatically updated to reflect the result of the mutation. To resolve this, your call to `useMutation` can include an **update function**. From dd4218296eb2fd0edad60b243272c72638875885 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Wed, 19 May 2021 14:48:54 -0400 Subject: [PATCH 339/380] Fix broken links to #sections within mutations docs. --- docs/source/api/react/hoc.mdx | 2 +- docs/source/performance/optimistic-ui.mdx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/source/api/react/hoc.mdx b/docs/source/api/react/hoc.mdx index 667e9fb9e69..4c953aa4d94 100644 --- a/docs/source/api/react/hoc.mdx +++ b/docs/source/api/react/hoc.mdx @@ -906,7 +906,7 @@ In order to change the data in your store call methods on your `DataProxy` insta To read the data from the store that you are changing, make sure to use methods on your `DataProxy` like [`readQuery`](../../caching/cache-interaction/#readquery) and [`readFragment`](../../caching/cache-interaction/#readfragment). -For more information on updating your cache after a mutation with the `options.update` function make sure to read the [Apollo Client technical documentation on the subject](../../data/mutations/#making-all-other-cache-updates). +For more information on updating your cache after a mutation with the `options.update` function make sure to read the [Apollo Client technical documentation on the subject](../../data/mutations/#updating-the-cache-directly). **Example:** diff --git a/docs/source/performance/optimistic-ui.mdx b/docs/source/performance/optimistic-ui.mdx index f09a1b6a527..d0c85935b82 100644 --- a/docs/source/performance/optimistic-ui.mdx +++ b/docs/source/performance/optimistic-ui.mdx @@ -21,7 +21,7 @@ Our app knows what the updated `Comment` object will probably look like, which m ## The `optimisticResponse` option -To enable this optimistic UI behavior, we provide an `optimisticResponse` option to the [mutate function](../data/mutations/#calling-the-mutate-function) that we use to execute our mutation. +To enable this optimistic UI behavior, we provide an `optimisticResponse` option to the [mutate function](../data/mutations/#executing-a-mutation) that we use to execute our mutation. Let's look at some code: @@ -95,7 +95,7 @@ When you execute the mutate function in this case, the Apollo Client cache store ### View on CodeSandbox -You can view a full to-do list example on CodeSandbox: +You can view a full to-do list example on CodeSandbox: Edit todo-list-client From b1aeb55e34ad4660b79a09f53bced977351771f1 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Wed, 19 May 2021 16:16:45 -0400 Subject: [PATCH 340/380] Some improvements to the `update` function section. --- docs/source/data/mutations.mdx | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/docs/source/data/mutations.mdx b/docs/source/data/mutations.mdx index 6d6890fa138..fd9f9213dc0 100644 --- a/docs/source/data/mutations.mdx +++ b/docs/source/data/mutations.mdx @@ -171,20 +171,15 @@ In most cases, a mutation response should include any object(s) the mutation mod Upon receiving this response object, Apollo Client caches it with key `Todo:5`. If a cached object _already_ exists with this key, Apollo Client overwrites any existing fields that are also included in the mutation response (other existing fields are preserved). -Returning modified objects like this is a helpful first step to keeping your cache in sync with your backend. However, it isn't always sufficient. For example, a newly cached object isn't automatically added to any _list fields_ that should now include that object. To accomplish this, you can define an **update function**. +Returning modified objects like this is a helpful first step to keeping your cache in sync with your backend. However, it isn't always sufficient. For example, a newly cached object isn't automatically added to any _list fields_ that should now include that object. To accomplish this, you can define an `update` function. -### The update function +### The `update` function -If a mutation modifies multiple entities, or if it creates or deletes entities, the Apollo Client cache is _not_ automatically updated to reflect the result of the -mutation. To resolve this, your call to `useMutation` can include an **update function**. +A single mutation can create, modify, or delete any number of different entities in your data graph. Often, the mutation will return some representation of these updated entity objects as part of its result data. Apollo Client will automatically write these entity objects into the cache, which is sometimes enough to broadcast appropriate updates to your application, but not always. -The purpose of an update function is to modify your _cached_ data to -match the modifications that a mutation makes to your _back-end_ -data. In the example in [Executing a mutation](#executing-a-mutation), the -update function for the `ADD_TODO` mutation should add the same item to our -cached version of the to-do list. +While you could refetch all your queries to find out what changed after each mutation, a much more efficient solution is to pass an `update` function to `useMutation` to apply manual changes to your cached data, to match whatever modifications the mutation made to your back-end data. -The following sample illustrates defining an update function in a call to `useMutation`: +The following sample illustrates defining an `update` function in a call to `useMutation`: ```jsx const GET_TODOS = gql` @@ -239,17 +234,19 @@ function AddTodo() { } ``` -As shown, the update function is passed a `cache` object that represents the Apollo Client cache. This object provides access to cache API methods like `readQuery`, `writeQuery`, `readFragment`, `writeFragment` and `modify`. These methods enable you to execute GraphQL operations on the cache as though you're interacting with a GraphQL server. +As shown, the `update` function is passed a `cache` object that represents the Apollo Client cache. This object provides access to cache API methods like `readQuery`, `writeQuery`, `readFragment`, `writeFragment`, `evict` and `modify`. These methods enable you to execute GraphQL operations on the cache as though you're interacting with a GraphQL server. > Learn more about supported cache functions in [Interacting with cached data](../caching/cache-interaction/). -The update function is _also_ passed an object with a `data` property that contains the result of the mutation. You can use this value to update the cache with `cache.writeQuery`, `cache.writeFragment` or `cache.modify`. +The `update` function is _also_ passed an object with a `data` property that contains the result of the mutation. You can use this value to update the cache with `cache.writeQuery`, `cache.writeFragment` or `cache.modify`. > If your mutation specifies an [optimistic response](../performance/optimistic-ui/), your update function is called **twice**: once with the optimistic result, and again with the actual result of the mutation when it returns. -When the `ADD_TODO` mutation is run in the above example, the newly added and returned todo object is saved into the cache. The previously cached list of todos, being watched by the `GET_TODOS` query, is not automatically updated however. This means the `GET_TODOS` query isn't notified that a new todo was added, which then means the query will not update to show the new todo. To address this we're using `cache.modify` which gives us a way to surgically insert or delete items from the cache, by running modifier functions. In the example above we know the results of the `GET_TODOS` query are stored in the `ROOT_QUERY.todos` array in the cache, so we're using a `todos` modifier function to update the cached array to include a reference to the newly added todo. With the help of `cache.writeFragment` we get an internal reference to the added todo, then store that reference in the `ROOT_QUERY.todos` array. +When the `ADD_TODO` mutation is run in the above example, the newly added and returned `addTodo` object is automatically saved into the cache before the `update` function runs. The previously cached list of `ROOT_QUERY.todos`, being watched by the `GET_TODOS` query, is not automatically updated. This means the `GET_TODOS` query isn't notified that a new todo was added, which then means the query will not update to show the new todo. -Any changes you make to cached data inside of an update function are automatically broadcast to queries that are listening for changes to that data. Consequently, your application's UI will update to reflect newly cached values. +To address this we're using `cache.modify` which gives us a way to surgically insert or delete items from the cache, by running "modifier" functions. In the example above we know the results of the `GET_TODOS` query are stored in the `ROOT_QUERY.todos` array in the cache, so we're using a `todos` modifier function to update the cached array to include a reference to the newly added todo. With the help of `cache.writeFragment` we get an internal reference to the added todo, then append that reference to the `ROOT_QUERY.todos` array. + +Any changes you make to cached data inside of an update function are automatically broadcast to queries that are listening for changes to that data. Consequently, your application's UI will update to reflect these updated cached values. ## `useMutation` API From 88cc3a5869ed0cf1a82c11e89f85d8599d5f0468 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Wed, 19 May 2021 16:20:03 -0400 Subject: [PATCH 341/380] Elaborate on "GraphQL string" terminology. --- docs/source/data/mutations.mdx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/source/data/mutations.mdx b/docs/source/data/mutations.mdx index fd9f9213dc0..323368507ba 100644 --- a/docs/source/data/mutations.mdx +++ b/docs/source/data/mutations.mdx @@ -23,8 +23,7 @@ This article also assumes that you've already set up Apollo Client and have wrap The `useMutation` [React hook](https://reactjs.org/docs/hooks-intro.html) is the primary API for executing mutations in an Apollo application. -To run a mutation, you first call `useMutation` within a React component and pass it a GraphQL string that represents the mutation: - +To run a mutation, you first call `useMutation` within a React component and pass it `MY_MUTATION`, which is a GraphQL document parsed from a string by the `gql` function: ```jsx{8}:title=my-component.jsx import { gql, useMutation } from '@apollo/client'; From abc05b2372518fb730ae6c7958832a3e1b113eb3 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Wed, 19 May 2021 16:22:27 -0400 Subject: [PATCH 342/380] More `code` formatting for `update` functions. --- docs/source/data/mutations.mdx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/source/data/mutations.mdx b/docs/source/data/mutations.mdx index 323368507ba..877f7f05f3d 100644 --- a/docs/source/data/mutations.mdx +++ b/docs/source/data/mutations.mdx @@ -239,13 +239,13 @@ As shown, the `update` function is passed a `cache` object that represents the A The `update` function is _also_ passed an object with a `data` property that contains the result of the mutation. You can use this value to update the cache with `cache.writeQuery`, `cache.writeFragment` or `cache.modify`. -> If your mutation specifies an [optimistic response](../performance/optimistic-ui/), your update function is called **twice**: once with the optimistic result, and again with the actual result of the mutation when it returns. +> If your mutation specifies an [optimistic response](../performance/optimistic-ui/), your `update` function is called **twice**: once with the optimistic result, and again with the actual result of the mutation when it returns. When the `ADD_TODO` mutation is run in the above example, the newly added and returned `addTodo` object is automatically saved into the cache before the `update` function runs. The previously cached list of `ROOT_QUERY.todos`, being watched by the `GET_TODOS` query, is not automatically updated. This means the `GET_TODOS` query isn't notified that a new todo was added, which then means the query will not update to show the new todo. To address this we're using `cache.modify` which gives us a way to surgically insert or delete items from the cache, by running "modifier" functions. In the example above we know the results of the `GET_TODOS` query are stored in the `ROOT_QUERY.todos` array in the cache, so we're using a `todos` modifier function to update the cached array to include a reference to the newly added todo. With the help of `cache.writeFragment` we get an internal reference to the added todo, then append that reference to the `ROOT_QUERY.todos` array. -Any changes you make to cached data inside of an update function are automatically broadcast to queries that are listening for changes to that data. Consequently, your application's UI will update to reflect these updated cached values. +Any changes you make to cached data inside of an `update` function are automatically broadcast to queries that are listening for changes to that data. Consequently, your application's UI will update to reflect these updated cached values. ## `useMutation` API @@ -277,4 +277,4 @@ you can begin to take advantage of Apollo Client's full feature set, including: - [Optimistic UI](../performance/optimistic-ui/): Learn how to improve perceived performance by returning an optimistic response before your mutation result comes back from the server. - [Local state](../local-state/local-state-management/): Use Apollo Client to manage the entirety of your application's local state by executing client-side mutations. -- [Caching in Apollo](../caching/cache-configuration/): Dive deep into the Apollo Client cache and how it's normalized. Understanding the cache is helpful when writing update functions for your mutations! +- [Caching in Apollo](../caching/cache-configuration/): Dive deep into the Apollo Client cache and how it's normalized. Understanding the cache is helpful when writing `update` functions for your mutations! From 0876f8a547088cf3f8c1379d3a7d366b93156ab2 Mon Sep 17 00:00:00 2001 From: Stephen Barlow Date: Mon, 7 Jun 2021 15:37:35 -0700 Subject: [PATCH 343/380] Minor edits in leadup to proper refetch docs --- docs/source/data/mutations.mdx | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/docs/source/data/mutations.mdx b/docs/source/data/mutations.mdx index 877f7f05f3d..23de8f384bd 100644 --- a/docs/source/data/mutations.mdx +++ b/docs/source/data/mutations.mdx @@ -8,7 +8,7 @@ import MutationResult from '../../shared/mutation-result.mdx'; Now that we've [learned how to query data](queries/) from our backend with Apollo Client, the natural next step is to learn how to _modify_ back-end data with **mutations**. -This article demonstrates how to send updates to your GraphQL server with the `useMutation` hook. You'll also learn how to update the Apollo Client cache after executing a mutation, and how to track loading and error states for a mutation. +This article demonstrates how to send updates to your GraphQL server with the `useMutation` hook. You'll also learn how to update the Apollo Client cache after executing a mutation, and how to track loading and error states. > To follow along with the examples below, open up our [starter project](https://codesandbox.io/s/mutations-example-app-start-gm7i5) and [sample GraphQL server](https://codesandbox.io/s/mutations-example-app-server-sxewr) on CodeSandbox. You can view the completed version of the app [here](https://codesandbox.io/s/mutations-example-app-final-tjoje). @@ -23,24 +23,31 @@ This article also assumes that you've already set up Apollo Client and have wrap The `useMutation` [React hook](https://reactjs.org/docs/hooks-intro.html) is the primary API for executing mutations in an Apollo application. -To run a mutation, you first call `useMutation` within a React component and pass it `MY_MUTATION`, which is a GraphQL document parsed from a string by the `gql` function: +To execute a mutation, you first call `useMutation` within a React component and pass it the mutation you want to execute, like so: -```jsx{8}:title=my-component.jsx +```jsx{13}:title=my-component.jsx import { gql, useMutation } from '@apollo/client'; -const MY_MUTATION = gql` - # Define mutation here +// Define mutation +const INCREMENT_COUNTER = gql` + # Increments a back-end counter and gets its resulting value + mutation IncrementCounter { + currentValue + } `; function MyComponent() { - const [mutateFunction, { data, loading, error }] = useMutation(MY_MUTATION); + // Pass mutation to useMutation + const [mutateFunction, { data, loading, error }] = useMutation(INCREMENT_COUNTER); } ``` - When your component renders, `useMutation` returns a tuple that includes: +As shown above, you use the `gql` function to parse the mutation string into a GraphQL document that you then pass to `useMutation`. + +When your component renders, `useMutation` returns a tuple that includes: * A **mutate function** that you can call at any time to execute the mutation - * Unlike `useQuery`, `useMutation` _doesn't_ execute its operation automatically. Instead, you call the mutate function. + * Unlike `useQuery`, `useMutation` _doesn't_ execute its operation automatically on render. Instead, you call the mutate function. * An object with fields that represent the current status of the mutation's execution (`data`, `loading`, etc.) * This object is similar to the object returned by the `useQuery` hook. For details, see [Result](#result). @@ -152,7 +159,7 @@ TODO TODO -### Include modified objects in mutation responses +#### Include modified objects in mutation responses In most cases, a mutation response should include any object(s) the mutation modified. This enables Apollo Client to normalize those objects and cache them according to their `id` and `__typename` fields ([by default](../caching/cache-configuration/#generating-unique-identifiers)). @@ -172,7 +179,7 @@ Upon receiving this response object, Apollo Client caches it with key `Todo:5`. Returning modified objects like this is a helpful first step to keeping your cache in sync with your backend. However, it isn't always sufficient. For example, a newly cached object isn't automatically added to any _list fields_ that should now include that object. To accomplish this, you can define an `update` function. -### The `update` function +#### The `update` function A single mutation can create, modify, or delete any number of different entities in your data graph. Often, the mutation will return some representation of these updated entity objects as part of its result data. Apollo Client will automatically write these entity objects into the cache, which is sometimes enough to broadcast appropriate updates to your application, but not always. From c71c92a09286ac32f80d20aa9c7f254d733f991e Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Mon, 7 Jun 2021 19:30:37 -0400 Subject: [PATCH 344/380] Section: Refetching queries --- docs/source/data/mutations.mdx | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/docs/source/data/mutations.mdx b/docs/source/data/mutations.mdx index 23de8f384bd..933273b44eb 100644 --- a/docs/source/data/mutations.mdx +++ b/docs/source/data/mutations.mdx @@ -153,7 +153,28 @@ If you're just getting started with Apollo Client, we recommend refetching queri ### Refetching queries -TODO +If you happen to know that certain queries always (or usually) need to be refetched from the network after a particular kind of mutation, you can identify them by passing a `refetchQueries: [...]` array in the mutation options. + +Typically, the elements of the `refetchQueries` array will be the string names of queries you want to refetch. Remember to give your queries unique names if you want to refer to them this way: + +```js +const [addTodo, { data, loading, error }] = useMutation(ADD_TODO, { + refetchQueries: ["GetTodos"], +}); +``` + +You can also specify queries using a `DocumentNode` object, or pass `refetchQueries` to the `addTodo` mutate function, instead of passing it to `useMutation`: + +```js +addTodo({ + variables: { + text: input.value, + }, + refetchQueries: [GET_TODOS], +}) +``` + +With only one query to refetch, this manual specification of `refetchQueries` may not seem very difficult. However, in larger applications, it can become tricky to guess which queries should be updated after an arbitrary mutation. ### Updating the cache directly From bdced7cf980c6ca4612e7708322a756d40133cde Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Mon, 7 Jun 2021 19:41:53 -0400 Subject: [PATCH 345/380] Use variables as example in useMutation options section. --- docs/source/data/mutations.mdx | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/docs/source/data/mutations.mdx b/docs/source/data/mutations.mdx index 933273b44eb..eceb4a73c71 100644 --- a/docs/source/data/mutations.mdx +++ b/docs/source/data/mutations.mdx @@ -106,11 +106,14 @@ In this example, our `form`'s `onSubmit` handler calls the **mutate function** ( ### Providing options -The `useMutation` hook accepts an `options` object as its second parameter. Here's an example that provides the `onCompleted` option to take an action after the mutation completes successfully: +The `useMutation` hook accepts an `options` object as its second parameter. Here's an example that provides some default `variables`: ```js const [addTodo, { data, loading, error }] = useMutation(ADD_TODO, { - onCompleted: (data) => console.log('Complete!') + variables: { + text: "placeholder", + someOtherVariable: 1234, + }, }); ``` @@ -119,12 +122,16 @@ const [addTodo, { data, loading, error }] = useMutation(ADD_TODO, { You can _also_ provide these options to your mutate function, as demonstrated in [the example above](#example): ```js -addTodo({ variables: { text: input.value } }); +addTodo({ + variables: { + text: input.value, + }, +}); ``` Here, we're using the `variables` option to provide the values of any GraphQL variables that our mutation requires (specifically, the `text` of the created to-do item). -> If you provide the same option to both `useMutation` _and_ your mutate function, the value you provide your mutate function takes precedence. +If you provide the same option to both `useMutation` _and_ your mutate function, the value you provide your mutate function takes precedence. In this case, `variables.text` will end up equal to `input.value`, overriding the `"placeholder"` text. The two `variables` objects are merged shallowly, so `variables.someOtherVariable` is preserved. This preservation is convenient, since it means passing `variables` to the mutate function won't discard other `variables` you passed to the `useMutation` function. ### Tracking mutation status From 7ef351aafcc252d3fb3525439fd2978a34228148 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Mon, 7 Jun 2021 21:37:45 -0400 Subject: [PATCH 346/380] New section: Refetching updated data --- docs/source/data/mutations.mdx | 58 ++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/docs/source/data/mutations.mdx b/docs/source/data/mutations.mdx index eceb4a73c71..e158dd0bd94 100644 --- a/docs/source/data/mutations.mdx +++ b/docs/source/data/mutations.mdx @@ -282,6 +282,64 @@ To address this we're using `cache.modify` which gives us a way to surgically in Any changes you make to cached data inside of an `update` function are automatically broadcast to queries that are listening for changes to that data. Consequently, your application's UI will update to reflect these updated cached values. +#### Refetching `update`d data + +The goal of the `update` function is to simulate the effects of a mutation locally, even though most mutations really take place on a remote server. If the `update` function succeeds in simulating the authoritative server-side changes, then your users won't have to await another network round-trip to see the latest data. + +In other words, just as an [optimistic response](../performance/optimistic-ui/) represents an informed guess about the _response data_ a mutation will return, the `update` function represents an informed guess about any _indirect effects_ that mutation might have. + +Both of these guesses could be slightly wrong in some cases. The `optimisticResponse` eventually gets superseded by the real mutation response, but what can you do to check the guesswork of your `update` function? + +> Thankfully, although GraphQL mutations can have arbitrary effects on the server, a GraphQL client only needs to worry about mutation effects that can be later observed by refetching GraphQL queries. + +Normally, when the `update` function succeeds, the cache is modified, and those modifications are broadcast to the application, which rerenders a few of its components, without needing to refetch the affected queries from the network. + +If you knew which currently-active queries were affected by the `update` function, then perhaps you could refetch just those queries from the network, behind the scenes, updating the UI only if the network data turns out to be different from what your `update` function produced, in effect double-checking the work of the `update` function. + +That's where `onQueryUpdated` comes in handy: + +```js +addTodo({ + variables: { type: input.value }, + update(cache, result) { + // Update the cache as an approximation of server-side mutation effects. + }, + onQueryUpdated(observableQuery) { + // Provide your own dynamic logic for controlling refetching behavior. + if (shouldRefetchQuery(observableQuery)) { + return observableQuery.refetch(); + } + }, +}) +``` + +If the `update` function triggers changes to the results of any queries, those queries will be passed to the `onQueryUpdated` function, represented by `ObservableQuery` objects. If you decide to `refetch` a query to make sure the `update` function guessed correctly, you can do that by returning `observableQuery.refetch()` from the `onQueryUpdated` function, as shown above. + +The beauty of this system is that the UI should not need to rerender as long as your optimistic guesses (both `optimisticResponse` and the `update` function) were accurate. + +Occasionally, it might be difficult to make your `update` function update all relevant queries. Not every mutation returns enough information for the `update` function to do its job effectively. To make absolutely sure a certain query is included, you can combine `onQueryUpdated` with `refetchQueries: [...]`: + +```js +addTodo({ + variables: { type: input.value }, + update(cache, result) { + // Update the cache as an approximation of server-side mutation effects. + }, + // Force ReallyImportantQuery to be passed to onQueryUpdated. + refetchQueries: ["ReallyImportantQuery"], + onQueryUpdated(observableQuery) { + // If ReallyImportantQuery is active, it will be passed to onQueryUpdated. + // If no query with that name is active, a warning will be logged. + }, +}) +``` + +If `ReallyImportantQuery` was already going to be passed to `onQueryUpdated` thanks of your `update` function, then it will only be passed once. Using `refetchQueries: ["ReallyImportantQuery"]` just guarantees the query will be included. + +If you find you've included more queries than you expected, you can skip or ignore a query by returning `false` from `onQueryUpdated`, after examining the `ObservableQuery` to determine that it doesn't need refetching. Returning a `Promise` from `onQueryUpdated` causes the final `Promise>` for the mutation to await any promises returned from `onQueryUpated`, eliminating the need for the legacy `awaitRefetchQueries: true` option. + +To use the `onQueryUpdated` API without performing a mutation, try the [`client.refetchQueries`](#TODO) method. In the standalone `client.refetchQueries` API, the `refetchQueries: [...]` mutation option is called `include: [...]`, and the `update` function is called `updateCache` for clarity. Otherwise, the same internal system powers both `client.refetchQueries` and refetching queries after a mutation. + ## `useMutation` API Supported options and result fields for the `useMutation` hook are listed below. From eafaff8cc69b3a423ea9e5d93571105694942abe Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Fri, 9 Jul 2021 17:01:44 -0400 Subject: [PATCH 347/380] Add stub onQueryUpdated section to . --- docs/shared/mutation-options.mdx | 25 ++++++++++++++++++++++++- src/core/ApolloClient.ts | 1 - 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/docs/shared/mutation-options.mdx b/docs/shared/mutation-options.mdx index de98b2ef855..a7dbc8f3dc6 100644 --- a/docs/shared/mutation-options.mdx +++ b/docs/shared/mutation-options.mdx @@ -101,6 +101,29 @@ This function is passed an [`ApolloError`](https://github.com/apollographql/apol + + + +###### `onQueryUpdated` + +```ts +( + observableQuery: ObservableQuery, + diff: Cache.DiffResult, + lastDiff: Cache.DiffResult | undefined, +) => boolean | TResult +``` + + + + + +TODO + + + + + @@ -132,7 +155,7 @@ Each array value can be either: -If `true`, makes sure all queries included in `refetchQueries` are completed before the mutation is considered complete. +If `true`, makes sure all queries included in `refetchQueries` are completed before the mutation is considered complete. The default value is `false` (queries are refetched asynchronously). diff --git a/src/core/ApolloClient.ts b/src/core/ApolloClient.ts index 17e068ed8b2..d620b1d0104 100644 --- a/src/core/ApolloClient.ts +++ b/src/core/ApolloClient.ts @@ -536,7 +536,6 @@ export class ApolloClient implements DataProxy { * their queries again using your network interface. If you do not want to * re-execute any queries then you should make sure to stop watching any * active queries. - * Takes optional parameter `includeStandby` which will include queries in standby-mode when refetching. */ public refetchQueries< TCache extends ApolloCache = ApolloCache, From 4d5e4107fe7ff269e063330f951960c590cd6df1 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 13 Jul 2021 10:30:37 -0400 Subject: [PATCH 348/380] Basic initial explanation of onQueryUpdated mutation option. --- docs/shared/mutation-options.mdx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/shared/mutation-options.mdx b/docs/shared/mutation-options.mdx index a7dbc8f3dc6..a3baf82fde2 100644 --- a/docs/shared/mutation-options.mdx +++ b/docs/shared/mutation-options.mdx @@ -118,7 +118,9 @@ This function is passed an [`ApolloError`](https://github.com/apollographql/apol -TODO +Optional callback for intercepting queries whose cache data has been updated by the mutation, as well as any queries specified in the [`refetchQueries: [...]`](#refetchQueries) list passed to `client.mutate`. + +Returning a `Promise` from `onQueryUpdated` will cause the final mutation `Promise` to await the returned `Promise`. Returning `false` causes the query to be ignored. From 40f5b37a1f97520af7f0dedbd3cbde002ffa34a4 Mon Sep 17 00:00:00 2001 From: Stephen Barlow Date: Tue, 13 Jul 2021 14:54:44 -0700 Subject: [PATCH 349/380] Show v3 shared MDX components instead of v2 --- docs/shared/mutation-options.mdx | 8 +------- docs/source/api/react/components.mdx | 24 ++++++++++++------------ docs/source/api/react/hooks.mdx | 28 ++++++++++++++-------------- docs/source/data/mutations.mdx | 8 ++++---- 4 files changed, 31 insertions(+), 37 deletions(-) diff --git a/docs/shared/mutation-options.mdx b/docs/shared/mutation-options.mdx index a3baf82fde2..1afa5e9ad85 100644 --- a/docs/shared/mutation-options.mdx +++ b/docs/shared/mutation-options.mdx @@ -106,13 +106,7 @@ This function is passed an [`ApolloError`](https://github.com/apollographql/apol ###### `onQueryUpdated` -```ts -( - observableQuery: ObservableQuery, - diff: Cache.DiffResult, - lastDiff: Cache.DiffResult | undefined, -) => boolean | TResult -``` +`(observableQuery: ObservableQuery, diff: Cache.DiffResult, lastDiff: Cache.DiffResult | undefined) => boolean | TResult` diff --git a/docs/source/api/react/components.mdx b/docs/source/api/react/components.mdx index ce564afa5b3..aa918890cbe 100644 --- a/docs/source/api/react/components.mdx +++ b/docs/source/api/react/components.mdx @@ -4,12 +4,12 @@ sidebar_title: 'Components (deprecated)' description: Deprecated React Apollo render prop component API --- -import QueryOptions from '../../../shared/query-options.mdx'; -import QueryResult from '../../../shared/query-result.mdx'; -import MutationOptions from '../../../shared/mutation-options.mdx'; -import MutationResult from '../../../shared/mutation-result.mdx'; -import SubscriptionOptions from '../../../shared/subscription-options.mdx'; -import SubscriptionResult from '../../../shared/subscription-result.mdx'; +import QueryOptions3 from '../../../shared/query-options.mdx'; +import QueryResult3 from '../../../shared/query-result.mdx'; +import MutationOptions3 from '../../../shared/mutation-options.mdx'; +import MutationResult3 from '../../../shared/mutation-result.mdx'; +import SubscriptionOptions3 from '../../../shared/subscription-options.mdx'; +import SubscriptionResult3 from '../../../shared/subscription-result.mdx'; > **Note:** Official support for React Apollo render prop components ended in March 2020. This library is still included in the `@apollo/client` package, but it no longer receives feature updates or bug fixes. @@ -29,25 +29,25 @@ You then import the library's symbols from `@apollo/client/react/components`. The `Query` component accepts the following props. `query` is **required**. - + ### Render prop function The render prop function that you pass to the `children` prop of `Query` is called with an object (`QueryResult`) that has the following properties. This object contains your query result, plus some helpful functions for refetching, dynamic polling, and pagination. - + ## `Mutation` The Mutation component accepts the following props. Only `mutation` is **required**. - + ### Render prop function The render prop function that you pass to the `children` prop of `Mutation` is called with the `mutate` function and an object with the mutation result. The `mutate` function is how you trigger the mutation from your UI. The object contains your mutation result, plus loading and error state. - + ## `Subscription` @@ -55,10 +55,10 @@ The render prop function that you pass to the `children` prop of `Mutation` is c The Subscription component accepts the following props. Only `subscription` is **required**. - + ### Render prop function The render prop function that you pass to the `children` prop of `Subscription` is called with an object that has the following properties. - + diff --git a/docs/source/api/react/hooks.mdx b/docs/source/api/react/hooks.mdx index 50b7390ef98..6e81a435eab 100644 --- a/docs/source/api/react/hooks.mdx +++ b/docs/source/api/react/hooks.mdx @@ -3,12 +3,12 @@ title: Hooks description: Apollo Client react hooks API reference --- -import QueryOptions from '../../../shared/query-options.mdx'; -import QueryResult from '../../../shared/query-result.mdx'; -import MutationOptions from '../../../shared/mutation-options.mdx'; -import MutationResult from '../../../shared/mutation-result.mdx'; -import SubscriptionOptions from '../../../shared/subscription-options.mdx'; -import SubscriptionResult from '../../../shared/subscription-result.mdx'; +import QueryOptions3 from '../../../shared/query-options.mdx'; +import QueryResult3 from '../../../shared/query-result.mdx'; +import MutationOptions3 from '../../../shared/mutation-options.mdx'; +import MutationResult3 from '../../../shared/mutation-result.mdx'; +import SubscriptionOptions3 from '../../../shared/subscription-options.mdx'; +import SubscriptionResult3 from '../../../shared/subscription-result.mdx'; ## Installation @@ -106,11 +106,11 @@ function useQuery( #### `options` - + ### Result - + ## `useLazyQuery` @@ -164,7 +164,7 @@ function useLazyQuery( #### `options` - + ### Result @@ -176,7 +176,7 @@ function useLazyQuery( **Result object** - + ## `useMutation` @@ -240,11 +240,11 @@ function useMutation( #### `options` - + ### Result - + ## `useSubscription` @@ -295,11 +295,11 @@ function useSubscription( #### `options` - + ### Result - + ## `useApolloClient` diff --git a/docs/source/data/mutations.mdx b/docs/source/data/mutations.mdx index e158dd0bd94..617c1b9cdb8 100644 --- a/docs/source/data/mutations.mdx +++ b/docs/source/data/mutations.mdx @@ -3,8 +3,8 @@ title: Mutations description: Modify data with the useMutation hook --- -import MutationOptions from '../../shared/mutation-options.mdx'; -import MutationResult from '../../shared/mutation-result.mdx'; +import MutationOptions3 from '../../shared/mutation-options.mdx'; +import MutationResult3 from '../../shared/mutation-result.mdx'; Now that we've [learned how to query data](queries/) from our backend with Apollo Client, the natural next step is to learn how to _modify_ back-end data with **mutations**. @@ -352,7 +352,7 @@ detail with usage examples, see the [API reference](../api/react/hooks/). The `useMutation` hook accepts the following options: - + ### Result @@ -360,7 +360,7 @@ The `useMutation` result is a tuple with a mutate function in the first position You call the mutate function to trigger the mutation from your UI. - + ## Next steps From 3ec5631c31157cbdfedd882b1fbb02a22758af5d Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Thu, 15 Jul 2021 16:22:50 -0400 Subject: [PATCH 350/380] Remove broken #TODO link to fix Gatsby preview builds. --- docs/source/data/mutations.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/data/mutations.mdx b/docs/source/data/mutations.mdx index 617c1b9cdb8..a40f344bfaa 100644 --- a/docs/source/data/mutations.mdx +++ b/docs/source/data/mutations.mdx @@ -338,7 +338,7 @@ If `ReallyImportantQuery` was already going to be passed to `onQueryUpdated` tha If you find you've included more queries than you expected, you can skip or ignore a query by returning `false` from `onQueryUpdated`, after examining the `ObservableQuery` to determine that it doesn't need refetching. Returning a `Promise` from `onQueryUpdated` causes the final `Promise>` for the mutation to await any promises returned from `onQueryUpated`, eliminating the need for the legacy `awaitRefetchQueries: true` option. -To use the `onQueryUpdated` API without performing a mutation, try the [`client.refetchQueries`](#TODO) method. In the standalone `client.refetchQueries` API, the `refetchQueries: [...]` mutation option is called `include: [...]`, and the `update` function is called `updateCache` for clarity. Otherwise, the same internal system powers both `client.refetchQueries` and refetching queries after a mutation. +To use the `onQueryUpdated` API without performing a mutation, try the `client.refetchQueries` method. In the standalone `client.refetchQueries` API, the `refetchQueries: [...]` mutation option is called `include: [...]`, and the `update` function is called `updateCache` for clarity. Otherwise, the same internal system powers both `client.refetchQueries` and refetching queries after a mutation. ## `useMutation` API From c5bf8529e893867bb712a7d209e766a0987557b2 Mon Sep 17 00:00:00 2001 From: Stephen Barlow Date: Mon, 19 Jul 2021 14:21:38 -0700 Subject: [PATCH 351/380] Minor improvements to mutations article --- docs/shared/mutation-options.mdx | 4 +++- docs/source/data/mutations.mdx | 34 +++++++++++++++----------------- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/docs/shared/mutation-options.mdx b/docs/shared/mutation-options.mdx index 1afa5e9ad85..f055b993ad1 100644 --- a/docs/shared/mutation-options.mdx +++ b/docs/shared/mutation-options.mdx @@ -27,7 +27,9 @@ A GraphQL query string parsed into an AST with the `gql` template literal. -**Optional** for the `useMutation` hook, because the mutation can be provided as the first parameter to the hook. **Required** for the `Mutation` component. +**Optional** for the `useMutation` hook, because the mutation can also be provided as the first parameter to the hook. + +**Required** for the `Mutation` component. diff --git a/docs/source/data/mutations.mdx b/docs/source/data/mutations.mdx index a40f344bfaa..f3fd00b6981 100644 --- a/docs/source/data/mutations.mdx +++ b/docs/source/data/mutations.mdx @@ -1,5 +1,6 @@ --- -title: Mutations +title: Mutations in Apollo Client +sidebar_title: Mutations description: Modify data with the useMutation hook --- @@ -106,7 +107,7 @@ In this example, our `form`'s `onSubmit` handler calls the **mutate function** ( ### Providing options -The `useMutation` hook accepts an `options` object as its second parameter. Here's an example that provides some default `variables`: +The `useMutation` hook accepts an `options` object as its second parameter. Here's an example that provides some default values for GraphQL `variables`: ```js const [addTodo, { data, loading, error }] = useMutation(ADD_TODO, { @@ -119,7 +120,7 @@ const [addTodo, { data, loading, error }] = useMutation(ADD_TODO, { > All supported options are listed in [Options](#options). -You can _also_ provide these options to your mutate function, as demonstrated in [the example above](#example): +You can _also_ provide options directly to your mutate function, as demonstrated in this snippet from [the example above](#example): ```js addTodo({ @@ -129,9 +130,13 @@ addTodo({ }); ``` -Here, we're using the `variables` option to provide the values of any GraphQL variables that our mutation requires (specifically, the `text` of the created to-do item). +Here, we use the `variables` option to provide the values of any GraphQL variables that our mutation requires (specifically, the `text` of the created to-do item). -If you provide the same option to both `useMutation` _and_ your mutate function, the value you provide your mutate function takes precedence. In this case, `variables.text` will end up equal to `input.value`, overriding the `"placeholder"` text. The two `variables` objects are merged shallowly, so `variables.someOtherVariable` is preserved. This preservation is convenient, since it means passing `variables` to the mutate function won't discard other `variables` you passed to the `useMutation` function. +#### Option precedence + +If you provide the same option to both `useMutation` _and_ your mutate function, the mutate function's value takes precedence. In the specific case of the `variables` option, the two objects are merged _shallowly_, which means any variables provided only to `useMutation` are preserved in the resulting object. This helps you set default values for variables. + +In [the example snippets above](#providing-options), `input.value` would override `"placeholder"` as the value of the `text` variable. The value of `someOtherVariable` (`1234`) would be preserved. ### Tracking mutation status @@ -160,28 +165,21 @@ If you're just getting started with Apollo Client, we recommend refetching queri ### Refetching queries -If you happen to know that certain queries always (or usually) need to be refetched from the network after a particular kind of mutation, you can identify them by passing a `refetchQueries: [...]` array in the mutation options. +If you know that your app usually needs to refetch certain queries after a particular mutation, you can include a `refetchQueries` array in that mutation's options. -Typically, the elements of the `refetchQueries` array will be the string names of queries you want to refetch. Remember to give your queries unique names if you want to refer to them this way: +Usually, the elements of the `refetchQueries` array are the string names of the queries you want to refetch (e.g., `"GetTodos"`). The array can also include `DocumentNode` objects parsed with the `gql` function: ```js const [addTodo, { data, loading, error }] = useMutation(ADD_TODO, { - refetchQueries: ["GetTodos"], + refetchQueries: ["GetTodos", MY_OTHER_QUERY], }); ``` -You can also specify queries using a `DocumentNode` object, or pass `refetchQueries` to the `addTodo` mutate function, instead of passing it to `useMutation`: +> To refer to queries by their string name, make sure each of your app's queries has a _unique_ name. -```js -addTodo({ - variables: { - text: input.value, - }, - refetchQueries: [GET_TODOS], -}) -``` +You can provide the `refetchQueries` option either to `useMutation` or to the mutate function. For details, see [Option precedence](#option-precedence). -With only one query to refetch, this manual specification of `refetchQueries` may not seem very difficult. However, in larger applications, it can become tricky to guess which queries should be updated after an arbitrary mutation. +Note that in an app with tens or hundreds of different queries, it becomes much more challenging to determine exactly which queries to refetch after a particular mutation. ### Updating the cache directly From 6426a7224dbfe37ac6a5e967dab420822b18ba88 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Mon, 19 Jul 2021 16:45:30 -0400 Subject: [PATCH 352/380] Add a Refetching docs page under Fetching. --- docs/gatsby-config.js | 1 + docs/shared/refetchQueries-options.mdx | 103 +++++++++++++++++++++++++ docs/source/data/refetching.mdx | 31 ++++++++ 3 files changed, 135 insertions(+) create mode 100644 docs/shared/refetchQueries-options.mdx create mode 100644 docs/source/data/refetching.mdx diff --git a/docs/gatsby-config.js b/docs/gatsby-config.js index 830114cb651..eec014f0367 100644 --- a/docs/gatsby-config.js +++ b/docs/gatsby-config.js @@ -31,6 +31,7 @@ module.exports = { 'Fetching': [ 'data/queries', 'data/mutations', + 'data/refetching', 'data/subscriptions', 'data/fragments', 'data/error-handling', diff --git a/docs/shared/refetchQueries-options.mdx b/docs/shared/refetchQueries-options.mdx new file mode 100644 index 00000000000..bd3090bcf75 --- /dev/null +++ b/docs/shared/refetchQueries-options.mdx @@ -0,0 +1,103 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Name /
Type
Description
+ +```ts +interface RefetchQueriesOptions< + TCache extends ApolloCache, + TResult, +> { +``` + +
+ +###### `updateCache` + +`(cache: TCache) => void` + + +Optional function that updates the cache as a way of triggering refetches of +queries whose data were affected by those cache updates. + +
+ +###### `include` + +`Array | "all" | "active"` + + +Optional array specifying the names or `DocumentNode` objects of queries to be +refetched. + +Analogous to the `options.refetchQueries` array for mutations. + +Pass `"active"` (or `"all"`) as a shorthand to refetch all (active) queries. + +
+ +###### `onQueryUpdated` + +`(observableQuery: ObservableQuery, diff: Cache.DiffResult, lastDiff: Cache.DiffResult | undefined) => boolean | TResult` + + + +Optional callback function that will be called for each `ObservableQuery` +affected by `options.updateCache` or specified by `options.include`. + +If `onQueryUpdated` is not provided, the default implementation returns the +result of calling `observableQuery.refetch()`. When `onQueryUpdated` is +provided, it can dynamically decide whether and how each query should be +refetched. Returning `false` from `onQueryUpdated` will prevent the given query +from being refetched. + +
+ +###### `optimistic` + +`boolean` + + +If `true`, run `options.updateCache` in a temporary optimistic layer of +`InMemoryCache`, so its modifications can be discarded from the cache after +observing which fields it invalidated. + +Defaults to `false`, meaning `options.updateCache` updates the cache in a +lasting way. + +
diff --git a/docs/source/data/refetching.mdx b/docs/source/data/refetching.mdx new file mode 100644 index 00000000000..24537e77b30 --- /dev/null +++ b/docs/source/data/refetching.mdx @@ -0,0 +1,31 @@ +--- +title: Refetching Queries +sidebar_title: Refetching +--- + +import RefetchQueriesOptions from '../../shared/refetchQueries-options.mdx'; + +Although Apollo Client allows you to make local modifications to previously +received GraphQL data by updating the cache, sometimes the easiest way to update +your client-side GraphQL data is to _refetch_ it from the server. + +In principle, you could refetch every active query after any client-side update, +but you can save time and network bandwidth by refetching queries more +selectively, taking advantage of `InMemoryCache` to determine which watched +queries may have been invalidated by recent cache updates. + +These two approaches (local updates and refetching) work well together: if your +application displays the results of local cache modifications immediately, you +can use refetching in the background to obtain the very latest data from the +server, rerendering the UI only if there are differences between local data and +refetched data. + +Refetching is especially common after a mutation, so `client.mutate` accepts +options like `refetchQueries` and `onQueryUpdated` to specify which queries +should be refetched, and how. However, the tools of selective refetching are +available even if you are not performing a mutation, in the form of the +`client.refetchQueries` method. + +## `client.refetchQueries` + + From 770e020d38ae5bab5f9ad725d0be2cc119bd43ff Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Mon, 19 Jul 2021 20:00:11 -0400 Subject: [PATCH 353/380] Add a brief explanation of client.refetchQueries results. --- docs/source/data/refetching.mdx | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/docs/source/data/refetching.mdx b/docs/source/data/refetching.mdx index 24537e77b30..83bc7ba356a 100644 --- a/docs/source/data/refetching.mdx +++ b/docs/source/data/refetching.mdx @@ -28,4 +28,28 @@ available even if you are not performing a mutation, in the form of the ## `client.refetchQueries` +### Refetch options + + +### Refetch results + +The `client.refetchQueries` method collects the `TResult` results returned by +`onQueryUpdated`, defaulting to `TResult = Promise>` if +`onQueryUpdated` is not provided, and combines the results into a single +`Promise` using `Promise.all(results)`. + +> Thanks to the `Promise`-unwrapping behavior of `Promise.all`, `TResolved` will +be the same type as `TResult` except when `TResult` is a +`PromiseLike` or a `boolean`. + +The returned `Promise` object has two other useful properties: + +| Property | Type | Description | +| - | - | - | +| `queries` | `ObservableQuery[]` | An array of `ObservableQuery` objects that were refetched | +| `results` | `TResult[]` | An array of results returned by `onQueryUpdated`, including pending promises | + +These two arrays are parallel: they have the same length, and `results[i]` is +the result returned by `onQueryUpdated` when it was called with the +`ObservableQuery` given by `queries[i]`, for any index `i`. From ed1c9eeaefea2b64226540421ae629795239c1e1 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Mon, 19 Jul 2021 20:24:00 -0400 Subject: [PATCH 354/380] Improve InternalRefetchQueriesResult generic type. --- src/core/QueryManager.ts | 8 ++++---- src/core/types.ts | 9 ++++++++- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index a30d1028555..bda834902c5 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -1238,7 +1238,7 @@ export class QueryManager { // options.include. includedQueriesById.delete(oq.queryId); - let result: boolean | InternalRefetchQueriesResult = + let result: TResult | boolean | Promise> = onQueryUpdated(oq, diff, lastDiff); if (result === true) { @@ -1250,7 +1250,7 @@ export class QueryManager { // Record the result in the results Map, as long as onQueryUpdated // did not return false to skip/ignore this result. if (result !== false) { - results.set(oq, result); + results.set(oq, result as InternalRefetchQueriesResult); } // Prevent the normal cache broadcast of this result, since we've @@ -1271,7 +1271,7 @@ export class QueryManager { if (includedQueriesById.size) { includedQueriesById.forEach(({ oq, lastDiff, diff }, queryId) => { - let result: undefined | boolean | InternalRefetchQueriesResult; + let result: TResult | boolean | Promise> | undefined; // If onQueryUpdated is provided, we want to use it for all included // queries, even the QueryOptions ones. @@ -1290,7 +1290,7 @@ export class QueryManager { } if (result !== false) { - results.set(oq, result!); + results.set(oq, result as InternalRefetchQueriesResult); } if (queryId.indexOf("legacyOneTimeQuery") >= 0) { diff --git a/src/core/types.ts b/src/core/types.ts index 83e0963cb2b..7d7adc73dc1 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -114,7 +114,14 @@ export interface InternalRefetchQueriesOptions< } export type InternalRefetchQueriesResult = - TResult | Promise>; + // If onQueryUpdated returns a boolean, that's equivalent to refetching the + // query when the boolean is true and skipping the query when false, so the + // internal type of refetched results is Promise>. + TResult extends boolean ? Promise> : + // Otherwise, onQueryUpdated returns whatever it returns. If onQueryUpdated is + // not provided, TResult defaults to Promise> (see the + // generic type parameters of client.refetchQueries). + TResult; export type InternalRefetchQueriesMap = Map, From 3a7576c15b1ddc959415b984c86476ea95f24c2b Mon Sep 17 00:00:00 2001 From: Stephen Barlow Date: Wed, 21 Jul 2021 16:51:16 -0700 Subject: [PATCH 355/380] Partial edits to direct cache updates following mutation --- docs/shared/mutation-options.mdx | 2 +- docs/source/data/mutations.mdx | 74 +++++++++++++------------------- 2 files changed, 31 insertions(+), 45 deletions(-) diff --git a/docs/shared/mutation-options.mdx b/docs/shared/mutation-options.mdx index f055b993ad1..132e01b2280 100644 --- a/docs/shared/mutation-options.mdx +++ b/docs/shared/mutation-options.mdx @@ -132,7 +132,7 @@ Returning a `Promise` from `onQueryUpdated` will cause the final mutation `Promi -An array or a function that _returns_ an array that specifies which queries you want to refetch after the mutation occurs. +An array (or a function that _returns_ an array) that specifies which queries you want to refetch after the mutation occurs. Each array value can be either: diff --git a/docs/source/data/mutations.mdx b/docs/source/data/mutations.mdx index f3fd00b6981..d64151dad6d 100644 --- a/docs/source/data/mutations.mdx +++ b/docs/source/data/mutations.mdx @@ -48,7 +48,7 @@ As shown above, you use the `gql` function to parse the mutation string into a G When your component renders, `useMutation` returns a tuple that includes: * A **mutate function** that you can call at any time to execute the mutation - * Unlike `useQuery`, `useMutation` _doesn't_ execute its operation automatically on render. Instead, you call the mutate function. + * Unlike `useQuery`, `useMutation` _doesn't_ execute its operation automatically on render. Instead, you call this mutate function. * An object with fields that represent the current status of the mutation's execution (`data`, `loading`, etc.) * This object is similar to the object returned by the `useQuery` hook. For details, see [Result](#result). @@ -101,7 +101,7 @@ function AddTodo() { } ``` -In this example, our `form`'s `onSubmit` handler calls the **mutate function** (named `addTodo`) that's returned by the `useMutation` hook. This tells Apollo Client to execute the mutation by sending it to our GraphQL server. +In this example, our form's `onSubmit` handler calls the **mutate function** (named `addTodo`) that's returned by the `useMutation` hook. This tells Apollo Client to execute the mutation by sending it to our GraphQL server. > Note that this behavior differs from [`useQuery`](./queries/), which executes its operation as soon as its component renders. This is because mutations are more commonly executed in response to a user action (such as submitting a form in this case). @@ -151,19 +151,19 @@ if (error) return `Submission error! ${error.message}`; > The `useMutation` hook also supports `onCompleted` and `onError` options if you prefer to use callbacks. [See the API reference.](../api/react/hooks/#options-2) -## Updating the cache after a mutation +## Updating local data -When you execute a mutation, you modify back-end data. You often then want to update your _locally cached_ data to reflect that back-end modification. For example, if you execute a mutation to add an item to your to-do list, you also want that item to appear in your local copy of the list. +When you execute a mutation, you modify back-end data. Usually, you then want to update your _locally cached_ data to reflect the back-end modification. For example, if you execute a mutation to add an item to your to-do list, you also want that item to appear in your cached copy of the list. -### Available methods +### Supported methods -The most straightforward way to update your local data is to **refetch any queries** that might be affected by the mutation. However, this method requires additional network requests. +The most straightforward way to update your local data is to [refetch any queries](#refetching-queries) that might be affected by the mutation. However, this method requires additional network requests. -If your mutation returns all of the objects and fields that it modified, you can **update the cache directly** _without_ making any network requests. However, this method increases in complexity as your mutations become more complex. +If your mutation returns all of the objects and fields that it modified, you can [update your cache directly](#updating-the-cache-directly) _without_ making any followup network requests. However, this method increases in complexity as your mutations become more complex. -If you're just getting started with Apollo Client, we recommend refetching queries to update your cached data. After you get that working, you can improve your app's performance by updating the cache directly instead. +If you're just getting started with Apollo Client, we recommend refetching queries to update your cached data. After you get that working, you can improve your app's responsiveness by updating the cache directly. -### Refetching queries +## Refetching queries If you know that your app usually needs to refetch certain queries after a particular mutation, you can include a `refetchQueries` array in that mutation's options. @@ -179,15 +179,13 @@ const [addTodo, { data, loading, error }] = useMutation(ADD_TODO, { You can provide the `refetchQueries` option either to `useMutation` or to the mutate function. For details, see [Option precedence](#option-precedence). -Note that in an app with tens or hundreds of different queries, it becomes much more challenging to determine exactly which queries to refetch after a particular mutation. +Note that in an app with tens or hundreds of different queries, it can be challenging to determine exactly which queries to refetch after a particular mutation. -### Updating the cache directly +## Updating the cache directly -TODO +### Include modified objects in mutation responses -#### Include modified objects in mutation responses - -In most cases, a mutation response should include any object(s) the mutation modified. This enables Apollo Client to normalize those objects and cache them according to their `id` and `__typename` fields ([by default](../caching/cache-configuration/#generating-unique-identifiers)). +In most cases, a mutation response should include any object(s) the mutation modified. This enables Apollo Client to normalize those objects and cache them according to their `__typename` and `id` fields ([by default](../caching/cache-configuration/#customizing-cache-ids)). [In the example above](#example), our `ADD_TODO` mutation might return a `Todo` object with the following structure: @@ -203,17 +201,15 @@ In most cases, a mutation response should include any object(s) the mutation mod Upon receiving this response object, Apollo Client caches it with key `Todo:5`. If a cached object _already_ exists with this key, Apollo Client overwrites any existing fields that are also included in the mutation response (other existing fields are preserved). -Returning modified objects like this is a helpful first step to keeping your cache in sync with your backend. However, it isn't always sufficient. For example, a newly cached object isn't automatically added to any _list fields_ that should now include that object. To accomplish this, you can define an `update` function. - -#### The `update` function +Returning modified objects like this is a helpful first step to keeping your cache in sync with your backend. However, it isn't always sufficient. For example, a newly cached object isn't automatically added to any _list fields_ that should now include that object. To accomplish this, you can define an [`update` function](#the-update-function). -A single mutation can create, modify, or delete any number of different entities in your data graph. Often, the mutation will return some representation of these updated entity objects as part of its result data. Apollo Client will automatically write these entity objects into the cache, which is sometimes enough to broadcast appropriate updates to your application, but not always. +### The `update` function -While you could refetch all your queries to find out what changed after each mutation, a much more efficient solution is to pass an `update` function to `useMutation` to apply manual changes to your cached data, to match whatever modifications the mutation made to your back-end data. +When a [mutation's response](#include-modified-objects-in-mutation-responses) is insufficient to update _all_ modified fields in your cache (such as certain list fields), you can define an `update` function to apply manual changes to your cached data after a mutation. -The following sample illustrates defining an `update` function in a call to `useMutation`: +You provide an `update` function to `useMutation`, like so: -```jsx +```jsx{12-29} const GET_TODOS = gql` query GetTodos { todos { @@ -266,44 +262,34 @@ function AddTodo() { } ``` -As shown, the `update` function is passed a `cache` object that represents the Apollo Client cache. This object provides access to cache API methods like `readQuery`, `writeQuery`, `readFragment`, `writeFragment`, `evict` and `modify`. These methods enable you to execute GraphQL operations on the cache as though you're interacting with a GraphQL server. +As shown, the `update` function is passed a `cache` object that represents the Apollo Client cache. This object provides access to cache API methods like `readQuery`/`writeQuery`, `readFragment`/`writeFragment`, `modify`, and `evict`. These methods enable you to execute GraphQL operations on the cache as though you're interacting with a GraphQL server. > Learn more about supported cache functions in [Interacting with cached data](../caching/cache-interaction/). -The `update` function is _also_ passed an object with a `data` property that contains the result of the mutation. You can use this value to update the cache with `cache.writeQuery`, `cache.writeFragment` or `cache.modify`. +The `update` function is _also_ passed an object with a `data` property that contains the result of the mutation. You can use this value to update the cache with `cache.writeQuery`, `cache.writeFragment`, or `cache.modify`. > If your mutation specifies an [optimistic response](../performance/optimistic-ui/), your `update` function is called **twice**: once with the optimistic result, and again with the actual result of the mutation when it returns. -When the `ADD_TODO` mutation is run in the above example, the newly added and returned `addTodo` object is automatically saved into the cache before the `update` function runs. The previously cached list of `ROOT_QUERY.todos`, being watched by the `GET_TODOS` query, is not automatically updated. This means the `GET_TODOS` query isn't notified that a new todo was added, which then means the query will not update to show the new todo. +When the `ADD_TODO` mutation executes in the above example, the newly added and returned `addTodo` object is automatically saved into the cache _before_ the `update` function runs. However, the cached list of `ROOT_QUERY.todos` (which is watched by the `GET_TODOS` query) is _not_ automatically updated. This means that the `GET_TODOS` query isn't notified of the new `Todo` object, which in turn means that the query doesn't update to show the new item. -To address this we're using `cache.modify` which gives us a way to surgically insert or delete items from the cache, by running "modifier" functions. In the example above we know the results of the `GET_TODOS` query are stored in the `ROOT_QUERY.todos` array in the cache, so we're using a `todos` modifier function to update the cached array to include a reference to the newly added todo. With the help of `cache.writeFragment` we get an internal reference to the added todo, then append that reference to the `ROOT_QUERY.todos` array. +To address this, we use `cache.modify` to surgically insert or delete items from the cache, by running "modifier" functions. In the example above, we know the results of the `GET_TODOS` query are stored in the `ROOT_QUERY.todos` array in the cache, so we use a `todos` modifier function to update the cached array to include a reference to the newly added `Todo`. With the help of `cache.writeFragment`, we get an internal reference to the added `Todo`, then append that reference to the `ROOT_QUERY.todos` array. Any changes you make to cached data inside of an `update` function are automatically broadcast to queries that are listening for changes to that data. Consequently, your application's UI will update to reflect these updated cached values. -#### Refetching `update`d data - -The goal of the `update` function is to simulate the effects of a mutation locally, even though most mutations really take place on a remote server. If the `update` function succeeds in simulating the authoritative server-side changes, then your users won't have to await another network round-trip to see the latest data. - -In other words, just as an [optimistic response](../performance/optimistic-ui/) represents an informed guess about the _response data_ a mutation will return, the `update` function represents an informed guess about any _indirect effects_ that mutation might have. +### Refetching after `update` -Both of these guesses could be slightly wrong in some cases. The `optimisticResponse` eventually gets superseded by the real mutation response, but what can you do to check the guesswork of your `update` function? +An `update` function attempts to replicate a mutation's back-end modifications in your client's local cache. These cache modifications are broadcast to all affected active queries, which updates your UI automatically. If the `update` function does this correctly, your users see the latest data immediately, without needing to await another network round trip. -> Thankfully, although GraphQL mutations can have arbitrary effects on the server, a GraphQL client only needs to worry about mutation effects that can be later observed by refetching GraphQL queries. +However, an `update` function might get this replication _wrong_ by setting a cached value incorrectly. You can "double check" your `update` function's modifications by refetching affected active queries on your GraphQL server. To specify _which_ active queries you want to refetch, provide the `onQueryUpdated` option to your mutate function: -Normally, when the `update` function succeeds, the cache is modified, and those modifications are broadcast to the application, which rerenders a few of its components, without needing to refetch the affected queries from the network. - -If you knew which currently-active queries were affected by the `update` function, then perhaps you could refetch just those queries from the network, behind the scenes, updating the UI only if the network data turns out to be different from what your `update` function produced, in effect double-checking the work of the `update` function. - -That's where `onQueryUpdated` comes in handy: - -```js +```js{6-11} addTodo({ variables: { type: input.value }, update(cache, result) { - // Update the cache as an approximation of server-side mutation effects. + // Update the cache as an approximation of server-side mutation effects }, onQueryUpdated(observableQuery) { - // Provide your own dynamic logic for controlling refetching behavior. + // Define any custom logic for determining whether to refetch if (shouldRefetchQuery(observableQuery)) { return observableQuery.refetch(); } @@ -311,9 +297,9 @@ addTodo({ }) ``` -If the `update` function triggers changes to the results of any queries, those queries will be passed to the `onQueryUpdated` function, represented by `ObservableQuery` objects. If you decide to `refetch` a query to make sure the `update` function guessed correctly, you can do that by returning `observableQuery.refetch()` from the `onQueryUpdated` function, as shown above. +After your `update` function completes, Apollo Client calls the `onQueryUpdated` function _once for each active query with cached fields that were updated_. Within `onQueryUpdated`, you can use any custom logic to determine whether you want to refetch a particular active query. -The beauty of this system is that the UI should not need to rerender as long as your optimistic guesses (both `optimisticResponse` and the `update` function) were accurate. +To execute an active query from `onQueryUpdated`, call `return observableQuery.refetch()`, as shown above. Otherwise, no return value is required. If a query's response differs from your `update` function's modifications, your cache and UI are both automatically updated again. Otherwise, your users see no change. Occasionally, it might be difficult to make your `update` function update all relevant queries. Not every mutation returns enough information for the `update` function to do its job effectively. To make absolutely sure a certain query is included, you can combine `onQueryUpdated` with `refetchQueries: [...]`: From 175c2cb564ec7110055103bc7e8e703306087acb Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Thu, 22 Jul 2021 12:40:01 -0400 Subject: [PATCH 356/380] Fix various broken links. --- docs/source/api/cache/InMemoryCache.mdx | 2 +- docs/source/caching/advanced-topics.mdx | 2 +- docs/source/caching/cache-interaction.md | 2 +- docs/source/caching/overview.mdx | 2 +- docs/source/local-state/managing-state-with-field-policies.mdx | 2 +- docs/source/performance/optimistic-ui.mdx | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/source/api/cache/InMemoryCache.mdx b/docs/source/api/cache/InMemoryCache.mdx index b9bbc402599..e40d3d28858 100644 --- a/docs/source/api/cache/InMemoryCache.mdx +++ b/docs/source/api/cache/InMemoryCache.mdx @@ -587,7 +587,7 @@ You can provide either an object or an object _reference_ to this function: * If you provide an object, `identify` returns the object's string-based ID (e.g., `Car:1`). * If you provide a reference, `identify` return the reference's `__ref` ID string. -For usage instructions, see [Interacting with cached data: Identify cached entities](../../caching/cache-interaction/#obtaining-an-objects-custom-id). +For usage instructions, see [Interacting with cached data: Identify cached entities](../../caching/cache-interaction/#obtaining-an-objects-cache-id). Accepts the following parameters: diff --git a/docs/source/caching/advanced-topics.mdx b/docs/source/caching/advanced-topics.mdx index e0477fcb6f1..201b5f5fe64 100644 --- a/docs/source/caching/advanced-topics.mdx +++ b/docs/source/caching/advanced-topics.mdx @@ -19,7 +19,7 @@ Operations that use this fetch policy don't write their result to the cache, and ## Rerunning queries after a mutation -In certain cases, writing an `update` function to [update the cache after a mutation](../data/mutations/#updating-the-cache-after-a-mutation) can be complex, or even impossible if the mutation doesn't return modified fields. +In certain cases, writing an `update` function to [update the cache after a mutation](../data/mutations/#updating-local-data) can be complex, or even impossible if the mutation doesn't return modified fields. In these cases, you can provide a `refetchQueries` option to the `useMutation` hook to automatically rerun certain queries after the mutation completes. diff --git a/docs/source/caching/cache-interaction.md b/docs/source/caching/cache-interaction.md index d071f39748c..ff9d49f3dc6 100644 --- a/docs/source/caching/cache-interaction.md +++ b/docs/source/caching/cache-interaction.md @@ -382,7 +382,7 @@ const [addComment] = useMutation(ADD_COMMENT, { In this example, `useMutation` automatically creates a `Comment` and adds it to the cache, but it _doesn't_ automatically know how to add that `Comment` to the corresponding `Post`'s list of `comments`. This means that any queries watching the `Post`'s list of `comments` _won't_ update. -To address this, we use the [`update` callback](../data/mutations/#updating-the-cache-after-a-mutation) of `useMutation` to call `cache.modify`. Like the [previous example](#example-adding-an-item-to-a-list), we add the new comment to the list. _Unlike_ the previous example, the comment was already added to the cache by `useMutation`. Consequently, `cache.writeFragment` returns a reference to the existing object. +To address this, we use the [`update` callback](../data/mutations/#updating-local-data) of `useMutation` to call `cache.modify`. Like the [previous example](#example-adding-an-item-to-a-list), we add the new comment to the list. _Unlike_ the previous example, the comment was already added to the cache by `useMutation`. Consequently, `cache.writeFragment` returns a reference to the existing object. ### Example: Deleting a field from a cached object diff --git a/docs/source/caching/overview.mdx b/docs/source/caching/overview.mdx index d3cfa7bbe8e..954726eafc7 100644 --- a/docs/source/caching/overview.mdx +++ b/docs/source/caching/overview.mdx @@ -88,7 +88,7 @@ If the cache _can't_ generate a cache ID for a particular object (for example, i Third, the cache takes each field that contains an object and replaces its value with a **reference** to the appropriate object. -For example, here's the `Person` object from [the example above]() _before_ reference replacement: +For example, here's the `Person` object from the example above _before_ reference replacement: ```json{5-9} { diff --git a/docs/source/local-state/managing-state-with-field-policies.mdx b/docs/source/local-state/managing-state-with-field-policies.mdx index 0afb3aeb74d..55f4e3b7940 100644 --- a/docs/source/local-state/managing-state-with-field-policies.mdx +++ b/docs/source/local-state/managing-state-with-field-policies.mdx @@ -97,7 +97,7 @@ You can use Apollo Client to query local state, regardless of how you _store_ th * [Reactive variables](#storing-local-state-in-reactive-variables) * [The Apollo Client cache itself](#storing-local-state-in-the-cache) -> **Note:** Apollo Client >= 3 no longer recommends the [local resolver](/local-state/local-resolvers) approach of using `client.mutate` / `useMutation` combined with an `@client` mutation operation, to [update local state](/local-state/local-resolvers/#local-resolvers). If you want to update local state, we recommend using [`writeQuery`](/caching/cache-interaction/#writequery), [`writeFragment`](/caching/cache-interaction/#writefragment), or [reactive variables](/local-state/reactive-variables/). +> **Note:** Apollo Client >= 3 no longer recommends the [local resolver](./local-resolvers) approach of using `client.mutate` / `useMutation` combined with an `@client` mutation operation, to [update local state](/local-state/local-resolvers/#local-resolvers). If you want to update local state, we recommend using [`writeQuery`](/caching/cache-interaction/#writequery), [`writeFragment`](/caching/cache-interaction/#writefragment), or [reactive variables](/local-state/reactive-variables/). ### Storing local state in reactive variables diff --git a/docs/source/performance/optimistic-ui.mdx b/docs/source/performance/optimistic-ui.mdx index d0c85935b82..87bf2fed915 100644 --- a/docs/source/performance/optimistic-ui.mdx +++ b/docs/source/performance/optimistic-ui.mdx @@ -59,7 +59,7 @@ function CommentPageWithData() { } ``` -As this example shows, the value of `optimisticResponse` is an object that matches the shape of the mutation response we expect from the server. Importantly, this includes the `Comment`'s `id` and `__typename` fields. The Apollo Client cache uses these values to generate the comment's [unique cache identifier](../caching/cache-configuration/#default-identifier-generation) (e.g., `Comment:5`). +As this example shows, the value of `optimisticResponse` is an object that matches the shape of the mutation response we expect from the server. Importantly, this includes the `Comment`'s `id` and `__typename` fields. The Apollo Client cache uses these values to generate the comment's [unique cache identifier](../caching/cache-configuration/#customizing-cache-ids) (e.g., `Comment:5`). ## Optimistic mutation lifecycle From 2f04e178566f59758a4f8757408a68eda4f716c1 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Thu, 22 Jul 2021 12:14:21 -0400 Subject: [PATCH 357/380] Prevent uncaught rejections of client.refetchQueries result promise. --- src/core/ApolloClient.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/core/ApolloClient.ts b/src/core/ApolloClient.ts index d620b1d0104..cf61b920c43 100644 --- a/src/core/ApolloClient.ts +++ b/src/core/ApolloClient.ts @@ -561,6 +561,13 @@ export class ApolloClient implements DataProxy { result.queries = queries; result.results = results; + // If you decide to ignore the result Promise because you're using + // result.queries and result.results instead, you shouldn't have to worry + // about preventing uncaught rejections for the Promise.all result. + result.catch(error => { + invariant.debug(`In client.refetchQueries, Promise.all promise rejected with error ${error}`); + }); + return result; } From 75ef9c460a1800aece05da0a03e457f4b3fc4847 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Thu, 22 Jul 2021 12:43:54 -0400 Subject: [PATCH 358/380] Clarify deprecation and future plans for local resolvers. --- docs/source/local-state/local-resolvers.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/local-state/local-resolvers.mdx b/docs/source/local-state/local-resolvers.mdx index d977e1ed7fa..19ca81de5f1 100644 --- a/docs/source/local-state/local-resolvers.mdx +++ b/docs/source/local-state/local-resolvers.mdx @@ -5,7 +5,7 @@ description: Manage local data with GraphQL like resolvers > ⚠️ **DEPRECATION WARNING:** Local resolvers are still available in Apollo Client 3, but they are deprecated. We recommend using field policies instead, as described in [Local-only fields](./managing-state-with-field-policies/). > -> Local resolver support will be removed in a future major Apollo Client release. See the [deprecation notice](#deprecation-notice) for details. +> Local resolver support will be removed from the core of Apollo Client in a future major release, but it will be possible to achieving the functionality of local resolvers using an `ApolloLink`, as described in [this comment](https://github.com/apollographql/apollo-client/issues/8189#issuecomment-857199932). We've learned how to manage remote data from our GraphQL server with Apollo Client, but what should we do with our local data? We want to be able to access boolean flags and device API results from multiple components in our app, but don't want to maintain a separate Redux or MobX store. Ideally, we would like the Apollo cache to be the single source of truth for all data in our client application. From f87dbf58aafd4d802394e7455d3183f8e7304fdc Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Thu, 22 Jul 2021 12:09:25 -0400 Subject: [PATCH 359/380] Add "Refetch recipes" section for client.refetchQueries. --- docs/source/data/refetching.mdx | 248 +++++++++++++++++++++++++++++++- 1 file changed, 242 insertions(+), 6 deletions(-) diff --git a/docs/source/data/refetching.mdx b/docs/source/data/refetching.mdx index 83bc7ba356a..6840a539bcc 100644 --- a/docs/source/data/refetching.mdx +++ b/docs/source/data/refetching.mdx @@ -39,8 +39,8 @@ The `client.refetchQueries` method collects the `TResult` results returned by `onQueryUpdated` is not provided, and combines the results into a single `Promise` using `Promise.all(results)`. -> Thanks to the `Promise`-unwrapping behavior of `Promise.all`, `TResolved` will -be the same type as `TResult` except when `TResult` is a +> Thanks to the `Promise`-unwrapping behavior of `Promise.all`, this `TResolved` +type will often be the same type as `TResult`, except when `TResult` is a `PromiseLike` or a `boolean`. The returned `Promise` object has two other useful properties: @@ -48,8 +48,244 @@ The returned `Promise` object has two other useful properties: | Property | Type | Description | | - | - | - | | `queries` | `ObservableQuery[]` | An array of `ObservableQuery` objects that were refetched | -| `results` | `TResult[]` | An array of results returned by `onQueryUpdated`, including pending promises | +| `results` | `TResult[]` | An array of results that were either returned by `onQueryUpdated`, or provided by default in the absence of `onQueryUpdated`, including pending promises. If `onQueryUpdated` returns `false` for a given query, no result will be provided for that query. If `onQueryUpdated` returns `true`, the resulting `Promise>` will be included in the `results` array, rather than `true`. | -These two arrays are parallel: they have the same length, and `results[i]` is -the result returned by `onQueryUpdated` when it was called with the -`ObservableQuery` given by `queries[i]`, for any index `i`. +These two arrays parallel each other: they have the same length, and +`results[i]` is the result produced by `onQueryUpdated` when called with the +`ObservableQuery` found at `queries[i]`, for any index `i`. + +### Refetch recipes + +To refetch a specific query by name, use the `include` option by itself: + +```ts +await client.refetchQueries({ + include: ["SomeQueryName"], +}); +``` + +The `include` option can also refetch a specific query using its `DocumentNode`: + +```ts +await client.refetchQueries({ + include: [SOME_QUERY], +}); +``` + +To refetch all active queries, pass the `"active"` shorthand for `include`: +```ts +await client.refetchQueries({ + include: "active", +}); +``` + +To refetch _all_ queries managed by Apollo Client, even those with no observers, +or whose components are currently unmounted, pass `"all"` for `include`: + +```ts +await client.refetchQueries({ + include: "all", // Consider using "active" instead! +}); +``` + +Alternatively, you can refetch queries affected by cache updates performed in +the `updateCache` callback: + +```ts +await client.refetchQueries({ + updateCache(cache) { + cache.evict({ fieldName: "someRootField" }); + }, +}); +``` + +This will refetch any queries that depend on `Query.someRootField`, without +requiring you to know in advance which queries might be included. Any +combination of cache operations (`writeQuery`, `writeFragment`, `modify`, +`evict`, etc.) is allowed within `updateCache`. Updates performed by +`updateCache` persist in the cache by default, but you can choose to perform +them in a temporary optimistic layer instead, if you want them to be discarded +immediately after `client.refetchQueries` is done observing them, leaving the +cache unchanged: + +```ts +await client.refetchQueries({ + updateCache(cache) { + cache.evict({ fieldName: "someRootField" }); + }, + + // Evict Query.someRootField only temporarily, in an optimistic layer. + optimistic: true, +}); +``` + +Another way of updating the cache without changing cache data is to use +`cache.modify` and its `INVALIDATE` sentinel object: + +```ts +await client.refetchQueries({ + updateCache(cache) { + cache.modify({ + fields: { + someRootField(value, { INVALIDATE }) { + // Update queries that involve Query.someRootField, without actually + // changing its value in the cache. + return INVALIDATE; + }, + }, + }); + }, +}); +``` + +> Before `client.refetchQueries` was introduced, the `INVALIDATE` sentinel was +[not very useful](https://github.com/apollographql/apollo-client/issues/7060#issuecomment-698026089), +since invalidated queries with `fetchPolicy: "cache-first"` would typically +re-read unchanged results, and therefore decide not to perform a network +request. The `client.refetchQueries` method makes this invalidation system more +accessible to application code, so you can control the refetching behavior of +invalidated queries. + +In all of the examples above, whether we use `include` or `updateCache`, +`client.refetchQueries` will refetch the affected queries from the network and +include the resulting `Promise>` results in the +`Promise` returned by `client.refetchQueries`. If a single query +happens to be included both by `include` and by `updateCache`, that query will +be refetched only once. In other words, the `include` option is a good way to +make sure certain queries are always included, no matter which queries are +included by `updateCache`. + +In development, you will probably want to make sure the appropriate queries are +getting refetched, rather than blindly refetching them. To intercept each query +before refetching, you can specify an `onQueryUpdated` callback: + +```ts +const results = await client.refetchQueries({ + updateCache(cache) { + cache.evict({ fieldName: "someRootField" }); + }, + + onQueryUpdated(observableQuery) { + // Logging and/or debugger breakpoints can be useful in development to + // understand what client.refetchQueries is doing. + console.log(`Examining ObservableQuery ${observableQuery.queryName}`); + debugger; + + // Proceed with the default refetching behavior, as if onQueryUpdated + // was not provided. + return true; + }, +}); + +results.forEach(result => { + // These results will be ApolloQueryResult objects, after all + // results have been refetched from the network. +}); +``` + +Notice how adding `onQueryUpdated` in this example did not change the refetching +behavior of `client.refetchQueries`, allowing us to use `onQueryUpdated` purely +for diagnostic or debugging purposes. + +If you want to skip certain queries that would otherwise be included, return +`false` from `onQueryUpdated`: + +```ts +await client.refetchQueries({ + updateCache(cache) { + cache.evict({ fieldName: "someRootField" }); + }, + + onQueryUpdated(observableQuery) { + console.log(`Examining ObservableQuery ${ + observableQuery.queryName + } whose latest result is ${JSON.stringify(result)} which is ${ + complete ? "complete" : "incomplete" + }`); + + if (shouldIgnoreQuery(observableQuery)) { + return false; + } + + // Refetch the query unconditionally from the network. + return true; + }, +}); +``` + +In case the `ObservableQuery` does not provide enough information, you can also +examine the latest `result` for the query, along with information about its +`complete`ness and `missing` fields, using the `Cache.DiffResult` object passed +as the second parameter to `onQueryUpdated`: + +```ts +await client.refetchQueries({ + updateCache(cache) { + cache.evict({ fieldName: "someRootField" }); + }, + + onQueryUpdated(observableQuery, { complete, result, missing }) { + if (shouldIgnoreQuery(observableQuery)) { + return false; + } + + if (complete) { + // Update the query according to its chosen FetchPolicy, rather than + // refetching it unconditionally from the network. + return observableQuery.reobserve(); + } + + // Refetch the query unconditionally from the network. + return true; + }, +}); +``` + +Because `onQueryUpdated` has the ability to filter queries dynamically, it also +pairs well with the bulk `include` options mentioned above: + +```ts +await client.refetchQueries({ + // Include all active queries by default, which may be ill-advised unless + // you also use onQueryUpdated to filter those queries. + include: "active"; + + // Called once for every active query, allowing dynamic filtering: + onQueryUpdated(observableQuery) { + return !shouldIngoreQuery(observableQuery); + }, +}); +``` + +In the examples above we `await client.refetchQueries(...)` to find out the +final `ApolloQueryResult` results for all the refetched queries. This +combined promise is created with `Promise.all`, so a single failure will reject +the entire `Promise`, potentially hiding other successful results. +If this is a problem, you can use the `queries` and `results` arrays returned by +`client.refetchQueries` instead of (or in addition to) `await`ing the `Promise`: + +```ts +const { queries, results } = client.refetchQueries({ + // Specific client.refetchQueries options are not relevant to this example. +}); + +const finalResults = await Promise.all( + results.map((result, i) => { + return Promise.resolve(result).catch(error => { + console.error(`Error refetching query ${queries[i].queryName}: ${error}`); + return null; // Silence this Promise rejection. + }); + }) +}); +``` + +In the future, just as additional input options may be added to the +`client.refetchQueries` method, additional properties may be added to its result +object, supplementing its `Promise`-related properties and the `queries` and +`results` arrays. + +If you discover that some specific additional `client.refetchQueries` input +options or result properties would be useful, please feel free to [open an +issue](https://github.com/apollographql/apollo-feature-requests/issues) or +[start a discussion](https://github.com/apollographql/apollo-client/discussions) +explaining your use case(s). From 6d705ae3014e7cce7907174efbaec2c9612d8bc5 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Thu, 22 Jul 2021 13:23:23 -0400 Subject: [PATCH 360/380] Show full RefetchQueriesOptions interface type. --- docs/shared/refetchQueries-options.mdx | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/docs/shared/refetchQueries-options.mdx b/docs/shared/refetchQueries-options.mdx index bd3090bcf75..067fe709f29 100644 --- a/docs/shared/refetchQueries-options.mdx +++ b/docs/shared/refetchQueries-options.mdx @@ -10,13 +10,27 @@ +The `client.refetchQueries` method take an `options` object that conforms to the +following TypeScript interface: + ```ts interface RefetchQueriesOptions< TCache extends ApolloCache, - TResult, + TResult = Promise>, > { + updateCache?: (cache: TCache) => void; + include?: Array | "all" | "active"; + onQueryUpdated?: ( + observableQuery: ObservableQuery, + diff: Cache.DiffResult, + lastDiff: Cache.DiffResult | undefined, + ) => boolean | TResult; + optimistic?: boolean; +} ``` +Descriptions of these fields can be found in the table below. + From 71646940fd94c61aac932dd7842b76da6dccafe5 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Thu, 22 Jul 2021 13:27:22 -0400 Subject: [PATCH 361/380] Fix link from mutations.mdx to refetching.mdx. --- docs/source/data/mutations.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/data/mutations.mdx b/docs/source/data/mutations.mdx index d64151dad6d..994137b5346 100644 --- a/docs/source/data/mutations.mdx +++ b/docs/source/data/mutations.mdx @@ -322,7 +322,7 @@ If `ReallyImportantQuery` was already going to be passed to `onQueryUpdated` tha If you find you've included more queries than you expected, you can skip or ignore a query by returning `false` from `onQueryUpdated`, after examining the `ObservableQuery` to determine that it doesn't need refetching. Returning a `Promise` from `onQueryUpdated` causes the final `Promise>` for the mutation to await any promises returned from `onQueryUpated`, eliminating the need for the legacy `awaitRefetchQueries: true` option. -To use the `onQueryUpdated` API without performing a mutation, try the `client.refetchQueries` method. In the standalone `client.refetchQueries` API, the `refetchQueries: [...]` mutation option is called `include: [...]`, and the `update` function is called `updateCache` for clarity. Otherwise, the same internal system powers both `client.refetchQueries` and refetching queries after a mutation. +To use the `onQueryUpdated` API without performing a mutation, try the [`client.refetchQueries`](./refetching/#clientrefetchqueries) method. In the standalone `client.refetchQueries` API, the `refetchQueries: [...]` mutation option is called `include: [...]`, and the `update` function is called `updateCache` for clarity. Otherwise, the same internal system powers both `client.refetchQueries` and refetching queries after a mutation. ## `useMutation` API From be73ce972c6cc08dcb2b15c80a11a1f2505e74ff Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Thu, 22 Jul 2021 13:53:24 -0400 Subject: [PATCH 362/380] Add translation table for client.{mutate,refetchQueries} options. --- docs/source/data/refetching.mdx | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/source/data/refetching.mdx b/docs/source/data/refetching.mdx index 6840a539bcc..129b892d371 100644 --- a/docs/source/data/refetching.mdx +++ b/docs/source/data/refetching.mdx @@ -289,3 +289,22 @@ options or result properties would be useful, please feel free to [open an issue](https://github.com/apollographql/apollo-feature-requests/issues) or [start a discussion](https://github.com/apollographql/apollo-client/discussions) explaining your use case(s). + +### Relationship to `client.mutate` options + +For refetching after a mutation, the `client.mutate` method supports options +similar to `client.refetchQueries`, which you should use instead of +`client.refetchQueries`, because it's important for refetching logic to happen +at specific times during the mutation process. + +For historical reasons, the `client.mutate` options have slightly different +names from the new `client.refetchQueries` options, but their internal +implementation is substantially the same, so you can translate between them +using the following table: + +| `client.mutate(options)` | | `client.refetchQueries(options)` | +| - | - | - | +| [`options.refetchQueries`](./mutations/#refetching-queries) | ⇔ | `options.include` | +| [`options.update`](./mutations/#the-update-function) | ⇔ | `options.updateCache` | +| [`options.onQueryUpdated`](./mutations/#refetching-after-update) | ⇔ | `options.onQueryUpdated` | +| [`options.awaitRefetchQueries`](./mutations/#awaitrefetchqueries) | ⇔ | just return a `Promise` from `onQueryUpdated` | From 48be27b5292b97ac0f1adcd6430d0e268eacc23a Mon Sep 17 00:00:00 2001 From: Stephen Barlow Date: Thu, 22 Jul 2021 12:29:58 -0700 Subject: [PATCH 363/380] Incorporate feedback from @benjamn --- docs/source/data/mutations.mdx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/source/data/mutations.mdx b/docs/source/data/mutations.mdx index 994137b5346..e623596160e 100644 --- a/docs/source/data/mutations.mdx +++ b/docs/source/data/mutations.mdx @@ -197,7 +197,7 @@ In most cases, a mutation response should include any object(s) the mutation mod } ``` -> Apollo Client automatically adds the `__typename` field to your queries and mutations by default. +> Apollo Client automatically adds the `__typename` field to every object in your queries and mutations by default. Upon receiving this response object, Apollo Client caches it with key `Todo:5`. If a cached object _already_ exists with this key, Apollo Client overwrites any existing fields that are also included in the mutation response (other existing fields are preserved). @@ -280,7 +280,7 @@ Any changes you make to cached data inside of an `update` function are automatic An `update` function attempts to replicate a mutation's back-end modifications in your client's local cache. These cache modifications are broadcast to all affected active queries, which updates your UI automatically. If the `update` function does this correctly, your users see the latest data immediately, without needing to await another network round trip. -However, an `update` function might get this replication _wrong_ by setting a cached value incorrectly. You can "double check" your `update` function's modifications by refetching affected active queries on your GraphQL server. To specify _which_ active queries you want to refetch, provide the `onQueryUpdated` option to your mutate function: +However, an `update` function might get this replication _wrong_ by setting a cached value incorrectly. You can "double check" your `update` function's modifications by refetching affected active queries. To do so, you first provide an `onQueryUpdated` callback function to your mutate function: ```js{6-11} addTodo({ @@ -297,9 +297,9 @@ addTodo({ }) ``` -After your `update` function completes, Apollo Client calls the `onQueryUpdated` function _once for each active query with cached fields that were updated_. Within `onQueryUpdated`, you can use any custom logic to determine whether you want to refetch a particular active query. +After your `update` function completes, Apollo Client calls `onQueryUpdated` _once for each active query with cached fields that were updated_. Within `onQueryUpdated`, you can use any custom logic to determine whether you want to refetch the associated query. -To execute an active query from `onQueryUpdated`, call `return observableQuery.refetch()`, as shown above. Otherwise, no return value is required. If a query's response differs from your `update` function's modifications, your cache and UI are both automatically updated again. Otherwise, your users see no change. +To refetch a query from `onQueryUpdated`, call `return observableQuery.refetch()`, as shown above. Otherwise, no return value is required. If a refetched query's response differs from your `update` function's modifications, your cache and UI are both automatically updated again. Otherwise, your users see no change. Occasionally, it might be difficult to make your `update` function update all relevant queries. Not every mutation returns enough information for the `update` function to do its job effectively. To make absolutely sure a certain query is included, you can combine `onQueryUpdated` with `refetchQueries: [...]`: From c6542fdb1f972d5eb08347e8f8130fd0a6f64d14 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Thu, 22 Jul 2021 15:50:10 -0400 Subject: [PATCH 364/380] Fix broken link error about #awaitrefetchqueries fragment link. https://app.netlify.com/sites/apollo-client-docs/deploys/60f9c9f09e186b000846394e https://github.com/apollographql/apollo-client/pull/8265#issuecomment-885187344 Strange because the link does render correctly and go to the right place. Perhaps the broken link detector doesn't understand #anchors within tables? --- docs/source/data/refetching.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/data/refetching.mdx b/docs/source/data/refetching.mdx index 129b892d371..a503976b1f4 100644 --- a/docs/source/data/refetching.mdx +++ b/docs/source/data/refetching.mdx @@ -307,4 +307,4 @@ using the following table: | [`options.refetchQueries`](./mutations/#refetching-queries) | ⇔ | `options.include` | | [`options.update`](./mutations/#the-update-function) | ⇔ | `options.updateCache` | | [`options.onQueryUpdated`](./mutations/#refetching-after-update) | ⇔ | `options.onQueryUpdated` | -| [`options.awaitRefetchQueries`](./mutations/#awaitrefetchqueries) | ⇔ | just return a `Promise` from `onQueryUpdated` | +| [`options.awaitRefetchQueries`](./mutations/#options) | ⇔ | just return a `Promise` from `onQueryUpdated` | From 1590859bc55d9d607612a81fbc2ba730ad9f3da6 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Thu, 22 Jul 2021 16:47:22 -0400 Subject: [PATCH 365/380] Bump @apollo/client npm version to 3.4.0-rc.22. --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 11076985f43..ecd5187df7a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-rc.21", + "version": "3.4.0-rc.22", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index d577b3cfa1b..752abfa4087 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-rc.21", + "version": "3.4.0-rc.22", "description": "A fully-featured caching GraphQL client.", "private": true, "keywords": [ From 9ab9400a9b2ec5e8e9369e7057f0b9f0b807b2ef Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Fri, 23 Jul 2021 13:05:02 -0400 Subject: [PATCH 366/380] Minor adjustments to CHANGELOG.md entry for AC3.4. Removed the item about PR #7627 because it has been superseded by #8347. --- CHANGELOG.md | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a67c6d4616..60e0d9c2f3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,9 @@ - Make `ObservableQuery#getCurrentResult` always call `queryInfo.getDiff()`.
[@benjamn](https://github.com/benjamn) in [#8422](https://github.com/apollographql/apollo-client/pull/8422) +- Make `readField` default to reading from current object only when the `from` option/argument is actually omitted, not when `from` is passed to `readField` with an undefined value. A warning will be printed when this situation occurs.
+ [@benjamn](https://github.com/benjamn) in [#8508](https://github.com/apollographql/apollo-client/pull/8508) + ### Potentially disruptive changes - To avoid retaining sensitive information from mutation root field arguments, Apollo Client v3.4 automatically clears any `ROOT_MUTATION` fields from the cache after each mutation finishes. If you need this information to remain in the cache, you can prevent the removal by passing the `keepRootFields: true` option to `client.mutate`. `ROOT_MUTATION` result data are also passed to the mutation `update` function, so we recommend obtaining the results that way, rather than using `keepRootFields: true`, if possible.
@@ -59,17 +62,11 @@ - The [`nextFetchPolicy`](https://github.com/apollographql/apollo-client/pull/6893) option for `client.watchQuery` and `useQuery` will no longer be removed from the `options` object after it has been applied, and instead will continue to be applied any time `options.fetchPolicy` is reset to another value, until/unless the `options.nextFetchPolicy` property is removed from `options`.
[@benjamn](https://github.com/benjamn) in [#8465](https://github.com/apollographql/apollo-client/pull/8465) -- Make `readField` default to reading from current object only when the `from` option/argument is actually omitted, not when `from` is passed to `readField` with an undefined value. A warning will be printed when this situation occurs.
- [@benjamn](https://github.com/benjamn) in [#8508](https://github.com/apollographql/apollo-client/pull/8508) - ### Improvements - `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.
[@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.
- [@benjamn](https://github.com/benjamn) in [#7819](https://github.com/apollographql/apollo-client/pull/7819) - - Mutations now accept an optional callback function called `onQueryUpdated`, which will be passed the `ObservableQuery` and `Cache.DiffResult` objects for any queries invalidated by cache writes performed by the mutation's final `update` function. Using `onQueryUpdated`, you can override the default `FetchPolicy` of the query, by (for example) calling `ObservableQuery` methods like `refetch` to force a network request. This automatic detection of invalidated queries provides an alternative to manually enumerating queries using the `refetchQueries` mutation option. Also, if you return a `Promise` from `onQueryUpdated`, the mutation will automatically await that `Promise`, rendering the `awaitRefetchQueries` option unnecessary.
[@benjamn](https://github.com/benjamn) in [#7827](https://github.com/apollographql/apollo-client/pull/7827) @@ -79,8 +76,11 @@ - Improve standalone `client.refetchQueries` method to support automatic detection of queries needing to be refetched.
[@benjamn](https://github.com/benjamn) in [#8000](https://github.com/apollographql/apollo-client/pull/8000) -- When `@apollo/client` is imported as CommonJS (for example, in Node.js), the global `process` variable is now shadowed with a stripped-down object that includes only `process.env.NODE_ENV` (since that's all Apollo Client needs), eliminating the significant performance penalty of repeatedly accessing `process.env` at runtime.
- [@benjamn](https://github.com/benjamn) in [#7627](https://github.com/apollographql/apollo-client/pull/7627) +- `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.
+ [@benjamn](https://github.com/benjamn) in [#7819](https://github.com/apollographql/apollo-client/pull/7819) + +- Allow `merge: true` field policy to merge `Reference` objects with non-normalized objects, and vice-versa.
+ [@benjamn](https://github.com/benjamn) in [#7778](https://github.com/apollographql/apollo-client/pull/7778) - Allow identical subscriptions to be deduplicated by default, like queries.
[@jkossis](https://github.com/jkossis) in [#6910](https://github.com/apollographql/apollo-client/pull/6910) @@ -91,9 +91,6 @@ - The `FetchMoreQueryOptions` type now takes two instead of three type parameters (``), thanks to using `Partial` instead of `K extends typeof TVariables` and `Pick`.
[@ArnaudBarre](https://github.com/ArnaudBarre) in [#7476](https://github.com/apollographql/apollo-client/pull/7476) -- Allow `merge: true` field policy to merge `Reference` objects with non-normalized objects, and vice-versa.
- [@benjamn](https://github.com/benjamn) in [#7778](https://github.com/apollographql/apollo-client/pull/7778) - - Pass `variables` and `context` to a mutation's `update` function
[@jcreighton](https://github.com/jcreighton) in [#7902](https://github.com/apollographql/apollo-client/pull/7902) @@ -118,8 +115,10 @@ - Improve interaction between React hooks and React Fast Refresh in development.
[@andreialecu](https://github.com/andreialecu) in [#7952](https://github.com/apollographql/apollo-client/pull/7952) -### Documentation -TBD +### New documentation + +- [**Refetching queries**](https://deploy-preview-7399--apollo-client-docs.netlify.app/docs/react/data/refetching/) with `client.refetchQueries`.
+ [@StephenBarlow](https://github.com/StephenBarlow) and [@benjamn](https://github.com/benjamn) in [#8265](https://github.com/apollographql/apollo-client/pull/8265) ## Apollo Client 3.3.21 From 60db4e4860bea209fb9abf8494c553b4299e9035 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Fri, 23 Jul 2021 13:24:51 -0400 Subject: [PATCH 367/380] Move stringifyForDisplay to utilities/common rather than /testing. Fixing a regression introduced by #8508. Importing from @apollo/client/testing in cache/inmemory/policies.ts made Rollup display circular dependency warnings (which are often harmless but also worth avoiding) and resulted in importing React-related code even when using @apollo/client/core rather than @apollo/client, which is a more serious problem. --- src/__tests__/__snapshots__/exports.ts.snap | 2 +- src/cache/inmemory/policies.ts | 3 ++- src/utilities/{testing => common}/stringifyForDisplay.ts | 2 +- src/utilities/index.ts | 1 + src/utilities/testing/index.ts | 1 - src/utilities/testing/mocking/mockLink.ts | 5 +---- 6 files changed, 6 insertions(+), 8 deletions(-) rename src/utilities/{testing => common}/stringifyForDisplay.ts (83%) diff --git a/src/__tests__/__snapshots__/exports.ts.snap b/src/__tests__/__snapshots__/exports.ts.snap index 90edc0545ba..dc287958ddf 100644 --- a/src/__tests__/__snapshots__/exports.ts.snap +++ b/src/__tests__/__snapshots__/exports.ts.snap @@ -312,7 +312,6 @@ Array [ "itAsync", "mockObservableLink", "mockSingleLink", - "stringifyForDisplay", "stripSymbols", "subscribeAndCount", "withErrorSpy", @@ -377,6 +376,7 @@ Array [ "resultKeyNameFromField", "shouldInclude", "storeKeyNameFromField", + "stringifyForDisplay", "valueToObjectRepresentation", ] `; diff --git a/src/cache/inmemory/policies.ts b/src/cache/inmemory/policies.ts index 67c2549787a..0f09e0a7a9d 100644 --- a/src/cache/inmemory/policies.ts +++ b/src/cache/inmemory/policies.ts @@ -22,6 +22,7 @@ import { getStoreKeyName, canUseWeakMap, isNonNullObject, + stringifyForDisplay, } from '../../utilities'; import { IdGetter, @@ -52,7 +53,7 @@ import { WriteContext } from './writeToStore'; // used by getStoreKeyName. This function is used when computing storeFieldName // strings (when no keyArgs has been configured for a field). import { canonicalStringify } from './object-canon'; -import { stringifyForDisplay } from '../../testing'; + getStoreKeyName.setStringify(canonicalStringify); export type TypePolicies = { diff --git a/src/utilities/testing/stringifyForDisplay.ts b/src/utilities/common/stringifyForDisplay.ts similarity index 83% rename from src/utilities/testing/stringifyForDisplay.ts rename to src/utilities/common/stringifyForDisplay.ts index 4eb8a724238..bce4d50b6ee 100644 --- a/src/utilities/testing/stringifyForDisplay.ts +++ b/src/utilities/common/stringifyForDisplay.ts @@ -1,4 +1,4 @@ -import { makeUniqueId } from "../common/makeUniqueId"; +import { makeUniqueId } from "./makeUniqueId"; export function stringifyForDisplay(value: any): string { const undefId = makeUniqueId("stringifyForDisplay"); diff --git a/src/utilities/index.ts b/src/utilities/index.ts index 5ad7f9f5581..ff4e765c8f1 100644 --- a/src/utilities/index.ts +++ b/src/utilities/index.ts @@ -94,5 +94,6 @@ export * from './common/errorHandling'; export * from './common/canUse'; export * from './common/compact'; export * from './common/makeUniqueId'; +export * from './common/stringifyForDisplay'; export * from './types/IsStrictlyAny'; diff --git a/src/utilities/testing/index.ts b/src/utilities/testing/index.ts index c0e5c1daa51..2fd8113ebe2 100644 --- a/src/utilities/testing/index.ts +++ b/src/utilities/testing/index.ts @@ -14,4 +14,3 @@ export { stripSymbols } from './stripSymbols'; export { default as subscribeAndCount } from './subscribeAndCount'; export { itAsync } from './itAsync'; export * from './withConsoleSpy'; -export * from './stringifyForDisplay'; diff --git a/src/utilities/testing/mocking/mockLink.ts b/src/utilities/testing/mocking/mockLink.ts index 4812177b4b7..22d3b687886 100644 --- a/src/utilities/testing/mocking/mockLink.ts +++ b/src/utilities/testing/mocking/mockLink.ts @@ -15,11 +15,8 @@ import { removeClientSetsFromDocument, removeConnectionDirectiveFromDocument, cloneDeep, -} from '../../../utilities'; - -import { stringifyForDisplay, -} from '../../../testing'; +} from '../../../utilities'; export type ResultFunction = () => T; From 81472483648b03d854029464b4b085d6d9130bf9 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Fri, 23 Jul 2021 13:34:23 -0400 Subject: [PATCH 368/380] Bump @apollo/client npm version to 3.4.0-rc.23. --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index ecd5187df7a..5ce75956c8f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-rc.22", + "version": "3.4.0-rc.23", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 752abfa4087..d860b359a8f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-rc.22", + "version": "3.4.0-rc.23", "description": "A fully-featured caching GraphQL client.", "private": true, "keywords": [ From 220df30fd739e3d26e78daa604e210abcbe08827 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Fri, 23 Jul 2021 13:53:30 -0400 Subject: [PATCH 369/380] Reorder CHANGELOG.md sections within AC3.4 heading. --- CHANGELOG.md | 126 +++++++++++++++++++++++++-------------------------- 1 file changed, 63 insertions(+), 63 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 60e0d9c2f3a..67bda0c7c9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,66 +1,9 @@ ## Apollo Client 3.4.0 (not yet released) -### Bug fixes - -- In Apollo Client 2.x, a `refetch` operation would always replace existing data in the cache. With the introduction of field policy `merge` functions in Apollo Client 3, existing field values could be inappropriately combined with incoming field values by a custom `merge` function that does not realize a `refetch` has happened. - - To give you more control over this behavior, we have introduced an `overwrite?: boolean = false` option for `cache.writeQuery` and `cache.writeFragment`, and an option called `refetchWritePolicy?: "merge" | "overwrite"` for `client.watchQuery`, `useQuery`, and other functions that accept `WatchQueryOptions`. You can use these options to make sure any `merge` functions involved in cache writes for `refetch` operations get invoked with `undefined` as their first argument, which simulates the absence of any existing data, while still giving the `merge` function a chance to determine the internal representation of the incoming data. - - The default behaviors are `overwrite: true` and `refetchWritePolicy: "overwrite"`, which restores the Apollo Client 2.x behavior, but (if this change causes any problems for your application) you can easily recover the previous merging behavior by setting a default value for `refetchWritePolicy` in `defaultOptions.watchQuery`: - ```ts - new ApolloClient({ - defaultOptions: { - watchQuery: { - refetchWritePolicy: "merge", - }, - }, - }) - ``` - [@benjamn](https://github.com/benjamn) in [#7810](https://github.com/apollographql/apollo-client/pull/7810) - -- Make sure the `MockedResponse` `ResultFunction` type is re-exported.
- [@hwillson](https://github.com/hwillson) in [#8315](https://github.com/apollographql/apollo-client/pull/8315) - -- Fix polling when used with `skip`.
- [@brainkim](https://github.com/brainkim) in [#8346](https://github.com/apollographql/apollo-client/pull/8346) - -- `InMemoryCache` now coalesces `EntityStore` updates to guarantee only one `store.merge(id, fields)` call per `id` per cache write.
- [@benjamn](https://github.com/benjamn) in [#8372](https://github.com/apollographql/apollo-client/pull/8372) - -- Fix polling when used with ``.
- [@brainkim](https://github.com/brainkim) in [#8414](https://github.com/apollographql/apollo-client/pull/8414) - -- Fix the React integration logging `Warning: Can't perform a React state update on an unmounted component`.
- [@wuarmin](https://github.com/wuarmin) in [#7745](https://github.com/apollographql/apollo-client/pull/7745) - -- Make `ObservableQuery#getCurrentResult` always call `queryInfo.getDiff()`.
- [@benjamn](https://github.com/benjamn) in [#8422](https://github.com/apollographql/apollo-client/pull/8422) - -- Make `readField` default to reading from current object only when the `from` option/argument is actually omitted, not when `from` is passed to `readField` with an undefined value. A warning will be printed when this situation occurs.
- [@benjamn](https://github.com/benjamn) in [#8508](https://github.com/apollographql/apollo-client/pull/8508) - -### Potentially disruptive changes - -- To avoid retaining sensitive information from mutation root field arguments, Apollo Client v3.4 automatically clears any `ROOT_MUTATION` fields from the cache after each mutation finishes. If you need this information to remain in the cache, you can prevent the removal by passing the `keepRootFields: true` option to `client.mutate`. `ROOT_MUTATION` result data are also passed to the mutation `update` function, so we recommend obtaining the results that way, rather than using `keepRootFields: true`, if possible.
- [@benjamn](https://github.com/benjamn) in [#8280](https://github.com/apollographql/apollo-client/pull/8280) - -- Internally, Apollo Client now controls the execution of development-only code using the `__DEV__` global variable, rather than `process.env.NODE_ENV`. While this change should not cause any visible differences in behavior, it will increase your minified+gzip bundle size by more than 3.5kB, unless you configure your minifier to replace `__DEV__` with a `true` or `false` constant, the same way you already replace `process.env.NODE_ENV` with a string literal like `"development"` or `"production"`. For an example of configuring a Create React App project without ejecting, see this pull request for our [React Apollo reproduction template](https://github.com/apollographql/react-apollo-error-template/pull/51).
- [@benjamn](https://github.com/benjamn) in [#8347](https://github.com/apollographql/apollo-client/pull/8347) - -- Internally, Apollo Client now uses namespace syntax (e.g. `import * as React from "react"`) for imports whose types are re-exported (and thus may appear in `.d.ts` files). This change should remove any need to configure `esModuleInterop` or `allowSyntheticDefaultImports` in `tsconfig.json`, but might require updating bundler configurations that specify named exports of the `react` and `prop-types` packages, to include exports like `createContext` and `createElement` ([example](https://github.com/apollographql/apollo-client/commit/16b08e1af9ba9934041298496e167aafb128c15d)).
- [@devrelm](https://github.com/devrelm) in [#7742](https://github.com/apollographql/apollo-client/pull/7742) - -- Respect `no-cache` fetch policy (by not reading any `data` from the cache) for `loading: true` results triggered by `notifyOnNetworkStatusChange: true`.
- [@jcreighton](https://github.com/jcreighton) in [#7761](https://github.com/apollographql/apollo-client/pull/7761) - -- The TypeScript return types of the `getLastResult` and `getLastError` methods of `ObservableQuery` now correctly include the possibility of returning `undefined`. If you happen to be calling either of these methods directly, you may need to adjust how the calling code handles the methods' possibly-`undefined` results.
- [@benjamn](https://github.com/benjamn) in [#8394](https://github.com/apollographql/apollo-client/pull/8394) - -- Log non-fatal `invariant.error` message when fields are missing from result objects written into `InMemoryCache`, rather than throwing an exception. While this change relaxes an exception to be merely an error message, which is usually a backwards-compatible change, the error messages are logged in more cases now than the exception was previously thrown, and those new error messages may be worth investigating to discover potential problems in your application. The errors are not displayed for `@client`-only fields, so adding `@client` is one way to handle/hide the errors for local-only fields. Another general strategy is to use a more precise query to write specific subsets of data into the cache, rather than reusing a larger query that contains fields not present in the written `data`.
- [@benjamn](https://github.com/benjamn) in [#8416](https://github.com/apollographql/apollo-client/pull/8416) +### New documentation -- The [`nextFetchPolicy`](https://github.com/apollographql/apollo-client/pull/6893) option for `client.watchQuery` and `useQuery` will no longer be removed from the `options` object after it has been applied, and instead will continue to be applied any time `options.fetchPolicy` is reset to another value, until/unless the `options.nextFetchPolicy` property is removed from `options`.
- [@benjamn](https://github.com/benjamn) in [#8465](https://github.com/apollographql/apollo-client/pull/8465) +- [**Refetching queries**](https://deploy-preview-7399--apollo-client-docs.netlify.app/docs/react/data/refetching/) with `client.refetchQueries`.
+ [@StephenBarlow](https://github.com/StephenBarlow) and [@benjamn](https://github.com/benjamn) in [#8265](https://github.com/apollographql/apollo-client/pull/8265) ### Improvements @@ -115,10 +58,67 @@ - Improve interaction between React hooks and React Fast Refresh in development.
[@andreialecu](https://github.com/andreialecu) in [#7952](https://github.com/apollographql/apollo-client/pull/7952) -### New documentation +### Potentially disruptive changes -- [**Refetching queries**](https://deploy-preview-7399--apollo-client-docs.netlify.app/docs/react/data/refetching/) with `client.refetchQueries`.
- [@StephenBarlow](https://github.com/StephenBarlow) and [@benjamn](https://github.com/benjamn) in [#8265](https://github.com/apollographql/apollo-client/pull/8265) +- To avoid retaining sensitive information from mutation root field arguments, Apollo Client v3.4 automatically clears any `ROOT_MUTATION` fields from the cache after each mutation finishes. If you need this information to remain in the cache, you can prevent the removal by passing the `keepRootFields: true` option to `client.mutate`. `ROOT_MUTATION` result data are also passed to the mutation `update` function, so we recommend obtaining the results that way, rather than using `keepRootFields: true`, if possible.
+ [@benjamn](https://github.com/benjamn) in [#8280](https://github.com/apollographql/apollo-client/pull/8280) + +- Internally, Apollo Client now controls the execution of development-only code using the `__DEV__` global variable, rather than `process.env.NODE_ENV`. While this change should not cause any visible differences in behavior, it will increase your minified+gzip bundle size by more than 3.5kB, unless you configure your minifier to replace `__DEV__` with a `true` or `false` constant, the same way you already replace `process.env.NODE_ENV` with a string literal like `"development"` or `"production"`. For an example of configuring a Create React App project without ejecting, see this pull request for our [React Apollo reproduction template](https://github.com/apollographql/react-apollo-error-template/pull/51).
+ [@benjamn](https://github.com/benjamn) in [#8347](https://github.com/apollographql/apollo-client/pull/8347) + +- Internally, Apollo Client now uses namespace syntax (e.g. `import * as React from "react"`) for imports whose types are re-exported (and thus may appear in `.d.ts` files). This change should remove any need to configure `esModuleInterop` or `allowSyntheticDefaultImports` in `tsconfig.json`, but might require updating bundler configurations that specify named exports of the `react` and `prop-types` packages, to include exports like `createContext` and `createElement` ([example](https://github.com/apollographql/apollo-client/commit/16b08e1af9ba9934041298496e167aafb128c15d)).
+ [@devrelm](https://github.com/devrelm) in [#7742](https://github.com/apollographql/apollo-client/pull/7742) + +- Respect `no-cache` fetch policy (by not reading any `data` from the cache) for `loading: true` results triggered by `notifyOnNetworkStatusChange: true`.
+ [@jcreighton](https://github.com/jcreighton) in [#7761](https://github.com/apollographql/apollo-client/pull/7761) + +- The TypeScript return types of the `getLastResult` and `getLastError` methods of `ObservableQuery` now correctly include the possibility of returning `undefined`. If you happen to be calling either of these methods directly, you may need to adjust how the calling code handles the methods' possibly-`undefined` results.
+ [@benjamn](https://github.com/benjamn) in [#8394](https://github.com/apollographql/apollo-client/pull/8394) + +- Log non-fatal `invariant.error` message when fields are missing from result objects written into `InMemoryCache`, rather than throwing an exception. While this change relaxes an exception to be merely an error message, which is usually a backwards-compatible change, the error messages are logged in more cases now than the exception was previously thrown, and those new error messages may be worth investigating to discover potential problems in your application. The errors are not displayed for `@client`-only fields, so adding `@client` is one way to handle/hide the errors for local-only fields. Another general strategy is to use a more precise query to write specific subsets of data into the cache, rather than reusing a larger query that contains fields not present in the written `data`.
+ [@benjamn](https://github.com/benjamn) in [#8416](https://github.com/apollographql/apollo-client/pull/8416) + +- The [`nextFetchPolicy`](https://github.com/apollographql/apollo-client/pull/6893) option for `client.watchQuery` and `useQuery` will no longer be removed from the `options` object after it has been applied, and instead will continue to be applied any time `options.fetchPolicy` is reset to another value, until/unless the `options.nextFetchPolicy` property is removed from `options`.
+ [@benjamn](https://github.com/benjamn) in [#8465](https://github.com/apollographql/apollo-client/pull/8465) + +### Bug fixes + +- In Apollo Client 2.x, a `refetch` operation would always replace existing data in the cache. With the introduction of field policy `merge` functions in Apollo Client 3, existing field values could be inappropriately combined with incoming field values by a custom `merge` function that does not realize a `refetch` has happened. + + To give you more control over this behavior, we have introduced an `overwrite?: boolean = false` option for `cache.writeQuery` and `cache.writeFragment`, and an option called `refetchWritePolicy?: "merge" | "overwrite"` for `client.watchQuery`, `useQuery`, and other functions that accept `WatchQueryOptions`. You can use these options to make sure any `merge` functions involved in cache writes for `refetch` operations get invoked with `undefined` as their first argument, which simulates the absence of any existing data, while still giving the `merge` function a chance to determine the internal representation of the incoming data. + + The default behaviors are `overwrite: true` and `refetchWritePolicy: "overwrite"`, which restores the Apollo Client 2.x behavior, but (if this change causes any problems for your application) you can easily recover the previous merging behavior by setting a default value for `refetchWritePolicy` in `defaultOptions.watchQuery`: + ```ts + new ApolloClient({ + defaultOptions: { + watchQuery: { + refetchWritePolicy: "merge", + }, + }, + }) + ``` + [@benjamn](https://github.com/benjamn) in [#7810](https://github.com/apollographql/apollo-client/pull/7810) + +- Make sure the `MockedResponse` `ResultFunction` type is re-exported.
+ [@hwillson](https://github.com/hwillson) in [#8315](https://github.com/apollographql/apollo-client/pull/8315) + +- Fix polling when used with `skip`.
+ [@brainkim](https://github.com/brainkim) in [#8346](https://github.com/apollographql/apollo-client/pull/8346) + +- `InMemoryCache` now coalesces `EntityStore` updates to guarantee only one `store.merge(id, fields)` call per `id` per cache write.
+ [@benjamn](https://github.com/benjamn) in [#8372](https://github.com/apollographql/apollo-client/pull/8372) + +- Fix polling when used with ``.
+ [@brainkim](https://github.com/brainkim) in [#8414](https://github.com/apollographql/apollo-client/pull/8414) + +- Fix the React integration logging `Warning: Can't perform a React state update on an unmounted component`.
+ [@wuarmin](https://github.com/wuarmin) in [#7745](https://github.com/apollographql/apollo-client/pull/7745) + +- Make `ObservableQuery#getCurrentResult` always call `queryInfo.getDiff()`.
+ [@benjamn](https://github.com/benjamn) in [#8422](https://github.com/apollographql/apollo-client/pull/8422) + +- Make `readField` default to reading from current object only when the `from` option/argument is actually omitted, not when `from` is passed to `readField` with an undefined value. A warning will be printed when this situation occurs.
+ [@benjamn](https://github.com/benjamn) in [#8508](https://github.com/apollographql/apollo-client/pull/8508) ## Apollo Client 3.3.21 From 3f0c9efbd60ea68b3d504241a81277cc8cfd7bf7 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Fri, 23 Jul 2021 13:59:23 -0400 Subject: [PATCH 370/380] Mention work to load @apollo/client/core from CDNs in CHANGELOG.md. --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 67bda0c7c9e..ba5f1438126 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,9 @@ - Improve standalone `client.refetchQueries` method to support automatic detection of queries needing to be refetched.
[@benjamn](https://github.com/benjamn) in [#8000](https://github.com/apollographql/apollo-client/pull/8000) +- Fix remaining barriers to loading [`@apollo/client/core`](https://cdn.jsdelivr.net/npm/@apollo/client@beta/core/+esm) as native ECMAScript modules from a CDN like [esm.run](https://www.jsdelivr.com/esm). Importing `@apollo/client` from a CDN will become possible once we move all React-related dependencies into `@apollo/client/react` in Apollo Client 4.
+ [@benjamn](https://github.com/benjamn) in [#8266](https://github.com/apollographql/apollo-client/issues/8266) + - `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.
[@benjamn](https://github.com/benjamn) in [#7819](https://github.com/apollographql/apollo-client/pull/7819) From a89fc5cbca2cbf5a788b0f608782038ebe2e1ccd Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Mon, 26 Jul 2021 14:17:30 -0500 Subject: [PATCH 371/380] Use 'next' tag rather than 'beta' when publishing @apollo/client to npm. With any luck, the next published version will be @apollo/client@3.4.0! --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 6a8d33e3006..4b721ba0d65 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "coverage": "jest --config ./config/jest.config.js --verbose --coverage", "bundlesize": "npm run build && bundlesize", "predeploy": "npm run build", - "deploy": "cd dist && npm publish --tag beta" + "deploy": "cd dist && npm publish --tag next" }, "bundlesize": [ { From 9b532acb7a27488f34fb7f9e3fbdbdd2dca2a264 Mon Sep 17 00:00:00 2001 From: Stephen Barlow Date: Mon, 26 Jul 2021 13:10:47 -0700 Subject: [PATCH 372/380] Miscellaneous improvements to caching docs (#8529) --- docs/source/caching/advanced-topics.mdx | 229 ++++++++------------ docs/source/caching/cache-configuration.mdx | 20 +- docs/source/caching/cache-interaction.md | 32 +-- docs/source/caching/garbage-collection.mdx | 2 +- docs/source/data/mutations.mdx | 20 +- 5 files changed, 137 insertions(+), 166 deletions(-) diff --git a/docs/source/caching/advanced-topics.mdx b/docs/source/caching/advanced-topics.mdx index 201b5f5fe64..53f005165d1 100644 --- a/docs/source/caching/advanced-topics.mdx +++ b/docs/source/caching/advanced-topics.mdx @@ -1,5 +1,5 @@ --- -title: Advanced topics on caching +title: Advanced topics on caching in Apollo Client sidebar_title: Advanced topics --- @@ -15,65 +15,109 @@ const { loading, error, data } = useQuery(GET_DOGS, { }); ``` -Operations that use this fetch policy don't write their result to the cache, and they also don't check the cache for data before sending a request to your server. +Operations that use this fetch policy don't write their result to the cache, and they also don't check the cache for data before sending a request to your server. [See all available fetch policies.](../data/queries/#setting-a-fetch-policy) -## Rerunning queries after a mutation +## Persisting the cache -In certain cases, writing an `update` function to [update the cache after a mutation](../data/mutations/#updating-local-data) can be complex, or even impossible if the mutation doesn't return modified fields. +You can persist and rehydrate the `InMemoryCache` from a storage provider like `AsyncStorage` or `localStorage`. To do so, use the [`apollo3-cache-persist`](https://github.com/apollographql/apollo-cache-persist) library. This library works with a variety of [storage providers](https://github.com/apollographql/apollo-cache-persist#storage-providers). -In these cases, you can provide a `refetchQueries` option to the `useMutation` hook to automatically rerun certain queries after the mutation completes. +To get started, pass your cache and a storage provider to `persistCache`. By default, the contents of your cache are immediately restored asynchronously, and they're persisted on every write to the cache with a short configurable debounce interval. -> Note that although `refetchQueries` can be faster to implement than an `update` function, it also requires additional network requests that are usually undesirable. For more information, see [this blog post](https://www.apollographql.com/blog/when-to-use-refetch-queries-in-apollo-client/). +> Note: The `persistCache` method is async and returns a `Promise`. -Here's an example of using `refetchQueries` to execute a query that's specified inline: +```js +import { AsyncStorage } from 'react-native'; +import { InMemoryCache } from '@apollo/client'; +import { persistCache } from 'apollo3-cache-persist'; -```javascript -useMutation( - // ... Mutation definition ... - - // Mutation options - { - refetchQueries: [{ - query: gql` - query UpdateCache($repoName: String!) { - entry(repoFullName: $repoName) { - id - comments { - postedBy { - login - html_url - } - createdAt - content - } - } - } - `, - variables: { repoName: 'apollographql/apollo-client' }, - }], +const cache = new InMemoryCache(); + +persistCache({ + cache, + storage: AsyncStorage, +}).then(() => { + // Continue setting up Apollo Client as usual. +}) +``` + +For advanced usage and additional configuration options, see the [README of `apollo3-cache-persist`](https://github.com/apollographql/apollo-cache-persist). + +## Resetting the cache + +Sometimes, you might want to reset the cache entirely, such as [when a user logs out](../networking/authentication/#reset-store-on-logout). To accomplish this, call `client.resetStore`. This method is asynchronous, because it also refetches any of your active queries. + +```js +export default withApollo(graphql(PROFILE_QUERY, { + props: ({ data: { loading, currentUser }, ownProps: { client }}) => ({ + loading, + currentUser, + resetOnLogout: async () => client.resetStore(), + }), +})(Profile)); +``` + +> To reset the cache _without_ refetching active queries, use `client.clearStore()` instead of `client.resetStore()`. + +### Responding to cache resets + +You can register callback functions that execute whenever `client.resetStore` is called. To do so, call `client.onResetStore` and pass in your callback. To register multiple callbacks, call `client.onResetStore` multiple times. All of your callbacks are added to an array and are executed concurrently whenever the cache is reset. + +In this example, we use `client.onResetStore` to write default values to the cache. This is useful when using Apollo Client's [local state management](../local-state/local-state-management/) features and calling `client.resetStore` anywhere in your application. + +```js +import { ApolloClient, InMemoryCache } from '@apollo/client'; +import { withClientState } from 'apollo-link-state'; + +import { resolvers, defaults } from './resolvers'; + +const cache = new InMemoryCache(); +const stateLink = withClientState({ cache, resolvers, defaults }); + +const client = new ApolloClient({ + cache, + link: stateLink, }); + +client.onResetStore(stateLink.writeDefaults); ``` -The `refetchQueries` option is an array where each element is one of the following: +You can also call `client.onResetStore` from your React components. This can be useful if you want to force your UI to rerender after the cache is reset. -* An object with a `query` field that specifies the query to execute, along with a `variables` field if applicable (shown above) -* The name of a query you've previously executed, as a string (e.g., `GetComments`) - * _Queries you list by name are executed with their most recently provided set of variables._ +The `client.onResetStore` method's return value is a function you can call to unregister your callback: -You can also import and provide queries that are defined in other components to make sure those components are updated: +```js{6-8,12} +import { withApollo } from "@apollo/react-hoc"; -```javascript -import RepoCommentsQuery from '../queries/RepoCommentsQuery'; - -mutate({ - //... insert comment mutation - refetchQueries: [{ - query: RepoCommentsQuery, - variables: { repoFullName: 'apollographql/apollo-client' }, - }], -}) +export class Foo extends Component { + constructor(props) { + super(props); + this.unsubscribe = props.client.onResetStore( + () => this.setState({ reset: false }) + ); + this.state = { reset: false }; + } + componentDidUnmount() { + this.unsubscribe(); + } + render() { + return this.state.reset ?
: + } +} + +export default withApollo(Foo); ``` + +## Refetching queries after a mutation + +In certain cases, writing an `update` function to [update the cache after a mutation](../data/mutations/#updating-local-data) can be complex, or even impossible if the mutation doesn't return modified fields. + +In these cases, you can provide a `refetchQueries` option to the `useMutation` hook to automatically rerun certain queries after the mutation completes. + +For details, see [Refetching queries](../data/mutations/#refetching-queries). + +> Note that although `refetchQueries` can be faster to implement than an `update` function, it also requires additional network requests that are usually undesirable. For more information, see [this blog post](https://www.apollographql.com/blog/when-to-use-refetch-queries-in-apollo-client/). + ## Incremental loading: `fetchMore` `fetchMore` can be used to update the result of a query based on the data returned by another query. Most often, it is used to handle infinite-scroll pagination or other situations where you are loading more data when you already have some. @@ -231,95 +275,8 @@ const client = new ApolloClient({ }); ``` -Now whenever a query is run that includes a `book` field, the `read` function above will be executed, and return a reference that points to the book entity that was already created in the cache when the `Books` list view query ran. Apollo Client will use the reference returned by the `read` function to look up the item in its cache. `toReference` is a helper utility that is passed into `read` functions as part of the second parameter options object, and is used to generate an entity reference based on its `__typename` and `id`. - -> ⚠️ **Note:** For the above to work properly, the data returned by the list query has to include all of the data the specific detail query needs. If the specific detail query fetches a field that the list query doesn't return, Apollo Client will consider the cache hit to be incomplete, and will attempt to fetch the full data set over the network (if network requests are enabled). - -## Resetting the store - -Sometimes, you may want to reset the store entirely, such as [when a user logs out](../networking/authentication/#reset-store-on-logout). To accomplish this, use `client.resetStore` to clear out your Apollo cache. Since `client.resetStore` also refetches any of your active queries for you, it is asynchronous. - -```js -export default withApollo(graphql(PROFILE_QUERY, { - props: ({ data: { loading, currentUser }, ownProps: { client }}) => ({ - loading, - currentUser, - resetOnLogout: async () => client.resetStore(), - }), -})(Profile)); -``` - -To register a callback function to be executed after the store has been reset, call `client.onResetStore` and pass in your callback. If you would like to register multiple callbacks, simply call `client.onResetStore` again. All of your callbacks will be pushed into an array and executed concurrently. - -In this example, we're using `client.onResetStore` to write default values to the cache. This is useful when using Apollo Client's [local state management](../local-state/local-state-management/) features and calling `client.resetStore` anywhere in your application. - -```js -import { ApolloClient, InMemoryCache } from '@apollo/client'; -import { withClientState } from 'apollo-link-state'; - -import { resolvers, defaults } from './resolvers'; - -const cache = new InMemoryCache(); -const stateLink = withClientState({ cache, resolvers, defaults }); - -const client = new ApolloClient({ - cache, - link: stateLink, -}); - -client.onResetStore(stateLink.writeDefaults); -``` - -You can also call `client.onResetStore` from your React components. This can be useful if you would like to force your UI to rerender after the store has been reset. - -If you would like to unsubscribe your callbacks from resetStore, use the return value of `client.onResetStore` for your unsubscribe function. - -```js -import { withApollo } from "@apollo/react-hoc"; - -export class Foo extends Component { - constructor(props) { - super(props); - this.unsubscribe = props.client.onResetStore( - () => this.setState({ reset: false }) - ); - this.state = { reset: false }; - } - componentDidUnmount() { - this.unsubscribe(); - } - render() { - return this.state.reset ?
: - } -} - -export default withApollo(Foo); -``` - -If you want to clear the store but don't want to refetch active queries, use -`client.clearStore()` instead of `client.resetStore()`. - -## Cache persistence - -If you would like to persist and rehydrate your Apollo Cache from a storage provider like `AsyncStorage` or `localStorage`, you can use [`apollo3-cache-persist`](https://github.com/apollographql/apollo-cache-persist). `apollo3-cache-persist` works with all Apollo caches, including `InMemoryCache` & `Hermes`, and a variety of different [storage providers](https://github.com/apollographql/apollo-cache-persist#storage-providers). - -To get started, simply pass your Apollo Cache and a storage provider to `persistCache`. By default, the contents of your Apollo Cache will be immediately restored asynchronously, and persisted upon every write to the cache with a short configurable debounce interval. - -> Note: The `persistCache` method is async and returns a `Promise`. +Now whenever a query includes the `book` field, the `read` function above executes and returns a reference that points to the book entity that was added to the cache when the `Books` list view query ran. Apollo Client uses the reference returned by the `read` function to look up the item in its cache. -```js -import { AsyncStorage } from 'react-native'; -import { InMemoryCache } from '@apollo/client'; -import { persistCache } from 'apollo3-cache-persist'; - -const cache = new InMemoryCache(); - -persistCache({ - cache, - storage: AsyncStorage, -}).then(() => { - // Continue setting up Apollo as usual. -}) -``` +The `toReference` helper utility is passed into `read` functions as part of the second parameter options object. It's used to generate an entity reference based on its `__typename` and `id`. -For more advanced usage, such as persisting the cache when the app is in the background, and additional configuration options, please check the [README of `apollo3-cache-persist`](https://github.com/apollographql/apollo-cache-persist). +> ⚠️ **Note:** For the above to work properly, the data returned by the list query must include _all_ of the data that the specific detail query needs. If the specific detail query fetches a field that the list query doesn't return, Apollo Client considers the cache hit to be incomplete, and it attempts to fetch the full data set over the network (if network requests are enabled). diff --git a/docs/source/caching/cache-configuration.mdx b/docs/source/caching/cache-configuration.mdx index cfd040a21a3..f1e9631195a 100644 --- a/docs/source/caching/cache-configuration.mdx +++ b/docs/source/caching/cache-configuration.mdx @@ -58,7 +58,7 @@ To customize cache behavior, provide an `options` object to the `InMemoryCache` -If `true`, the cache automatically adds `__typename` fields to all outgoing queries, removing the need to add them manually. +If `true`, the cache automatically adds `__typename` fields to all objects in outgoing queries, removing the need to add them manually. The default value is `true`. @@ -75,7 +75,7 @@ The default value is `true`. -If `true`, the cache returns an identical (`===`) response object for every execution of the same query, as long as the underlying data remains unchanged. This makes it easier to detect changes to a query's result. +If `true`, the cache returns an identical (`===`) response object for every execution of the same query, as long as the underlying data remains unchanged. This helps you detect changes to a query's result. The default value is `true`. @@ -212,13 +212,19 @@ const cache = new InMemoryCache({ }); ``` -> The `dataIdFromObject` API is included in Apollo Client 3.0 to ease the transition from Apollo Client 2.x. The API might be removed in a future version of `@apollo/client`. +> The `dataIdFromObject` API is included in Apollo Client 3 to ease the transition from Apollo Client 2.x. The API might be removed in a future version of `@apollo/client`. -Notice that the above function still uses different logic to generate keys based on an object's `__typename`. In the above case, you might as well define `keyFields` arrays for the `Product` and `Person` types via `typePolicies`. Also, this code is sensitive to aliasing mistakes, it does nothing to protect against undefined `object` properties, and accidentally using different key fields at different times can cause inconsistencies in the cache. +Notice that the above function still uses different logic to generate keys based on an object's `__typename`. In a case like this, you should almost always define `keyFields` arrays for the `Product` and `Person` types via `typePolicies`. + +This code also has the following drawbacks: + +* It's sensitive to aliasing mistakes. +* It does nothing to protect against undefined `object` properties. +* Accidentally using different key fields at different times can cause inconsistencies in the cache. ### Disabling normalization -You can instruct the `InMemoryCache` _not_ to normalize objects of a certain type. This can be useful for metrics and other transient data that's identified by a timestamp and never receives updates. +You can instruct the `InMemoryCache` _not_ to normalize objects of a particular type. This can be useful for metrics and other transient data that's identified by a timestamp and never receives updates. To disable normalization for a type, define a `TypePolicy` for the type (as shown in [Customizing cache IDs](#customizing-cache-ids)) and set the policy's `keyFields` field to `false`. @@ -306,9 +312,9 @@ const equivalentResult = cache.readQuery({ }); ``` -The cache normally obtains `__typename` information by adding the `__typename` field to every query selection set it sends to the server. It could technically use the same trick for the outermost selection set of every operation, but the `__typename` of the root query or mutation is almost always simply `"Query"` or `"Mutation"`, so the cache assumes those common defaults unless instructed otherwise in a `TypePolicy`. +The cache normally obtains `__typename` information by adding the `__typename` field to every query selection set it sends to the server. It could technically use the same trick for the outermost selection set of every operation, but the `__typename` of the root query or mutation is almost always `"Query"` or `"Mutation"`, so the cache assumes those common defaults unless instructed otherwise in a `TypePolicy`. -Compared to the `__typename`s of entity objects like `Book`s or `Person`s, which are absolutely vital to proper identification and normalization, the `__typename` of the root query or mutation type is not nearly as useful or important, because those types are singletons with only one instance per client. +Compared to the `__typename`s of entity objects like `Book` or `Person`, which are vital to proper identification and normalization, the `__typename` of the root query or mutation type is not nearly as useful or important, because those types are singletons with only one instance per client. ### The `fields` property diff --git a/docs/source/caching/cache-interaction.md b/docs/source/caching/cache-interaction.md index ff9d49f3dc6..64cb730b77b 100644 --- a/docs/source/caching/cache-interaction.md +++ b/docs/source/caching/cache-interaction.md @@ -9,9 +9,9 @@ Apollo Client supports multiple strategies for interacting with cached data: | Strategy | API | Description | |----------|-----|-------------| -| [Using GraphQL queries](#using-graphql-queries) | `readQuery` / `writeQuery` | Enables you to use standard GraphQL queries for managing both remote and local data. | -| [Using GraphQL fragments](#using-graphql-fragments) | `readFragment` / `writeFragment` | Enables you to access the fields of any cached object without composing an entire query to reach that object. | -| [Directly modifying cached fields](#using-cachemodify) | `cache.modify` | Enables you to manipulate cached data without using GraphQL at all. | +| [Using GraphQL queries](#using-graphql-queries) | `readQuery` / `writeQuery` | Use standard GraphQL queries for managing both remote and local data. | +| [Using GraphQL fragments](#using-graphql-fragments) | `readFragment` / `writeFragment` | Access the fields of any cached object without composing an entire query to reach that object. | +| [Directly modifying cached fields](#using-cachemodify) | `cache.modify` | Manipulate cached data without using GraphQL at all. | You can use whichever combination of strategies and methods are most helpful for your use case. @@ -166,13 +166,11 @@ In the example above, `readFragment` returns `null` if no `Todo` object with ID ### `writeFragment` -In addition to reading arbitrary data from the Apollo Client cache, you can _write_ arbitrary data to the cache with the `writeQuery` and `writeFragment` methods. +In addition to reading "random-access" data from the Apollo Client cache with `readFragment`, you can _write_ data to the cache with the `writeFragment` method. -> **Any changes you make to cached data with `writeQuery` and `writeFragment` are not pushed to your GraphQL server.** If you reload your environment, these changes will disappear. +> **Any changes you make to cached data with `writeFragment` are not pushed to your GraphQL server.** If you reload your environment, these changes will disappear. -These methods have the same signature as their `read` counterparts, except they require an additional `data` variable. - -For example, the following call to `writeFragment` _locally_ updates the `completed` flag for a `Todo` object with an `id` of `5`: +The `writeFragment` method resembles `readFragment`, except it requires an additional `data` variable. For example, the following call to `writeFragment` updates the `completed` flag for a `Todo` object with an `id` of `5`: ```js client.writeFragment({ @@ -274,7 +272,9 @@ A modifier function can optionally take a second parameter, which is an object t A couple of these utilities (the `readField` function and the `DELETE` sentinel object) are used in the examples below. For descriptions of all available utilities, see the [API reference](../api/cache/InMemoryCache/#modifier-function-api). -### Example: Removing an item from a list +### Examples + +#### Example: Removing an item from a list Let's say we have a blog application where each `Post` has an array of `Comment`s. Here's how we might remove a specific `Comment` from a paginated `Post.comments` array: @@ -303,7 +303,7 @@ Let's break this down: * The modifier function returns an array that filters out all comments with an ID that matches `idToRemove`. The returned array replaces the existing array in the cache. -### Example: Adding an item to a list +#### Example: Adding an item to a list Now let's look at _adding_ a `Comment` to a `Post`: @@ -346,9 +346,9 @@ When the `comments` field modifier function is called, it first calls `writeFrag As a safety check, we then scan the array of existing comment references (`existingCommentRefs`) to make sure that our new isn't already in the list. If it isn't, we add the new comment reference to the list of references, returning the full list to be stored in the cache. -### Example: Updating the cache after a mutation +#### Example: Updating the cache after a mutation -If you call `writeFragment` with an `options.data` object that the cache is able to identify, based on its `__typename` and primary key fields, you can avoid passing `options.id` to `writeFragment`. +If you call `writeFragment` with an `options.data` object that the cache is able to identify ( based on its `__typename` and cache ID fields), you can avoid passing `options.id` to `writeFragment`. Whether you provide `options.id` explicitly or let `writeFragment` figure it out using `options.data`, `writeFragment` returns a `Reference` to the identified object. @@ -384,7 +384,7 @@ In this example, `useMutation` automatically creates a `Comment` and adds it to To address this, we use the [`update` callback](../data/mutations/#updating-local-data) of `useMutation` to call `cache.modify`. Like the [previous example](#example-adding-an-item-to-a-list), we add the new comment to the list. _Unlike_ the previous example, the comment was already added to the cache by `useMutation`. Consequently, `cache.writeFragment` returns a reference to the existing object. -### Example: Deleting a field from a cached object +#### Example: Deleting a field from a cached object A modifier function's optional second parameter is an object that includes [several helpful utilities](#modifier-function-utilities), such as the `canRead` and `isReference` functions. It also includes a sentinel object called `DELETE`. @@ -401,7 +401,7 @@ cache.modify({ }); ``` -### Example: Invalidating fields within a cached object +#### Example: Invalidating fields within a cached object Normally, changing or deleting a field's value also _invalidates_ the field, causing watched queries to be reread if they previously consumed the field. @@ -418,7 +418,7 @@ cache.modify({ }); ``` -If you need to invalidate all fields within the given object, you can pass a modifier function as the value of the `fields` option: +If you need to invalidate _all_ fields within a given object, you can pass a modifier function as the value of the `fields` option: ```js cache.modify({ @@ -429,7 +429,7 @@ cache.modify({ }); ``` -When using this form of `cache.modify`, you can determine the individual field names using `details.fieldName`. This technique works for any modifier function, not just those that return `INVALIDATE`. +When using this form of `cache.modify`, you can determine individual field names using `details.fieldName`. This technique works for any modifier function, not just those that return `INVALIDATE`. ## Obtaining an object's cache ID diff --git a/docs/source/caching/garbage-collection.mdx b/docs/source/caching/garbage-collection.mdx index cc682552cba..b2cc07e6270 100644 --- a/docs/source/caching/garbage-collection.mdx +++ b/docs/source/caching/garbage-collection.mdx @@ -98,6 +98,6 @@ new InMemoryCache({ * The `read` function for `Query.ruler` returns a default ruler (Apollo) if the `existingRuler` has been deposed. * The `read` function for `Deity.offspring` filters its array to return only offspring that are alive and well in the cache. -Filtering dangling references out of a cached array field (like the `Deity.offspring` example above) is so common that Apollo Client performs this filtering _automatically_ for array fields that don't define a `read` function. You can define a `read` function to override this behavior. +Filtering dangling references out of a cached list field (like the `Deity.offspring` example above) is so common that the default `read` function for a list field performs this filtering _automatically_. You can define a custom `read` function to override this behavior. There isn't a similarly common solution for a field that contains a _single_ dangling reference (like the `Query.ruler` example above), so this is where writing a custom `read` function comes in handy most often. diff --git a/docs/source/data/mutations.mdx b/docs/source/data/mutations.mdx index e623596160e..ac4ac00e71a 100644 --- a/docs/source/data/mutations.mdx +++ b/docs/source/data/mutations.mdx @@ -165,17 +165,25 @@ If you're just getting started with Apollo Client, we recommend refetching queri ## Refetching queries -If you know that your app usually needs to refetch certain queries after a particular mutation, you can include a `refetchQueries` array in that mutation's options. +If you know that your app usually needs to refetch certain queries after a particular mutation, you can include a `refetchQueries` array in that mutation's options: -Usually, the elements of the `refetchQueries` array are the string names of the queries you want to refetch (e.g., `"GetTodos"`). The array can also include `DocumentNode` objects parsed with the `gql` function: - -```js +```js{3-6} +// Refetches two queries after mutation completes const [addTodo, { data, loading, error }] = useMutation(ADD_TODO, { - refetchQueries: ["GetTodos", MY_OTHER_QUERY], + refetchQueries: [ + GET_POST, // DocumentNode object parsed with gql + 'GetComments' // Query name + ], }); ``` -> To refer to queries by their string name, make sure each of your app's queries has a _unique_ name. +Each element in the `refetchQueries` array is one of the following: + +* A `DocumentNode` object parsed with the `gql` function +* The name of a query you've previously executed, as a string (e.g., `GetComments`) + * To refer to queries by name, make sure each of your app's queries has a _unique_ name. + +Each included query is executed with its most recently provided set of variables. You can provide the `refetchQueries` option either to `useMutation` or to the mutate function. For details, see [Option precedence](#option-precedence). From 7b4fdfbe4df1e59e618abd744013372c43fe1a93 Mon Sep 17 00:00:00 2001 From: Stephen Barlow Date: Mon, 26 Jul 2021 13:42:02 -0700 Subject: [PATCH 373/380] Keep each md paragraph on a single line in refetching article --- docs/source/data/refetching.mdx | 121 +++++++------------------------- 1 file changed, 26 insertions(+), 95 deletions(-) diff --git a/docs/source/data/refetching.mdx b/docs/source/data/refetching.mdx index a503976b1f4..92791f09113 100644 --- a/docs/source/data/refetching.mdx +++ b/docs/source/data/refetching.mdx @@ -1,30 +1,17 @@ --- -title: Refetching Queries +title: Refetching queries in Apollo Client sidebar_title: Refetching --- import RefetchQueriesOptions from '../../shared/refetchQueries-options.mdx'; -Although Apollo Client allows you to make local modifications to previously -received GraphQL data by updating the cache, sometimes the easiest way to update -your client-side GraphQL data is to _refetch_ it from the server. +Although Apollo Client allows you to make local modifications to previously received GraphQL data by updating the cache, sometimes the easiest way to update your client-side GraphQL data is to _refetch_ it from the server. -In principle, you could refetch every active query after any client-side update, -but you can save time and network bandwidth by refetching queries more -selectively, taking advantage of `InMemoryCache` to determine which watched -queries may have been invalidated by recent cache updates. +In principle, you could refetch every active query after any client-side update, but you can save time and network bandwidth by refetching queries more selectively, taking advantage of `InMemoryCache` to determine which watched queries may have been invalidated by recent cache updates. -These two approaches (local updates and refetching) work well together: if your -application displays the results of local cache modifications immediately, you -can use refetching in the background to obtain the very latest data from the -server, rerendering the UI only if there are differences between local data and -refetched data. +These two approaches (local updates and refetching) work well together: if your application displays the results of local cache modifications immediately, you can use refetching in the background to obtain the very latest data from the server, rerendering the UI only if there are differences between local data and refetched data. -Refetching is especially common after a mutation, so `client.mutate` accepts -options like `refetchQueries` and `onQueryUpdated` to specify which queries -should be refetched, and how. However, the tools of selective refetching are -available even if you are not performing a mutation, in the form of the -`client.refetchQueries` method. +Refetching is especially common after a mutation, so `client.mutate` accepts options like `refetchQueries` and `onQueryUpdated` to specify which queries should be refetched, and how. However, the tools of selective refetching are available even if you are not performing a mutation, in the form of the `client.refetchQueries` method. ## `client.refetchQueries` @@ -34,14 +21,9 @@ available even if you are not performing a mutation, in the form of the ### Refetch results -The `client.refetchQueries` method collects the `TResult` results returned by -`onQueryUpdated`, defaulting to `TResult = Promise>` if -`onQueryUpdated` is not provided, and combines the results into a single -`Promise` using `Promise.all(results)`. +The `client.refetchQueries` method collects the `TResult` results returned by `onQueryUpdated`, defaulting to `TResult = Promise>` if `onQueryUpdated` is not provided, and combines the results into a single `Promise` using `Promise.all(results)`. -> Thanks to the `Promise`-unwrapping behavior of `Promise.all`, this `TResolved` -type will often be the same type as `TResult`, except when `TResult` is a -`PromiseLike` or a `boolean`. +> Thanks to the `Promise`-unwrapping behavior of `Promise.all`, this `TResolved` type will often be the same type as `TResult`, except when `TResult` is a `PromiseLike` or a `boolean`. The returned `Promise` object has two other useful properties: @@ -50,9 +32,7 @@ The returned `Promise` object has two other useful properties: | `queries` | `ObservableQuery[]` | An array of `ObservableQuery` objects that were refetched | | `results` | `TResult[]` | An array of results that were either returned by `onQueryUpdated`, or provided by default in the absence of `onQueryUpdated`, including pending promises. If `onQueryUpdated` returns `false` for a given query, no result will be provided for that query. If `onQueryUpdated` returns `true`, the resulting `Promise>` will be included in the `results` array, rather than `true`. | -These two arrays parallel each other: they have the same length, and -`results[i]` is the result produced by `onQueryUpdated` when called with the -`ObservableQuery` found at `queries[i]`, for any index `i`. +These two arrays parallel each other: they have the same length, and `results[i]` is the result produced by `onQueryUpdated` when called with the `ObservableQuery` found at `queries[i]`, for any index `i`. ### Refetch recipes @@ -79,8 +59,7 @@ await client.refetchQueries({ }); ``` -To refetch _all_ queries managed by Apollo Client, even those with no observers, -or whose components are currently unmounted, pass `"all"` for `include`: +To refetch _all_ queries managed by Apollo Client, even those with no observers, or whose components are currently unmounted, pass `"all"` for `include`: ```ts await client.refetchQueries({ @@ -88,8 +67,7 @@ await client.refetchQueries({ }); ``` -Alternatively, you can refetch queries affected by cache updates performed in -the `updateCache` callback: +Alternatively, you can refetch queries affected by cache updates performed in the `updateCache` callback: ```ts await client.refetchQueries({ @@ -99,14 +77,7 @@ await client.refetchQueries({ }); ``` -This will refetch any queries that depend on `Query.someRootField`, without -requiring you to know in advance which queries might be included. Any -combination of cache operations (`writeQuery`, `writeFragment`, `modify`, -`evict`, etc.) is allowed within `updateCache`. Updates performed by -`updateCache` persist in the cache by default, but you can choose to perform -them in a temporary optimistic layer instead, if you want them to be discarded -immediately after `client.refetchQueries` is done observing them, leaving the -cache unchanged: +This will refetch any queries that depend on `Query.someRootField`, without requiring you to know in advance which queries might be included. Any combination of cache operations (`writeQuery`, `writeFragment`, `modify`, `evict`, etc.) is allowed within `updateCache`. Updates performed by `updateCache` persist in the cache by default, but you can choose to perform them in a temporary optimistic layer instead, if you want them to be discarded immediately after `client.refetchQueries` is done observing them, leaving the cache unchanged: ```ts await client.refetchQueries({ @@ -119,8 +90,7 @@ await client.refetchQueries({ }); ``` -Another way of updating the cache without changing cache data is to use -`cache.modify` and its `INVALIDATE` sentinel object: +Another way of updating the cache without changing cache data is to use `cache.modify` and its `INVALIDATE` sentinel object: ```ts await client.refetchQueries({ @@ -138,26 +108,11 @@ await client.refetchQueries({ }); ``` -> Before `client.refetchQueries` was introduced, the `INVALIDATE` sentinel was -[not very useful](https://github.com/apollographql/apollo-client/issues/7060#issuecomment-698026089), -since invalidated queries with `fetchPolicy: "cache-first"` would typically -re-read unchanged results, and therefore decide not to perform a network -request. The `client.refetchQueries` method makes this invalidation system more -accessible to application code, so you can control the refetching behavior of -invalidated queries. - -In all of the examples above, whether we use `include` or `updateCache`, -`client.refetchQueries` will refetch the affected queries from the network and -include the resulting `Promise>` results in the -`Promise` returned by `client.refetchQueries`. If a single query -happens to be included both by `include` and by `updateCache`, that query will -be refetched only once. In other words, the `include` option is a good way to -make sure certain queries are always included, no matter which queries are -included by `updateCache`. - -In development, you will probably want to make sure the appropriate queries are -getting refetched, rather than blindly refetching them. To intercept each query -before refetching, you can specify an `onQueryUpdated` callback: +> Before `client.refetchQueries` was introduced, the `INVALIDATE` sentinel was [not very useful](https://github.com/apollographql/apollo-client/issues/7060#issuecomment-698026089), since invalidated queries with `fetchPolicy: "cache-first"` would typically re-read unchanged results, and therefore decide not to perform a network request. The `client.refetchQueries` method makes this invalidation system more accessible to application code, so you can control the refetching behavior of invalidated queries. + +In all of the examples above, whether we use `include` or `updateCache`, `client.refetchQueries` will refetch the affected queries from the network and include the resulting `Promise>` results in the `Promise` returned by `client.refetchQueries`. If a single query happens to be included both by `include` and by `updateCache`, that query will be refetched only once. In other words, the `include` option is a good way to make sure certain queries are always included, no matter which queries are included by `updateCache`. + +In development, you will probably want to make sure the appropriate queries are getting refetched, rather than blindly refetching them. To intercept each query before refetching, you can specify an `onQueryUpdated` callback: ```ts const results = await client.refetchQueries({ @@ -183,12 +138,9 @@ results.forEach(result => { }); ``` -Notice how adding `onQueryUpdated` in this example did not change the refetching -behavior of `client.refetchQueries`, allowing us to use `onQueryUpdated` purely -for diagnostic or debugging purposes. +Notice how adding `onQueryUpdated` in this example did not change the refetching behavior of `client.refetchQueries`, allowing us to use `onQueryUpdated` purely for diagnostic or debugging purposes. -If you want to skip certain queries that would otherwise be included, return -`false` from `onQueryUpdated`: +If you want to skip certain queries that would otherwise be included, return `false` from `onQueryUpdated`: ```ts await client.refetchQueries({ @@ -213,10 +165,7 @@ await client.refetchQueries({ }); ``` -In case the `ObservableQuery` does not provide enough information, you can also -examine the latest `result` for the query, along with information about its -`complete`ness and `missing` fields, using the `Cache.DiffResult` object passed -as the second parameter to `onQueryUpdated`: +In case the `ObservableQuery` does not provide enough information, you can also examine the latest `result` for the query, along with information about its `complete`ness and `missing` fields, using the `Cache.DiffResult` object passed as the second parameter to `onQueryUpdated`: ```ts await client.refetchQueries({ @@ -241,8 +190,7 @@ await client.refetchQueries({ }); ``` -Because `onQueryUpdated` has the ability to filter queries dynamically, it also -pairs well with the bulk `include` options mentioned above: +Because `onQueryUpdated` has the ability to filter queries dynamically, it also pairs well with the bulk `include` options mentioned above: ```ts await client.refetchQueries({ @@ -257,11 +205,7 @@ await client.refetchQueries({ }); ``` -In the examples above we `await client.refetchQueries(...)` to find out the -final `ApolloQueryResult` results for all the refetched queries. This -combined promise is created with `Promise.all`, so a single failure will reject -the entire `Promise`, potentially hiding other successful results. -If this is a problem, you can use the `queries` and `results` arrays returned by +In the examples above we `await client.refetchQueries(...)` to find out the final `ApolloQueryResult` results for all the refetched queries. This combined promise is created with `Promise.all`, so a single failure will reject the entire `Promise`, potentially hiding other successful results. If this is a problem, you can use the `queries` and `results` arrays returned by `client.refetchQueries` instead of (or in addition to) `await`ing the `Promise`: ```ts @@ -279,28 +223,15 @@ const finalResults = await Promise.all( }); ``` -In the future, just as additional input options may be added to the -`client.refetchQueries` method, additional properties may be added to its result -object, supplementing its `Promise`-related properties and the `queries` and -`results` arrays. +In the future, just as additional input options may be added to the `client.refetchQueries` method, additional properties may be added to its result object, supplementing its `Promise`-related properties and the `queries` and `results` arrays. -If you discover that some specific additional `client.refetchQueries` input -options or result properties would be useful, please feel free to [open an -issue](https://github.com/apollographql/apollo-feature-requests/issues) or -[start a discussion](https://github.com/apollographql/apollo-client/discussions) -explaining your use case(s). +If you discover that some specific additional `client.refetchQueries` input options or result properties would be useful, please feel free to [open an issue](https://github.com/apollographql/apollo-feature-requests/issues) or [start a discussion](https://github.com/apollographql/apollo-client/discussions) explaining your use case(s). ### Relationship to `client.mutate` options -For refetching after a mutation, the `client.mutate` method supports options -similar to `client.refetchQueries`, which you should use instead of -`client.refetchQueries`, because it's important for refetching logic to happen -at specific times during the mutation process. +For refetching after a mutation, the `client.mutate` method supports options similar to `client.refetchQueries`, which you should use instead of `client.refetchQueries`, because it's important for refetching logic to happen at specific times during the mutation process. -For historical reasons, the `client.mutate` options have slightly different -names from the new `client.refetchQueries` options, but their internal -implementation is substantially the same, so you can translate between them -using the following table: +For historical reasons, the `client.mutate` options have slightly different names from the new `client.refetchQueries` options, but their internal implementation is substantially the same, so you can translate between them using the following table: | `client.mutate(options)` | | `client.refetchQueries(options)` | | - | - | - | From 7856c187ebf89df80c446834ab238e5475e6114d Mon Sep 17 00:00:00 2001 From: Stephen Barlow Date: Tue, 27 Jul 2021 08:35:56 -0700 Subject: [PATCH 374/380] Style edits to refetching docs (#8547) --- docs/shared/refetchQueries-options.mdx | 45 +++++------ docs/source/data/refetching.mdx | 105 +++++++++++++++++++------ 2 files changed, 100 insertions(+), 50 deletions(-) diff --git a/docs/shared/refetchQueries-options.mdx b/docs/shared/refetchQueries-options.mdx index 067fe709f29..97da9ab988a 100644 --- a/docs/shared/refetchQueries-options.mdx +++ b/docs/shared/refetchQueries-options.mdx @@ -1,15 +1,3 @@ - - - - - - - - - - - - +
Name /
Type
Description
- The `client.refetchQueries` method take an `options` object that conforms to the following TypeScript interface: @@ -29,10 +17,16 @@ interface RefetchQueriesOptions< } ``` -Descriptions of these fields can be found in the table below. +These fields are described below: -
+ + + + + + + @@ -60,10 +53,9 @@ queries whose data were affected by those cache updates. @@ -103,9 +96,7 @@ from being refetched.
Name /
Type
Description
@@ -44,8 +38,7 @@ Descriptions of these fields can be found in the table below. -Optional function that updates the cache as a way of triggering refetches of -queries whose data were affected by those cache updates. +Optional function that updates cached fields to trigger refetches of queries that include those fields.
-Optional array specifying the names or `DocumentNode` objects of queries to be -refetched. +Optional array specifying queries to refetch. Each element can be either a query's string name or a `DocumentNode` object. -Analogous to the `options.refetchQueries` array for mutations. +Analogous to the [`options.refetchQueries`](https://www.apollographql.com/docs/react/data/mutations/#options) array for mutations. Pass `"active"` (or `"all"`) as a shorthand to refetch all (active) queries. @@ -81,13 +73,14 @@ Pass `"active"` (or `"all"`) as a shorthand to refetch all (active) queries. -Optional callback function that will be called for each `ObservableQuery` -affected by `options.updateCache` or specified by `options.include`. +Optional callback function that's called once for each `ObservableQuery` that's either affected by `options.updateCache` or listed in `options.include` (or both). If `onQueryUpdated` is not provided, the default implementation returns the result of calling `observableQuery.refetch()`. When `onQueryUpdated` is -provided, it can dynamically decide whether and how each query should be -refetched. Returning `false` from `onQueryUpdated` will prevent the given query +provided, it can dynamically decide whether (and how) each query should be +refetched. + +Returning `false` from `onQueryUpdated` prevents the associated query from being refetched. -If `true`, run `options.updateCache` in a temporary optimistic layer of -`InMemoryCache`, so its modifications can be discarded from the cache after -observing which fields it invalidated. +If `true`, the `options.updateCache` function is executed on a temporary optimistic layer of `InMemoryCache`, so its modifications can be discarded from the cache after observing which fields it invalidated. Defaults to `false`, meaning `options.updateCache` updates the cache in a lasting way. diff --git a/docs/source/data/refetching.mdx b/docs/source/data/refetching.mdx index 92791f09113..70e9b7783cc 100644 --- a/docs/source/data/refetching.mdx +++ b/docs/source/data/refetching.mdx @@ -5,37 +5,84 @@ sidebar_title: Refetching import RefetchQueriesOptions from '../../shared/refetchQueries-options.mdx'; -Although Apollo Client allows you to make local modifications to previously received GraphQL data by updating the cache, sometimes the easiest way to update your client-side GraphQL data is to _refetch_ it from the server. +Apollo Client allows you to make local modifications to your GraphQL data by [updating the cache](./mutations/#updating-the-cache-directly), but sometimes it's more straightforward to update your client-side GraphQL data by refetching queries from the server. -In principle, you could refetch every active query after any client-side update, but you can save time and network bandwidth by refetching queries more selectively, taking advantage of `InMemoryCache` to determine which watched queries may have been invalidated by recent cache updates. +In theory, you could refetch _every_ active query after a client-side update, but you can save time and network bandwidth by refetching queries more selectively. The `InMemoryCache` helps you determine _which_ active queries might have been invalidated by recent cache updates. -These two approaches (local updates and refetching) work well together: if your application displays the results of local cache modifications immediately, you can use refetching in the background to obtain the very latest data from the server, rerendering the UI only if there are differences between local data and refetched data. +Local cache updates and refetching work especially well in combination: your application can display the results of local cache modifications immediately, while _also_ refetching in the background to obtain the very latest data from the server. The UI is then rerendered only if there are differences between local data and refetched data. -Refetching is especially common after a mutation, so `client.mutate` accepts options like `refetchQueries` and `onQueryUpdated` to specify which queries should be refetched, and how. However, the tools of selective refetching are available even if you are not performing a mutation, in the form of the `client.refetchQueries` method. +Refetching is especially common after a mutation, so [mutate functions](./mutations/#executing-a-mutation) accept options like [`refetchQueries`](./mutations/#refetching-queries) and [`onQueryUpdated`](./mutations/#refetching-after-update) to specify which queries should be refetched, and how. + +To selectively refetch queries _outside_ of a mutation, you instead use the `refetchQueries` method of `ApolloClient`, which is documented here. ## `client.refetchQueries` +> This method is new in Apollo Client 3.4. + ### Refetch options ### Refetch results -The `client.refetchQueries` method collects the `TResult` results returned by `onQueryUpdated`, defaulting to `TResult = Promise>` if `onQueryUpdated` is not provided, and combines the results into a single `Promise` using `Promise.all(results)`. +The `client.refetchQueries` method collects the `TResult` results returned by `onQueryUpdated`, defaulting to `TResult = Promise>` if `onQueryUpdated` is not provided. It combines those results into a single `Promise` using `Promise.all(results)`. -> Thanks to the `Promise`-unwrapping behavior of `Promise.all`, this `TResolved` type will often be the same type as `TResult`, except when `TResult` is a `PromiseLike` or a `boolean`. +> Thanks to the `Promise`-unwrapping behavior of `Promise.all`, this `TResolved` type is often the same type as `TResult`, except when `TResult` is a `PromiseLike` or a `boolean`. The returned `Promise` object has two other useful properties: -| Property | Type | Description | -| - | - | - | -| `queries` | `ObservableQuery[]` | An array of `ObservableQuery` objects that were refetched | -| `results` | `TResult[]` | An array of results that were either returned by `onQueryUpdated`, or provided by default in the absence of `onQueryUpdated`, including pending promises. If `onQueryUpdated` returns `false` for a given query, no result will be provided for that query. If `onQueryUpdated` returns `true`, the resulting `Promise>` will be included in the `results` array, rather than `true`. | + + + + + + + + + + + + + + + + + + + + + + +
Name /
Type
Description
+ +###### `queries` + +`ObservableQuery[]` + + +An array of `ObservableQuery` objects that were refetched. + +
+ +###### `results` + +`TResult[]` + + +An array of results that were either returned by `onQueryUpdated`, or provided by default in the absence of `onQueryUpdated`, including pending promises. + +If `onQueryUpdated` returns `false` for a given query, no result is provided for that query. + +If `onQueryUpdated` returns `true`, the resulting `Promise>` is included in the `results` array instead of `true`. + +
These two arrays parallel each other: they have the same length, and `results[i]` is the result produced by `onQueryUpdated` when called with the `ObservableQuery` found at `queries[i]`, for any index `i`. ### Refetch recipes +#### Refetching a specific query + To refetch a specific query by name, use the `include` option by itself: ```ts @@ -52,14 +99,16 @@ await client.refetchQueries({ }); ``` -To refetch all active queries, pass the `"active"` shorthand for `include`: +#### Refetching all queries + +To refetch all _active_ queries, pass the `"active"` shorthand for `include`: ```ts await client.refetchQueries({ include: "active", }); ``` -To refetch _all_ queries managed by Apollo Client, even those with no observers, or whose components are currently unmounted, pass `"all"` for `include`: +To refetch _all_ queries managed by Apollo Client (even those with no observers, or whose components are currently unmounted), pass `"all"` for `include`: ```ts await client.refetchQueries({ @@ -67,7 +116,9 @@ await client.refetchQueries({ }); ``` -Alternatively, you can refetch queries affected by cache updates performed in the `updateCache` callback: +#### Refetching queries affected by cache updates + +You can refetch queries affected by cache updates performed in the `updateCache` callback: ```ts await client.refetchQueries({ @@ -77,7 +128,9 @@ await client.refetchQueries({ }); ``` -This will refetch any queries that depend on `Query.someRootField`, without requiring you to know in advance which queries might be included. Any combination of cache operations (`writeQuery`, `writeFragment`, `modify`, `evict`, etc.) is allowed within `updateCache`. Updates performed by `updateCache` persist in the cache by default, but you can choose to perform them in a temporary optimistic layer instead, if you want them to be discarded immediately after `client.refetchQueries` is done observing them, leaving the cache unchanged: +This refetches any queries that depend on `Query.someRootField`, without requiring you to know in advance which queries might be included. Any combination of cache operations (`writeQuery`, `writeFragment`, `modify`, `evict`, etc.) is allowed within `updateCache`. + +Updates performed by `updateCache` persist in the cache by default. You can perform them in a temporary optimistic layer instead, if you want them to be discarded immediately after `client.refetchQueries` is done observing them, leaving the cache unchanged: ```ts await client.refetchQueries({ @@ -90,7 +143,7 @@ await client.refetchQueries({ }); ``` -Another way of updating the cache without changing cache data is to use `cache.modify` and its `INVALIDATE` sentinel object: +Another way to "update" the cache without actually changing cache data is to use `cache.modify` and its `INVALIDATE` sentinel object: ```ts await client.refetchQueries({ @@ -108,11 +161,15 @@ await client.refetchQueries({ }); ``` -> Before `client.refetchQueries` was introduced, the `INVALIDATE` sentinel was [not very useful](https://github.com/apollographql/apollo-client/issues/7060#issuecomment-698026089), since invalidated queries with `fetchPolicy: "cache-first"` would typically re-read unchanged results, and therefore decide not to perform a network request. The `client.refetchQueries` method makes this invalidation system more accessible to application code, so you can control the refetching behavior of invalidated queries. +> Before `client.refetchQueries` was introduced, the `INVALIDATE` sentinel was [not very useful](https://github.com/apollographql/apollo-client/issues/7060#issuecomment-698026089), because invalidated queries with `fetchPolicy: "cache-first"` would typically re-read unchanged results, and therefore decide not to perform a network request. The `client.refetchQueries` method makes this invalidation system more accessible to application code, so you can control the refetching behavior of invalidated queries. -In all of the examples above, whether we use `include` or `updateCache`, `client.refetchQueries` will refetch the affected queries from the network and include the resulting `Promise>` results in the `Promise` returned by `client.refetchQueries`. If a single query happens to be included both by `include` and by `updateCache`, that query will be refetched only once. In other words, the `include` option is a good way to make sure certain queries are always included, no matter which queries are included by `updateCache`. +In all of the examples above, whether we use `include` or `updateCache`, `client.refetchQueries` refetches affected queries from the network and includes the resulting `Promise>` results in the `Promise` returned by `client.refetchQueries`. -In development, you will probably want to make sure the appropriate queries are getting refetched, rather than blindly refetching them. To intercept each query before refetching, you can specify an `onQueryUpdated` callback: +If a particular query is included both by `include` and by `updateCache`, that query is refetched only once. In other words, the `include` option is a good way to make sure certain queries are always included, no matter which queries are included by `updateCache`. + +#### Refetching selectively + +In development, you probably want to make sure the appropriate queries are getting refetched, rather than blindly refetching them. To intercept each query before refetching, you can specify an `onQueryUpdated` callback: ```ts const results = await client.refetchQueries({ @@ -205,7 +262,9 @@ await client.refetchQueries({ }); ``` -In the examples above we `await client.refetchQueries(...)` to find out the final `ApolloQueryResult` results for all the refetched queries. This combined promise is created with `Promise.all`, so a single failure will reject the entire `Promise`, potentially hiding other successful results. If this is a problem, you can use the `queries` and `results` arrays returned by +#### Handling refetch errors + +In the examples above, we `await client.refetchQueries(...)` to find out the final `ApolloQueryResult` results for all the refetched queries. This combined promise is created with `Promise.all`, so a single failure rejects the entire `Promise`, potentially hiding other successful results. If this is a problem, you can use the `queries` and `results` arrays returned by `client.refetchQueries` instead of (or in addition to) `await`ing the `Promise`: ```ts @@ -227,15 +286,15 @@ In the future, just as additional input options may be added to the `client.refe If you discover that some specific additional `client.refetchQueries` input options or result properties would be useful, please feel free to [open an issue](https://github.com/apollographql/apollo-feature-requests/issues) or [start a discussion](https://github.com/apollographql/apollo-client/discussions) explaining your use case(s). -### Relationship to `client.mutate` options +### Corresponding `client.mutate` options -For refetching after a mutation, the `client.mutate` method supports options similar to `client.refetchQueries`, which you should use instead of `client.refetchQueries`, because it's important for refetching logic to happen at specific times during the mutation process. +For refetching after a mutation, `client.mutate` supports [options](./mutations/#options) similar to `client.refetchQueries`, which you should use instead of `client.refetchQueries`, because it's important for refetching logic to happen at specific times during the mutation process. -For historical reasons, the `client.mutate` options have slightly different names from the new `client.refetchQueries` options, but their internal implementation is substantially the same, so you can translate between them using the following table: +For historical reasons, `client.mutate` options have slightly different names from the new `client.refetchQueries` options, but their internal implementation is substantially the same, so you can translate between them using the following table: | `client.mutate(options)` | | `client.refetchQueries(options)` | | - | - | - | | [`options.refetchQueries`](./mutations/#refetching-queries) | ⇔ | `options.include` | | [`options.update`](./mutations/#the-update-function) | ⇔ | `options.updateCache` | | [`options.onQueryUpdated`](./mutations/#refetching-after-update) | ⇔ | `options.onQueryUpdated` | -| [`options.awaitRefetchQueries`](./mutations/#options) | ⇔ | just return a `Promise` from `onQueryUpdated` | +| [`options.awaitRefetchQueries`](./mutations/#options) | ⇔ | Return a `Promise` from `onQueryUpdated` | From 3b2d25bcebc30a3026f64eabc5082faab2787bff Mon Sep 17 00:00:00 2001 From: Guilherme Ananias Date: Tue, 13 Apr 2021 10:15:15 -0300 Subject: [PATCH 375/380] add optional chaining to obsFetchMore return --- src/react/data/QueryData.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/react/data/QueryData.ts b/src/react/data/QueryData.ts index b6d05cde5ea..3e96c305758 100644 --- a/src/react/data/QueryData.ts +++ b/src/react/data/QueryData.ts @@ -495,7 +495,7 @@ export class QueryData extends OperationData< private obsFetchMore = ( fetchMoreOptions: FetchMoreQueryOptions & FetchMoreOptions - ) => this.currentObservable!.fetchMore(fetchMoreOptions); + ) => this.currentObservable?.fetchMore(fetchMoreOptions); private obsUpdateQuery = ( mapFn: ( From 15762ca84a8dcfa71a50789ee0933b7218d4b4c6 Mon Sep 17 00:00:00 2001 From: Guilherme Ananias Date: Tue, 13 Apr 2021 10:15:35 -0300 Subject: [PATCH 376/380] add optional chaining to obsSubscribeToMore return --- src/react/data/QueryData.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/react/data/QueryData.ts b/src/react/data/QueryData.ts index 3e96c305758..6a506d18109 100644 --- a/src/react/data/QueryData.ts +++ b/src/react/data/QueryData.ts @@ -521,7 +521,7 @@ export class QueryData extends OperationData< TSubscriptionVariables, TSubscriptionData > - ) => this.currentObservable!.subscribeToMore(options); + ) => this.currentObservable?.subscribeToMore(options); private observableQueryFields() { return { From e52764e3d6da1b795beb1096feb475fa55de669b Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Wed, 28 Jul 2021 11:47:37 -0400 Subject: [PATCH 377/380] add optional chaining to obsUpdateQuery return --- src/react/data/QueryData.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/react/data/QueryData.ts b/src/react/data/QueryData.ts index 6a506d18109..953979818c9 100644 --- a/src/react/data/QueryData.ts +++ b/src/react/data/QueryData.ts @@ -502,7 +502,7 @@ export class QueryData extends OperationData< previousQueryResult: TData, options: UpdateQueryOptions ) => TData - ) => this.currentObservable!.updateQuery(mapFn); + ) => this.currentObservable?.updateQuery(mapFn); private obsStartPolling = (pollInterval: number) => { this.currentObservable?.startPolling(pollInterval); From b677262e6536d8187078499def93f0a228a87a1d Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Wed, 28 Jul 2021 11:48:26 -0400 Subject: [PATCH 378/380] Reinstate MutationUpdaterFn for back-compat, with deprecation note. Following a suggestion from @bennypowers in this comment: https://github.com/apollographql/apollo-client/issues/7895#issuecomment-887480944 --- CHANGELOG.md | 2 +- src/core/types.ts | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba5f1438126..2d029366408 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,7 +37,7 @@ - The `FetchMoreQueryOptions` type now takes two instead of three type parameters (``), thanks to using `Partial` instead of `K extends typeof TVariables` and `Pick`.
[@ArnaudBarre](https://github.com/ArnaudBarre) in [#7476](https://github.com/apollographql/apollo-client/pull/7476) -- Pass `variables` and `context` to a mutation's `update` function
+- Pass `variables` and `context` to a mutation's `update` function. **Note:** The type of the `update` function is now named `MutationUpdaterFunction` rather than `MutationUpdaterFn`, since the older type was [broken beyond repair](https://github.com/apollographql/apollo-client/issues/8506#issuecomment-881706613). If you are using `MutationUpdaterFn` in your own code, please use `MutationUpdaterFunction` instead.
[@jcreighton](https://github.com/jcreighton) in [#7902](https://github.com/apollographql/apollo-client/pull/7902) - A `resultCacheMaxSize` option may be passed to the `InMemoryCache` constructor to limit the number of result objects that will be retained in memory (to speed up repeated reads), and calling `cache.reset()` now releases all such memory.
diff --git a/src/core/types.ts b/src/core/types.ts index 7d7adc73dc1..e846ccc5640 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -158,6 +158,15 @@ export type MutationQueryReducersMap = { [queryName: string]: MutationQueryReducer; }; +// @deprecated Use MutationUpdaterFunction instead. +export type MutationUpdaterFn = ( + // The MutationUpdaterFn type is broken because it mistakenly uses the same + // type parameter T for both the cache and the mutationResult. Do not use this + // type unless you absolutely need it for backwards compatibility. + cache: ApolloCache, + mutationResult: FetchResult, +) => void; + export type MutationUpdaterFunction< TData, TVariables, From 67a0ecb8b426f1acfc29384d88a824cb9862050c Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Wed, 28 Jul 2021 11:59:02 -0400 Subject: [PATCH 379/380] update CHANGELOG.md --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba5f1438126..f626e837c67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -84,6 +84,8 @@ - The [`nextFetchPolicy`](https://github.com/apollographql/apollo-client/pull/6893) option for `client.watchQuery` and `useQuery` will no longer be removed from the `options` object after it has been applied, and instead will continue to be applied any time `options.fetchPolicy` is reset to another value, until/unless the `options.nextFetchPolicy` property is removed from `options`.
[@benjamn](https://github.com/benjamn) in [#8465](https://github.com/apollographql/apollo-client/pull/8465) +- The `fetchMore`, `subscribeToMore`, and `updateQuery` functions returned from the `useQuery` hook may now return undefined in edge cases where the functions are called when the component is unmounted
[@noghartt](https://github.com/noghartt) in [#7980](https://github.com/apollographql/apollo-client/pull/7980). + ### Bug fixes - In Apollo Client 2.x, a `refetch` operation would always replace existing data in the cache. With the introduction of field policy `merge` functions in Apollo Client 3, existing field values could be inappropriately combined with incoming field values by a custom `merge` function that does not realize a `refetch` has happened. @@ -123,6 +125,8 @@ - Make `readField` default to reading from current object only when the `from` option/argument is actually omitted, not when `from` is passed to `readField` with an undefined value. A warning will be printed when this situation occurs.
[@benjamn](https://github.com/benjamn) in [#8508](https://github.com/apollographql/apollo-client/pull/8508) +- The `fetchMore`, `subscribeToMore`, and `updateQuery` functions no longer throw `undefined` errors
[@noghartt](https://github.com/noghartt) in [#7980](https://github.com/apollographql/apollo-client/pull/7980). + ## Apollo Client 3.3.21 ### Bug fixes From 550ec23afd30dcd35716b8d8bfb4578b8f101ef1 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Mon, 26 Jul 2021 14:17:52 -0500 Subject: [PATCH 380/380] Bump @apollo/client npm version to 3.4.0 (final). :tada: --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index f98be338482..dcd3f0a5cea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-rc.23", + "version": "3.4.0", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 4b721ba0d65..5cbefd39b1a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.4.0-rc.23", + "version": "3.4.0", "description": "A fully-featured caching GraphQL client.", "private": true, "keywords": [