Skip to content

Commit c95848e

Browse files
Fix fetchMore for queries with no-cache fetch policies (#11974)
Co-authored-by: Lenz Weber-Tronic <[email protected]> Co-authored-by: jerelmiller <[email protected]>
1 parent 076bb63 commit c95848e

File tree

8 files changed

+462
-46
lines changed

8 files changed

+462
-46
lines changed

.changeset/nice-worms-juggle.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@apollo/client": patch
3+
---
4+
5+
Fix an issue where `fetchMore` would write its result data to the cache when using it with a `no-cache` fetch policy.

.changeset/pink-horses-pay.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@apollo/client": patch
3+
---
4+
5+
Fix an issue where executing `fetchMore` with a `no-cache` fetch policy could sometimes result in multiple network requests.

.changeset/short-scissors-speak.md

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@apollo/client": patch
3+
---
4+
5+
**Potentially disruptive change**
6+
7+
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.

.size-limits.json

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
{
2-
"dist/apollo-client.min.cjs": 40168,
3-
"import { ApolloClient, InMemoryCache, HttpLink } from \"dist/index.js\" (production)": 32974
2+
"dist/apollo-client.min.cjs": 40243,
3+
"import { ApolloClient, InMemoryCache, HttpLink } from \"dist/index.js\" (production)": 33041
44
}

docs/source/pagination/core-api.mdx

+71-1
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,7 @@ const cache = new InMemoryCache({
199199
Query: {
200200
fields: {
201201
feed: {
202-
keyArgs: [],
202+
keyArgs: false,
203203
merge(existing, incoming, { args: { offset = 0 }}) {
204204
// Slicing is necessary because the existing data is
205205
// immutable, and frozen in development.
@@ -218,6 +218,41 @@ const cache = new InMemoryCache({
218218

219219
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.
220220

221+
### Updating the query with the fetch more result
222+
223+
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.
224+
225+
As a shortcut, you can provide the `updateQuery` option to `fetchMore` to update your query using the result from the `fetchMore` call.
226+
227+
<Note>
228+
229+
`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`.
230+
231+
</Note>
232+
233+
Let's see the example above using `updateQuery` to merge results together instead of a field policy merge function:
234+
235+
```ts
236+
fetchMore({
237+
variables: { offset: data.feed.length },
238+
updateQuery(previousData, { fetchMoreResult, variables: { offset }}) {
239+
// Slicing is necessary because the existing data is
240+
// immutable, and frozen in development.
241+
const updatedFeed = previousData.feed.slice(0);
242+
for (let i = 0; i < fetchMoreResult.feed.length; ++i) {
243+
updatedFeed[offset + i] = fetchMoreResult.feed[i];
244+
}
245+
return { ...previousData, feed: updatedFeed };
246+
},
247+
})
248+
```
249+
250+
<Tip>
251+
252+
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.
253+
254+
</Tip>
255+
221256
## `read` functions for paginated fields
222257

223258
[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
304339
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.
305340
306341
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.
342+
343+
## Using `fetchMore` with queries that set a `no-cache` fetch policy
344+
345+
<Note>
346+
347+
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.
348+
349+
</Note>
350+
351+
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.
352+
353+
To update our query, we provide the `updateQuery` option to the `fetchMore` function.
354+
355+
Let's use the example above, but instead provide the `updateQuery` function to `fetchMore` to update the query.
356+
357+
```ts
358+
fetchMore({
359+
variables: { offset: data.feed.length },
360+
updateQuery(previousData, { fetchMoreResult, variables: { offset }}) {
361+
// Slicing is necessary because the existing data is
362+
// immutable, and frozen in development.
363+
const updatedFeed = previousData.feed.slice(0);
364+
for (let i = 0; i < fetchMoreResult.feed.length; ++i) {
365+
updatedFeed[offset + i] = fetchMoreResult.feed[i];
366+
}
367+
return { ...previousData, feed: updatedFeed };
368+
},
369+
})
370+
```
371+
372+
<Note>
373+
374+
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.
375+
376+
</Note>

src/core/ObservableQuery.ts

+76-42
Original file line numberDiff line numberDiff line change
@@ -484,6 +484,16 @@ Did you mean to call refetch(variables) instead of refetch({ variables })?`,
484484

485485
const updatedQuerySet = new Set<DocumentNode>();
486486

487+
const updateQuery = fetchMoreOptions?.updateQuery;
488+
const isCached = this.options.fetchPolicy !== "no-cache";
489+
490+
if (!isCached) {
491+
invariant(
492+
updateQuery,
493+
"You must provide an `updateQuery` function when using `fetchMore` with a `no-cache` fetch policy."
494+
);
495+
}
496+
487497
return this.queryManager
488498
.fetchQuery(qid, combinedOptions, NetworkStatus.fetchMore)
489499
.then((fetchMoreResult) => {
@@ -493,48 +503,72 @@ Did you mean to call refetch(variables) instead of refetch({ variables })?`,
493503
queryInfo.networkStatus = originalNetworkStatus;
494504
}
495505

496-
// Performing this cache update inside a cache.batch transaction ensures
497-
// any affected cache.watch watchers are notified at most once about any
498-
// updates. Most watchers will be using the QueryInfo class, which
499-
// responds to notifications by calling reobserveCacheFirst to deliver
500-
// fetchMore cache results back to this ObservableQuery.
501-
this.queryManager.cache.batch({
502-
update: (cache) => {
503-
const { updateQuery } = fetchMoreOptions;
504-
if (updateQuery) {
505-
cache.updateQuery(
506-
{
507-
query: this.query,
508-
variables: this.variables,
509-
returnPartialData: true,
510-
optimistic: false,
511-
},
512-
(previous) =>
513-
updateQuery(previous!, {
514-
fetchMoreResult: fetchMoreResult.data,
515-
variables: combinedOptions.variables as TFetchVars,
516-
})
517-
);
518-
} else {
519-
// If we're using a field policy instead of updateQuery, the only
520-
// thing we need to do is write the new data to the cache using
521-
// combinedOptions.variables (instead of this.variables, which is
522-
// what this.updateQuery uses, because it works by abusing the
523-
// original field value, keyed by the original variables).
524-
cache.writeQuery({
525-
query: combinedOptions.query,
526-
variables: combinedOptions.variables,
527-
data: fetchMoreResult.data,
528-
});
529-
}
530-
},
506+
if (isCached) {
507+
// Performing this cache update inside a cache.batch transaction ensures
508+
// any affected cache.watch watchers are notified at most once about any
509+
// updates. Most watchers will be using the QueryInfo class, which
510+
// responds to notifications by calling reobserveCacheFirst to deliver
511+
// fetchMore cache results back to this ObservableQuery.
512+
this.queryManager.cache.batch({
513+
update: (cache) => {
514+
const { updateQuery } = fetchMoreOptions;
515+
if (updateQuery) {
516+
cache.updateQuery(
517+
{
518+
query: this.query,
519+
variables: this.variables,
520+
returnPartialData: true,
521+
optimistic: false,
522+
},
523+
(previous) =>
524+
updateQuery(previous!, {
525+
fetchMoreResult: fetchMoreResult.data,
526+
variables: combinedOptions.variables as TFetchVars,
527+
})
528+
);
529+
} else {
530+
// If we're using a field policy instead of updateQuery, the only
531+
// thing we need to do is write the new data to the cache using
532+
// combinedOptions.variables (instead of this.variables, which is
533+
// what this.updateQuery uses, because it works by abusing the
534+
// original field value, keyed by the original variables).
535+
cache.writeQuery({
536+
query: combinedOptions.query,
537+
variables: combinedOptions.variables,
538+
data: fetchMoreResult.data,
539+
});
540+
}
541+
},
531542

532-
onWatchUpdated: (watch) => {
533-
// Record the DocumentNode associated with any watched query whose
534-
// data were updated by the cache writes above.
535-
updatedQuerySet.add(watch.query);
536-
},
537-
});
543+
onWatchUpdated: (watch) => {
544+
// Record the DocumentNode associated with any watched query whose
545+
// data were updated by the cache writes above.
546+
updatedQuerySet.add(watch.query);
547+
},
548+
});
549+
} else {
550+
// There is a possibility `lastResult` may not be set when
551+
// `fetchMore` is called which would cause this to crash. This should
552+
// only happen if we haven't previously reported a result. We don't
553+
// quite know what the right behavior should be here since this block
554+
// of code runs after the fetch result has executed on the network.
555+
// We plan to let it crash in the meantime.
556+
//
557+
// If we get bug reports due to the `data` property access on
558+
// undefined, this should give us a real-world scenario that we can
559+
// use to test against and determine the right behavior. If we do end
560+
// up changing this behavior, this may require, for example, an
561+
// adjustment to the types on `updateQuery` since that function
562+
// expects that the first argument always contains previous result
563+
// data, but not `undefined`.
564+
const lastResult = this.getLast("result")!;
565+
const data = updateQuery!(lastResult.data, {
566+
fetchMoreResult: fetchMoreResult.data,
567+
variables: combinedOptions.variables as TFetchVars,
568+
});
569+
570+
this.reportResult({ ...lastResult, data }, this.variables);
571+
}
538572

539573
return fetchMoreResult;
540574
})
@@ -544,7 +578,7 @@ Did you mean to call refetch(variables) instead of refetch({ variables })?`,
544578
// likely because the written data were the same as what was already in
545579
// the cache, we still want fetchMore to deliver its final loading:false
546580
// result with the unchanged data.
547-
if (!updatedQuerySet.has(this.query)) {
581+
if (isCached && !updatedQuerySet.has(this.query)) {
548582
reobserveCacheFirst(this);
549583
}
550584
});

0 commit comments

Comments
 (0)