From b1e558c1dbce2e2ad8d153f7f4f3f34f4dc76d43 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Mon, 5 Dec 2022 21:13:09 -0700 Subject: [PATCH] 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. --- src/core/QueryInfo.ts | 13 +- src/react/hooks/__tests__/useQuery.test.tsx | 196 ++++++++++++++++++++ 2 files changed, 208 insertions(+), 1 deletion(-) diff --git a/src/core/QueryInfo.ts b/src/core/QueryInfo.ts index 844ebe13cab..20df962efdc 100644 --- a/src/core/QueryInfo.ts +++ b/src/core/QueryInfo.ts @@ -363,6 +363,7 @@ export class QueryInfo { | "errorPolicy">, cacheWriteBehavior: CacheWriteBehavior, ) { + const merger = new DeepMerger(); const graphQLErrors = isNonEmptyArray(result.errors) ? result.errors.slice(0) : []; @@ -371,9 +372,19 @@ export class QueryInfo { // requests. To allow future notify timeouts, diff and dirty are reset as well. this.reset(); + // 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. + if ('hasNext' in result && result.hasNext && !('incremental' in result)) { + const diff = this.getDiff(); + result.data = merger.merge(diff.result, result.data) + } + 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]; diff --git a/src/react/hooks/__tests__/useQuery.test.tsx b/src/react/hooks/__tests__/useQuery.test.tsx index 1b023b058ec..d0bc999bd13 100644 --- a/src/react/hooks/__tests__/useQuery.test.tsx +++ b/src/react/hooks/__tests__/useQuery.test.tsx @@ -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 }) => ( + + {children} + + ), + } + ); + + 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 }) => ( + + {children} + + ), + } + ); + + 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' }, + }, + }); + }); }); });