Skip to content

Commit

Permalink
Better handle cached data with deferred queries (#10334)
Browse files Browse the repository at this point in the history
* Better handle cached data with deferred queries

When using `useQuery` with deferred queries that already have cache data
written, the initial chunk of data would overwrite anything in the
cache, which meant cached data for deferred chunks would disappear. This
is now better handled by merging existing cache data with the initial
deferred chunk to ensure a complete result set is still returned.

* Add changeset
  • Loading branch information
jerelmiller committed Dec 8, 2022
1 parent f982a8d commit 7d92393
Show file tree
Hide file tree
Showing 4 changed files with 213 additions and 2 deletions.
5 changes: 5 additions & 0 deletions .changeset/shiny-balloons-matter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@apollo/client': patch
---

Better handle deferred queries that have cached or partial cached data for them
2 changes: 1 addition & 1 deletion config/bundlesize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { join } from "path";
import { gzipSync } from "zlib";
import bytes from "bytes";

const gzipBundleByteLengthLimit = bytes("31.85KB");
const gzipBundleByteLengthLimit = bytes("31.87KB");
const minFile = join("dist", "apollo-client.min.cjs");
const minPath = join(__dirname, "..", minFile);
const gzipByteLen = gzipSync(readFileSync(minPath)).byteLength;
Expand Down
12 changes: 11 additions & 1 deletion src/core/QueryInfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,7 @@ export class QueryInfo {
| "errorPolicy">,
cacheWriteBehavior: CacheWriteBehavior,
) {
const merger = new DeepMerger();
const graphQLErrors = isNonEmptyArray(result.errors)
? result.errors.slice(0)
: [];
Expand All @@ -373,7 +374,7 @@ export class QueryInfo {

if ('incremental' in result && isNonEmptyArray(result.incremental)) {
let mergedData = this.getDiff().result;
const merger = new DeepMerger();

result.incremental.forEach(({ data, path, errors }) => {
for (let i = path.length - 1; i >= 0; --i) {
const key = path[i];
Expand All @@ -388,6 +389,15 @@ export class QueryInfo {
mergedData = merger.merge(mergedData, data);
});
result.data = mergedData;

// Detect the first chunk of a deferred query and merge it with existing
// cache data. This ensures a `cache-first` fetch policy that returns
// partial cache data or a `cache-and-network` fetch policy that already
// has full data in the cache does not complain when trying to merge the
// initial deferred server data with existing cache data.
} else if ('hasNext' in result && result.hasNext) {
const diff = this.getDiff();
result.data = merger.merge(diff.result, result.data)
}

this.graphQLErrors = graphQLErrors;
Expand Down
196 changes: 196 additions & 0 deletions src/react/hooks/__tests__/useQuery.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5647,5 +5647,201 @@ describe('useQuery Hook', () => {
}
});
});

it('returns eventually consistent data from deferred queries with data in the cache while using a "cache-and-network" fetch policy', async () => {
const query = gql`
query {
greeting {
message
... on Greeting @defer {
recipient {
name
}
}
}
}
`;

const link = new MockSubscriptionLink();
const cache = new InMemoryCache();
const client = new ApolloClient({ cache, link });

cache.writeQuery({
query,
data: {
greeting: {
__typename: 'Greeting',
message: 'Hello cached',
recipient: { __typename: 'Person', name: 'Cached Alice' },
},
},
});

const { result, waitForNextUpdate } = renderHook(
() => useQuery(query, { fetchPolicy: 'cache-and-network' }),
{
wrapper: ({ children }) => (
<ApolloProvider client={client}>
{children}
</ApolloProvider>
),
}
);

expect(result.current.loading).toBe(true);
expect(result.current.networkStatus).toBe(NetworkStatus.loading);
expect(result.current.data).toEqual({
greeting: {
message: 'Hello cached',
__typename: 'Greeting',
recipient: { __typename: 'Person', name: 'Cached Alice' },
},
});

link.simulateResult({
result: {
data: { greeting: { __typename: 'Greeting', message: 'Hello world' } },
hasNext: true,
},
});

await waitForNextUpdate();
expect(result.current.loading).toBe(false);
expect(result.current.networkStatus).toBe(NetworkStatus.ready);
expect(result.current.data).toEqual({
greeting: {
__typename: 'Greeting',
message: 'Hello world',
recipient: { __typename: 'Person', name: 'Cached Alice' },
},
});

link.simulateResult({
result: {
incremental: [
{
data: {
recipient: { name: 'Alice', __typename: 'Person' },
__typename: 'Greeting',
},
path: ['greeting'],
},
],
hasNext: false,
},
});

await waitForNextUpdate();
expect(result.current.loading).toBe(false);
expect(result.current.networkStatus).toBe(NetworkStatus.ready);
expect(result.current.data).toEqual({
greeting: {
__typename: 'Greeting',
message: 'Hello world',
recipient: { __typename: 'Person', name: 'Alice' },
},
});
});

it('returns eventually consistent data from deferred queries with partial data in the cache and using a "cache-first" fetch policy with `returnPartialData`', async () => {
const query = gql`
query {
greeting {
message
... on Greeting @defer {
recipient {
name
}
}
}
}
`;

const cache = new InMemoryCache();
const link = new MockSubscriptionLink();
const client = new ApolloClient({ cache, link });

// We know we are writing partial data to the cache so suppress the console
// warning.
const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
cache.writeQuery({
query,
data: {
greeting: {
__typename: 'Greeting',
recipient: { __typename: 'Person', name: 'Cached Alice' },
},
},
});
consoleSpy.mockRestore();

const { result, waitForNextUpdate } = renderHook(
() =>
useQuery(query, {
fetchPolicy: 'cache-first',
returnPartialData: true
}),
{
wrapper: ({ children }) => (
<ApolloProvider client={client}>
{children}
</ApolloProvider>
),
}
);

expect(result.current.loading).toBe(true);
expect(result.current.networkStatus).toBe(NetworkStatus.loading);
expect(result.current.data).toEqual({
greeting: {
__typename: 'Greeting',
recipient: { __typename: 'Person', name: 'Cached Alice' },
},
});

link.simulateResult({
result: {
data: { greeting: { message: 'Hello world', __typename: 'Greeting' } },
hasNext: true,
},
});

await waitForNextUpdate();
expect(result.current.loading).toBe(false);
expect(result.current.networkStatus).toBe(NetworkStatus.ready);
expect(result.current.data).toEqual({
greeting: {
__typename: 'Greeting',
message: 'Hello world',
recipient: { __typename: 'Person', name: 'Cached Alice' },
},
});

link.simulateResult({
result: {
incremental: [
{
data: {
__typename: 'Greeting',
recipient: { name: 'Alice', __typename: 'Person' },
},
path: ['greeting'],
},
],
hasNext: false,
},
});

await waitForNextUpdate();
expect(result.current.loading).toBe(false);
expect(result.current.networkStatus).toBe(NetworkStatus.ready);
expect(result.current.data).toEqual({
greeting: {
__typename: 'Greeting',
message: 'Hello world',
recipient: { __typename: 'Person', name: 'Alice' },
},
});
});
});
});

0 comments on commit 7d92393

Please sign in to comment.