From c95848e859fb7ce0b3b9439ac71dff880f991450 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 1 Aug 2024 16:45:01 -0600 Subject: [PATCH] Fix `fetchMore` for queries with `no-cache` fetch policies (#11974) Co-authored-by: Lenz Weber-Tronic Co-authored-by: jerelmiller --- .changeset/nice-worms-juggle.md | 5 + .changeset/pink-horses-pay.md | 5 + .changeset/short-scissors-speak.md | 7 + .size-limits.json | 4 +- docs/source/pagination/core-api.mdx | 72 ++++- src/core/ObservableQuery.ts | 118 +++++--- src/react/hooks/__tests__/useQuery.test.tsx | 295 ++++++++++++++++++++ src/testing/internal/scenarios/index.ts | 2 +- 8 files changed, 462 insertions(+), 46 deletions(-) create mode 100644 .changeset/nice-worms-juggle.md create mode 100644 .changeset/pink-horses-pay.md create mode 100644 .changeset/short-scissors-speak.md diff --git a/.changeset/nice-worms-juggle.md b/.changeset/nice-worms-juggle.md new file mode 100644 index 00000000000..92aa1051b69 --- /dev/null +++ b/.changeset/nice-worms-juggle.md @@ -0,0 +1,5 @@ +--- +"@apollo/client": patch +--- + +Fix an issue where `fetchMore` would write its result data to the cache when using it with a `no-cache` fetch policy. diff --git a/.changeset/pink-horses-pay.md b/.changeset/pink-horses-pay.md new file mode 100644 index 00000000000..c2773153ddf --- /dev/null +++ b/.changeset/pink-horses-pay.md @@ -0,0 +1,5 @@ +--- +"@apollo/client": patch +--- + +Fix an issue where executing `fetchMore` with a `no-cache` fetch policy could sometimes result in multiple network requests. diff --git a/.changeset/short-scissors-speak.md b/.changeset/short-scissors-speak.md new file mode 100644 index 00000000000..38a6d8aa45b --- /dev/null +++ b/.changeset/short-scissors-speak.md @@ -0,0 +1,7 @@ +--- +"@apollo/client": patch +--- + +**Potentially disruptive change** + +When calling `fetchMore` with a query that has a `no-cache` fetch policy, `fetchMore` will now throw if an `updateQuery` function is not provided. This provides a mechanism to merge the results from the `fetchMore` call with the query's previous result. diff --git a/.size-limits.json b/.size-limits.json index 6f43705ee6e..0e52c9ae9c7 100644 --- a/.size-limits.json +++ b/.size-limits.json @@ -1,4 +1,4 @@ { - "dist/apollo-client.min.cjs": 40168, - "import { ApolloClient, InMemoryCache, HttpLink } from \"dist/index.js\" (production)": 32974 + "dist/apollo-client.min.cjs": 40243, + "import { ApolloClient, InMemoryCache, HttpLink } from \"dist/index.js\" (production)": 33041 } diff --git a/docs/source/pagination/core-api.mdx b/docs/source/pagination/core-api.mdx index 6d4f0b0b0f3..d1aee410565 100644 --- a/docs/source/pagination/core-api.mdx +++ b/docs/source/pagination/core-api.mdx @@ -199,7 +199,7 @@ const cache = new InMemoryCache({ Query: { fields: { feed: { - keyArgs: [], + keyArgs: false, merge(existing, incoming, { args: { offset = 0 }}) { // Slicing is necessary because the existing data is // immutable, and frozen in development. @@ -218,6 +218,41 @@ const cache = new InMemoryCache({ This logic handles sequential page writes the same way the single-line strategy does, but it can also tolerate repeated, overlapping, or out-of-order writes, without duplicating any list items. +### Updating the query with the fetch more result + +At times the call to `fetchMore` may need to perform additional cache updates for your query. While you can use the [`cache.readQuery`](/caching/cache-interaction#readquery) and [`cache.writeQuery`](/caching/cache-interaction#writequery) functions to to do this work yourself, it can be cumbsersome to use both of these together. + +As a shortcut, you can provide the `updateQuery` option to `fetchMore` to update your query using the result from the `fetchMore` call. + + + +`updateQuery` is not a replacement for your field policy `merge` functions. While you can use `updateQuery` without the need to define a `merge` function, `merge` functions defined for fields in the query will run using the result from `updateQuery`. + + + +Let's see the example above using `updateQuery` to merge results together instead of a field policy merge function: + +```ts +fetchMore({ + variables: { offset: data.feed.length }, + updateQuery(previousData, { fetchMoreResult, variables: { offset }}) { + // Slicing is necessary because the existing data is + // immutable, and frozen in development. + const updatedFeed = previousData.feed.slice(0); + for (let i = 0; i < fetchMoreResult.feed.length; ++i) { + updatedFeed[offset + i] = fetchMoreResult.feed[i]; + } + return { ...previousData, feed: updatedFeed }; + }, +}) +``` + + + +We recommend defining field policies that contain at least a [`keyArgs`](/pagination/key-args/) value even when you use `updateQuery`. This prevents fragmenting the data unnecessarily in the cache. Setting `keyArgs` to `false` is adequate for most situations to ignore the `offset` and `limit` arguments and write the paginated data as one big array. + + + ## `read` functions for paginated fields [As shown above](#defining-a-field-policy), a `merge` function helps you combine paginated query results from your GraphQL server into a single list in your client cache. But what if you also want to configure how that locally cached list is _read_? For that, you can define a **`read` function**. @@ -304,3 +339,38 @@ The `read` function for a paginated field can choose to _ignore_ arguments like If you adopt this approach, you might not need to define a `read` function at all, because the cached list can be returned without any processing. That's why the [`offsetLimitPagination` helper function](./offset-based/#the-offsetlimitpagination-helper) is implemented _without_ a `read` function. Regardless of how you configure `keyArgs`, your `read` (and `merge`) functions can always examine any arguments passed to the field using the `options.args` object. See [The `keyArgs` API](./key-args) for a deeper discussion of how to reason about dividing argument-handling responsibility between `keyArgs` and your `read` or `merge` functions. + +## Using `fetchMore` with queries that set a `no-cache` fetch policy + + + +We recommend upgrading to version 3.11.3 or later to address bugs that exhibit unexpected behavior when using `fetchMore` with queries that set `no-cache` fetch policies. Please see pull request [#11974](https://github.com/apollographql/apollo-client/pull/11974) for more information. + + + +The examples shown above use field policies and `merge` functions to update the result of a paginated field. But what about queries that use a `no-cache` fetch policy? Data is not written to the cache, so field policies have no effect. + +To update our query, we provide the `updateQuery` option to the `fetchMore` function. + +Let's use the example above, but instead provide the `updateQuery` function to `fetchMore` to update the query. + +```ts +fetchMore({ + variables: { offset: data.feed.length }, + updateQuery(previousData, { fetchMoreResult, variables: { offset }}) { + // Slicing is necessary because the existing data is + // immutable, and frozen in development. + const updatedFeed = previousData.feed.slice(0); + for (let i = 0; i < fetchMoreResult.feed.length; ++i) { + updatedFeed[offset + i] = fetchMoreResult.feed[i]; + } + return { ...previousData, feed: updatedFeed }; + }, +}) +``` + + + +As of Apollo Client version 3.11.3, the `updateQuery` option is required when using `fetchMore` with a `no-cache` fetch policy. This is required to correctly determine how the results should be merged since field policy `merge` functions are ignored. Calling `fetchMore` without an `updateQuery` function will throw an error. + + diff --git a/src/core/ObservableQuery.ts b/src/core/ObservableQuery.ts index 7a419ff078e..1289a9735ef 100644 --- a/src/core/ObservableQuery.ts +++ b/src/core/ObservableQuery.ts @@ -484,6 +484,16 @@ Did you mean to call refetch(variables) instead of refetch({ variables })?`, const updatedQuerySet = new Set(); + const updateQuery = fetchMoreOptions?.updateQuery; + const isCached = this.options.fetchPolicy !== "no-cache"; + + if (!isCached) { + invariant( + updateQuery, + "You must provide an `updateQuery` function when using `fetchMore` with a `no-cache` fetch policy." + ); + } + return this.queryManager .fetchQuery(qid, combinedOptions, NetworkStatus.fetchMore) .then((fetchMoreResult) => { @@ -493,48 +503,72 @@ Did you mean to call refetch(variables) instead of refetch({ variables })?`, queryInfo.networkStatus = originalNetworkStatus; } - // Performing this cache update inside a cache.batch transaction ensures - // any affected cache.watch watchers are notified at most once about any - // updates. Most watchers will be using the QueryInfo class, which - // responds to notifications by calling reobserveCacheFirst to deliver - // fetchMore cache results back to this ObservableQuery. - this.queryManager.cache.batch({ - update: (cache) => { - const { updateQuery } = fetchMoreOptions; - if (updateQuery) { - cache.updateQuery( - { - query: this.query, - variables: this.variables, - returnPartialData: true, - optimistic: false, - }, - (previous) => - updateQuery(previous!, { - fetchMoreResult: fetchMoreResult.data, - variables: combinedOptions.variables as TFetchVars, - }) - ); - } else { - // If we're using a field policy instead of updateQuery, the only - // thing we need to do is write the new data to the cache using - // combinedOptions.variables (instead of this.variables, which is - // what this.updateQuery uses, because it works by abusing the - // original field value, keyed by the original variables). - cache.writeQuery({ - query: combinedOptions.query, - variables: combinedOptions.variables, - data: fetchMoreResult.data, - }); - } - }, + if (isCached) { + // Performing this cache update inside a cache.batch transaction ensures + // any affected cache.watch watchers are notified at most once about any + // updates. Most watchers will be using the QueryInfo class, which + // responds to notifications by calling reobserveCacheFirst to deliver + // fetchMore cache results back to this ObservableQuery. + this.queryManager.cache.batch({ + update: (cache) => { + const { updateQuery } = fetchMoreOptions; + if (updateQuery) { + cache.updateQuery( + { + query: this.query, + variables: this.variables, + returnPartialData: true, + optimistic: false, + }, + (previous) => + updateQuery(previous!, { + fetchMoreResult: fetchMoreResult.data, + variables: combinedOptions.variables as TFetchVars, + }) + ); + } else { + // If we're using a field policy instead of updateQuery, the only + // thing we need to do is write the new data to the cache using + // combinedOptions.variables (instead of this.variables, which is + // what this.updateQuery uses, because it works by abusing the + // original field value, keyed by the original variables). + cache.writeQuery({ + query: combinedOptions.query, + variables: combinedOptions.variables, + data: fetchMoreResult.data, + }); + } + }, - onWatchUpdated: (watch) => { - // Record the DocumentNode associated with any watched query whose - // data were updated by the cache writes above. - updatedQuerySet.add(watch.query); - }, - }); + onWatchUpdated: (watch) => { + // Record the DocumentNode associated with any watched query whose + // data were updated by the cache writes above. + updatedQuerySet.add(watch.query); + }, + }); + } else { + // There is a possibility `lastResult` may not be set when + // `fetchMore` is called which would cause this to crash. This should + // only happen if we haven't previously reported a result. We don't + // quite know what the right behavior should be here since this block + // of code runs after the fetch result has executed on the network. + // We plan to let it crash in the meantime. + // + // If we get bug reports due to the `data` property access on + // undefined, this should give us a real-world scenario that we can + // use to test against and determine the right behavior. If we do end + // up changing this behavior, this may require, for example, an + // adjustment to the types on `updateQuery` since that function + // expects that the first argument always contains previous result + // data, but not `undefined`. + const lastResult = this.getLast("result")!; + const data = updateQuery!(lastResult.data, { + fetchMoreResult: fetchMoreResult.data, + variables: combinedOptions.variables as TFetchVars, + }); + + this.reportResult({ ...lastResult, data }, this.variables); + } return fetchMoreResult; }) @@ -544,7 +578,7 @@ Did you mean to call refetch(variables) instead of refetch({ variables })?`, // likely because the written data were the same as what was already in // the cache, we still want fetchMore to deliver its final loading:false // result with the unchanged data. - if (!updatedQuerySet.has(this.query)) { + if (isCached && !updatedQuerySet.has(this.query)) { reobserveCacheFirst(this); } }); diff --git a/src/react/hooks/__tests__/useQuery.test.tsx b/src/react/hooks/__tests__/useQuery.test.tsx index 19a1ba57687..7c87e28a92f 100644 --- a/src/react/hooks/__tests__/useQuery.test.tsx +++ b/src/react/hooks/__tests__/useQuery.test.tsx @@ -7,6 +7,7 @@ import { render, screen, waitFor, renderHook } from "@testing-library/react"; import { ApolloClient, ApolloError, + ApolloQueryResult, NetworkStatus, OperationVariables, TypedDocumentNode, @@ -32,12 +33,15 @@ import { useMutation } from "../useMutation"; import { createProfiler, disableActWarnings, + PaginatedCaseData, profileHook, + setupPaginatedCase, spyOnConsole, } from "../../../testing/internal"; import { useApolloClient } from "../useApolloClient"; import { useLazyQuery } from "../useLazyQuery"; import { mockFetchQuery } from "../../../core/__tests__/ObservableQuery"; +import { InvariantError } from "../../../utilities/globals"; const IS_REACT_17 = React.version.startsWith("17"); @@ -4049,6 +4053,297 @@ describe("useQuery Hook", () => { expect(result.current.data).toEqual({ letters: ab.concat(cd) }); }); + // https://github.com/apollographql/apollo-client/issues/11965 + it("should only execute single network request when calling fetchMore with no-cache fetch policy", async () => { + let fetches: Array<{ variables: Record }> = []; + const { query, data } = setupPaginatedCase(); + + const link = new ApolloLink((operation) => { + fetches.push({ variables: operation.variables }); + + const { offset = 0, limit = 2 } = operation.variables; + const letters = data.slice(offset, offset + limit); + + return new Observable((observer) => { + setTimeout(() => { + observer.next({ data: { letters } }); + observer.complete(); + }, 10); + }); + }); + + const client = new ApolloClient({ cache: new InMemoryCache(), link }); + + const ProfiledHook = profileHook(() => + useQuery(query, { fetchPolicy: "no-cache", variables: { limit: 2 } }) + ); + + render(, { + wrapper: ({ children }) => ( + {children} + ), + }); + + // loading + await ProfiledHook.takeSnapshot(); + // finished loading + await ProfiledHook.takeSnapshot(); + + expect(fetches).toStrictEqual([{ variables: { limit: 2 } }]); + + const { fetchMore } = ProfiledHook.getCurrentSnapshot(); + + await act(() => + fetchMore({ + variables: { offset: 2 }, + updateQuery: (_, { fetchMoreResult }) => fetchMoreResult, + }) + ); + + expect(fetches).toStrictEqual([ + { variables: { limit: 2 } }, + { variables: { limit: 2, offset: 2 } }, + ]); + }); + + it("uses updateQuery to update the result of the query with no-cache queries", async () => { + const { query, link } = setupPaginatedCase(); + + const client = new ApolloClient({ cache: new InMemoryCache(), link }); + + const ProfiledHook = profileHook(() => + useQuery(query, { + notifyOnNetworkStatusChange: true, + fetchPolicy: "no-cache", + variables: { limit: 2 }, + }) + ); + + render(, { + wrapper: ({ children }) => ( + {children} + ), + }); + + { + const { loading, networkStatus, data } = + await ProfiledHook.takeSnapshot(); + + expect(loading).toBe(true); + expect(networkStatus).toBe(NetworkStatus.loading); + expect(data).toBeUndefined(); + } + + { + const { loading, networkStatus, data } = + await ProfiledHook.takeSnapshot(); + + expect(loading).toBe(false); + expect(networkStatus).toBe(NetworkStatus.ready); + expect(data).toStrictEqual({ + letters: [ + { __typename: "Letter", letter: "A", position: 1 }, + { __typename: "Letter", letter: "B", position: 2 }, + ], + }); + } + + let fetchMorePromise!: Promise>; + const { fetchMore } = ProfiledHook.getCurrentSnapshot(); + + act(() => { + fetchMorePromise = fetchMore({ + variables: { offset: 2 }, + updateQuery: (prev, { fetchMoreResult }) => ({ + letters: prev.letters.concat(fetchMoreResult.letters), + }), + }); + }); + + { + const { loading, networkStatus, data } = + await ProfiledHook.takeSnapshot(); + + expect(loading).toBe(true); + expect(networkStatus).toBe(NetworkStatus.fetchMore); + expect(data).toStrictEqual({ + letters: [ + { __typename: "Letter", letter: "A", position: 1 }, + { __typename: "Letter", letter: "B", position: 2 }, + ], + }); + } + + { + const { loading, networkStatus, data, observable } = + await ProfiledHook.takeSnapshot(); + + expect(loading).toBe(false); + expect(networkStatus).toBe(NetworkStatus.ready); + expect(data).toEqual({ + letters: [ + { __typename: "Letter", letter: "A", position: 1 }, + { __typename: "Letter", letter: "B", position: 2 }, + { __typename: "Letter", letter: "C", position: 3 }, + { __typename: "Letter", letter: "D", position: 4 }, + ], + }); + + // Ensure we store the merged result as the last result + expect(observable.getCurrentResult(false).data).toEqual({ + letters: [ + { __typename: "Letter", letter: "A", position: 1 }, + { __typename: "Letter", letter: "B", position: 2 }, + { __typename: "Letter", letter: "C", position: 3 }, + { __typename: "Letter", letter: "D", position: 4 }, + ], + }); + } + + await expect(fetchMorePromise).resolves.toStrictEqual({ + data: { + letters: [ + { __typename: "Letter", letter: "C", position: 3 }, + { __typename: "Letter", letter: "D", position: 4 }, + ], + }, + loading: false, + networkStatus: NetworkStatus.ready, + }); + + await expect(ProfiledHook).not.toRerender(); + + act(() => { + fetchMorePromise = fetchMore({ + variables: { offset: 4 }, + updateQuery: (_, { fetchMoreResult }) => fetchMoreResult, + }); + }); + + { + const { data, loading, networkStatus } = + await ProfiledHook.takeSnapshot(); + + expect(loading).toBe(true); + expect(networkStatus).toBe(NetworkStatus.fetchMore); + expect(data).toEqual({ + letters: [ + { __typename: "Letter", letter: "A", position: 1 }, + { __typename: "Letter", letter: "B", position: 2 }, + { __typename: "Letter", letter: "C", position: 3 }, + { __typename: "Letter", letter: "D", position: 4 }, + ], + }); + } + + { + const { data, loading, networkStatus, observable } = + await ProfiledHook.takeSnapshot(); + + expect(loading).toBe(false); + expect(networkStatus).toBe(NetworkStatus.ready); + expect(data).toEqual({ + letters: [ + { __typename: "Letter", letter: "E", position: 5 }, + { __typename: "Letter", letter: "F", position: 6 }, + ], + }); + + expect(observable.getCurrentResult(false).data).toEqual({ + letters: [ + { __typename: "Letter", letter: "E", position: 5 }, + { __typename: "Letter", letter: "F", position: 6 }, + ], + }); + } + + await expect(fetchMorePromise).resolves.toStrictEqual({ + data: { + letters: [ + { __typename: "Letter", letter: "E", position: 5 }, + { __typename: "Letter", letter: "F", position: 6 }, + ], + }, + loading: false, + networkStatus: NetworkStatus.ready, + }); + + await expect(ProfiledHook).not.toRerender(); + }); + + it("throws when using fetchMore without updateQuery for no-cache queries", async () => { + const { query, link } = setupPaginatedCase(); + + const client = new ApolloClient({ cache: new InMemoryCache(), link }); + + const ProfiledHook = profileHook(() => + useQuery(query, { fetchPolicy: "no-cache", variables: { limit: 2 } }) + ); + + render(, { + wrapper: ({ children }) => ( + {children} + ), + }); + + // loading + await ProfiledHook.takeSnapshot(); + // finished loading + await ProfiledHook.takeSnapshot(); + + const { fetchMore } = ProfiledHook.getCurrentSnapshot(); + + expect(() => fetchMore({ variables: { offset: 2 } })).toThrow( + new InvariantError( + "You must provide an `updateQuery` function when using `fetchMore` with a `no-cache` fetch policy." + ) + ); + }); + + it("does not write to cache when using fetchMore with no-cache queries", async () => { + const { query, data } = setupPaginatedCase(); + + const link = new ApolloLink((operation) => { + const { offset = 0, limit = 2 } = operation.variables; + const letters = data.slice(offset, offset + limit); + + return new Observable((observer) => { + setTimeout(() => { + observer.next({ data: { letters } }); + observer.complete(); + }, 10); + }); + }); + + const client = new ApolloClient({ cache: new InMemoryCache(), link }); + + const ProfiledHook = profileHook(() => + useQuery(query, { fetchPolicy: "no-cache", variables: { limit: 2 } }) + ); + + render(, { + wrapper: ({ children }) => ( + {children} + ), + }); + + // initial loading + await ProfiledHook.takeSnapshot(); + + // Initial result + await ProfiledHook.takeSnapshot(); + + const { fetchMore } = ProfiledHook.getCurrentSnapshot(); + await act(() => + fetchMore({ + variables: { offset: 2 }, + updateQuery: (_, { fetchMoreResult }) => fetchMoreResult, + }) + ); + + expect(client.extract()).toStrictEqual({}); + }); + it("regression test for issue #8600", async () => { const cache = new InMemoryCache({ typePolicies: { diff --git a/src/testing/internal/scenarios/index.ts b/src/testing/internal/scenarios/index.ts index 2ed2bb5c523..c6943f51236 100644 --- a/src/testing/internal/scenarios/index.ts +++ b/src/testing/internal/scenarios/index.ts @@ -106,5 +106,5 @@ export function setupPaginatedCase() { }); }); - return { query, link }; + return { query, link, data }; }