diff --git a/.changeset/ten-cherries-clap.md b/.changeset/ten-cherries-clap.md new file mode 100644 index 00000000000..f4d570511f3 --- /dev/null +++ b/.changeset/ten-cherries-clap.md @@ -0,0 +1,5 @@ +--- +'@apollo/client': patch +--- + +`useSuspenseQuery` and `useBackgroundQuery` will now properly apply changes to its options between renders. diff --git a/.size-limit.cjs b/.size-limit.cjs index 3f854cf9fa9..c9c78069eb9 100644 --- a/.size-limit.cjs +++ b/.size-limit.cjs @@ -1,7 +1,7 @@ const checks = [ { path: "dist/apollo-client.min.cjs", - limit: "37731" + limit: "37915" }, { path: "dist/main.cjs", @@ -10,7 +10,7 @@ const checks = [ { path: "dist/index.js", import: "{ ApolloClient, InMemoryCache, HttpLink }", - limit: "33389" + limit: "33413" }, ...[ "ApolloProvider", diff --git a/src/core/ObservableQuery.ts b/src/core/ObservableQuery.ts index cbb198b0252..483d8bd49ec 100644 --- a/src/core/ObservableQuery.ts +++ b/src/core/ObservableQuery.ts @@ -592,6 +592,13 @@ Did you mean to call refetch(variables) instead of refetch({ variables })?`, return this.reobserve(newOptions); } + public silentSetOptions( + newOptions: Partial>, + ) { + const mergedOptions = compact(this.options, newOptions || {}); + assign(this.options, mergedOptions); + } + /** * Update the variables of this observable query, and fetch the new results * if they've changed. Most users should prefer `refetch` instead of diff --git a/src/react/cache/QueryReference.ts b/src/react/cache/QueryReference.ts index 2da24aea881..477771e4851 100644 --- a/src/react/cache/QueryReference.ts +++ b/src/react/cache/QueryReference.ts @@ -1,3 +1,4 @@ +import { equal } from '@wry/equality'; import type { ApolloError, ApolloQueryResult, @@ -33,6 +34,15 @@ interface InternalQueryReferenceOptions { autoDisposeTimeoutMs?: number; } +const OBSERVED_CHANGED_OPTIONS: Array = [ + 'canonizeResults', + 'context', + 'errorPolicy', + 'fetchPolicy', + 'refetchWritePolicy', + 'returnPartialData', +]; + export class InternalQueryReference { public result: ApolloQueryResult; public readonly key: CacheKey; @@ -102,6 +112,35 @@ export class InternalQueryReference { return this.observable.options; } + didChangeOptions(watchQueryOptions: WatchQueryOptions) { + return OBSERVED_CHANGED_OPTIONS.some( + (option) => + !equal(this.watchQueryOptions[option], watchQueryOptions[option]) + ); + } + + applyOptions(watchQueryOptions: WatchQueryOptions) { + const { fetchPolicy: currentFetchPolicy } = this.watchQueryOptions; + + // "standby" is used when `skip` is set to `true`. Detect when we've + // enabled the query (i.e. `skip` is `false`) to execute a network request. + if ( + currentFetchPolicy === 'standby' && + currentFetchPolicy !== watchQueryOptions.fetchPolicy + ) { + this.promise = this.observable.reobserve(watchQueryOptions); + } else { + this.observable.silentSetOptions(watchQueryOptions); + + // Maintain the previous result in case the current result does not return + // a `data` property. + this.result = { ...this.result, ...this.observable.getCurrentResult() }; + this.promise = createFulfilledPromise(this.result); + } + + return this.promise; + } + listen(listener: Listener) { // As soon as the component listens for updates, we know it has finished // suspending and is ready to receive updates, so we can remove the auto diff --git a/src/react/hooks/__tests__/useBackgroundQuery.test.tsx b/src/react/hooks/__tests__/useBackgroundQuery.test.tsx index dedce0e08da..9485da84a32 100644 --- a/src/react/hooks/__tests__/useBackgroundQuery.test.tsx +++ b/src/react/hooks/__tests__/useBackgroundQuery.test.tsx @@ -50,8 +50,12 @@ import { RefetchFunction, QueryReference, } from '../../../react'; -import { SuspenseQueryHookOptions } from '../../types/types'; +import { + SuspenseQueryHookFetchPolicy, + SuspenseQueryHookOptions, +} from '../../types/types'; import equal from '@wry/equality'; +import { RefetchWritePolicy } from '../../../core/watchQueryOptions'; function renderIntegrationTest({ client, @@ -1974,6 +1978,828 @@ describe('useBackgroundQuery', () => { }); }); + it('applies `errorPolicy` on next fetch when it changes between renders', async () => { + interface Data { + greeting: string; + } + + const user = userEvent.setup(); + + const query: TypedDocumentNode = gql` + query { + greeting + } + `; + + const mocks = [ + { + request: { query }, + result: { data: { greeting: 'Hello' } }, + }, + { + request: { query }, + result: { + errors: [new GraphQLError('oops')], + }, + }, + ]; + + const client = new ApolloClient({ + link: new MockLink(mocks), + cache: new InMemoryCache(), + }); + + const suspenseCache = new SuspenseCache(); + + function SuspenseFallback() { + return
Loading...
; + } + + function Parent() { + const [errorPolicy, setErrorPolicy] = React.useState('none'); + const [queryRef, { refetch }] = useBackgroundQuery(query, { + errorPolicy, + }); + + return ( + <> + + + }> + + + + ); + } + + function Greeting({ queryRef }: { queryRef: QueryReference }) { + const { data, error } = useReadQuery(queryRef); + + return error ? ( +
{error.message}
+ ) : ( +
{data.greeting}
+ ); + } + + function App() { + return ( + + Error boundary} + > + + + + ); + } + + render(); + + expect(await screen.findByTestId('greeting')).toHaveTextContent('Hello'); + + await act(() => user.click(screen.getByText('Change error policy'))); + await act(() => user.click(screen.getByText('Refetch greeting'))); + + // Ensure we aren't rendering the error boundary and instead rendering the + // error message in the Greeting component. + expect(await screen.findByTestId('error')).toHaveTextContent('oops'); + }); + + it('applies `context` on next fetch when it changes between renders', async () => { + interface Data { + context: Record; + } + + const user = userEvent.setup(); + + const query: TypedDocumentNode = gql` + query { + context + } + `; + + const link = new ApolloLink((operation) => { + return Observable.of({ + data: { + context: operation.getContext(), + }, + }); + }); + + const client = new ApolloClient({ + link, + cache: new InMemoryCache(), + }); + + const suspenseCache = new SuspenseCache(); + + function SuspenseFallback() { + return
Loading...
; + } + + function Parent() { + const [phase, setPhase] = React.useState('initial'); + const [queryRef, { refetch }] = useBackgroundQuery(query, { + context: { phase }, + }); + + return ( + <> + + + }> + + + + ); + } + + function Context({ queryRef }: { queryRef: QueryReference }) { + const { data } = useReadQuery(queryRef); + + return
{data.context.phase}
; + } + + function App() { + return ( + + + + ); + } + + render(); + + expect(await screen.findByTestId('context')).toHaveTextContent('initial'); + + await act(() => user.click(screen.getByText('Update context'))); + await act(() => user.click(screen.getByText('Refetch'))); + + expect(await screen.findByTestId('context')).toHaveTextContent('rerender'); + }); + + // NOTE: We only test the `false` -> `true` path here. If the option changes + // from `true` -> `false`, the data has already been canonized, so it has no + // effect on the output. + it('returns canonical results immediately when `canonizeResults` changes from `false` to `true` between renders', async () => { + interface Result { + __typename: string; + value: number; + } + + interface Data { + results: Result[]; + } + + const cache = new InMemoryCache({ + typePolicies: { + Result: { + keyFields: false, + }, + }, + }); + + const query: TypedDocumentNode = gql` + query { + results { + value + } + } + `; + + const results: Result[] = [ + { __typename: 'Result', value: 0 }, + { __typename: 'Result', value: 1 }, + { __typename: 'Result', value: 1 }, + { __typename: 'Result', value: 2 }, + { __typename: 'Result', value: 3 }, + { __typename: 'Result', value: 5 }, + ]; + + const user = userEvent.setup(); + + cache.writeQuery({ + query, + data: { results }, + }); + + const client = new ApolloClient({ + link: new MockLink([]), + cache, + }); + + const result: { current: Data | null } = { + current: null, + }; + + const suspenseCache = new SuspenseCache(); + + function SuspenseFallback() { + return
Loading...
; + } + + function Parent() { + const [canonizeResults, setCanonizeResults] = React.useState(false); + const [queryRef] = useBackgroundQuery(query, { + canonizeResults, + }); + + return ( + <> + + }> + + + + ); + } + + function Results({ queryRef }: { queryRef: QueryReference }) { + const { data } = useReadQuery(queryRef); + + result.current = data; + + return null; + } + + function App() { + return ( + + + + ); + } + + render(); + + function verifyCanonicalResults(data: Data, canonized: boolean) { + const resultSet = new Set(data.results); + const values = Array.from(resultSet).map((item) => item.value); + + expect(data).toEqual({ results }); + + if (canonized) { + expect(data.results.length).toBe(6); + expect(resultSet.size).toBe(5); + expect(values).toEqual([0, 1, 2, 3, 5]); + } else { + expect(data.results.length).toBe(6); + expect(resultSet.size).toBe(6); + expect(values).toEqual([0, 1, 1, 2, 3, 5]); + } + } + + verifyCanonicalResults(result.current!, false); + + await act(() => user.click(screen.getByText('Canonize results'))); + + verifyCanonicalResults(result.current!, true); + }); + + it('applies changed `refetchWritePolicy` to next fetch when changing between renders', async () => { + interface Data { + primes: number[]; + } + + const user = userEvent.setup(); + + const query: TypedDocumentNode = 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] } }, + delay: 10, + }, + { + request: { query, variables: { min: 30, max: 50 } }, + result: { data: { primes: [31, 37, 41, 43, 47] } }, + delay: 10, + }, + ]; + + const mergeParams: [number[] | undefined, number[]][] = []; + + const cache = new InMemoryCache({ + typePolicies: { + Query: { + fields: { + primes: { + keyArgs: false, + merge(existing: number[] | undefined, incoming: number[]) { + mergeParams.push([existing, incoming]); + return existing ? existing.concat(incoming) : incoming; + }, + }, + }, + }, + }, + }); + + const client = new ApolloClient({ + link: new MockLink(mocks), + cache, + }); + + const suspenseCache = new SuspenseCache(); + + function SuspenseFallback() { + return
Loading...
; + } + + function Parent() { + const [refetchWritePolicy, setRefetchWritePolicy] = + React.useState('merge'); + + const [queryRef, { refetch }] = useBackgroundQuery(query, { + refetchWritePolicy, + variables: { min: 0, max: 12 }, + }); + + return ( + <> + + + + }> + + + + ); + } + + function Primes({ queryRef }: { queryRef: QueryReference }) { + const { data } = useReadQuery(queryRef); + + return {data.primes.join(', ')}; + } + + function App() { + return ( + + + + ); + } + + render(); + + const primes = await screen.findByTestId('primes'); + + expect(primes).toHaveTextContent('2, 3, 5, 7, 11'); + expect(mergeParams).toEqual([[undefined, [2, 3, 5, 7, 11]]]); + + await act(() => user.click(screen.getByText('Refetch next'))); + + await waitFor(() => { + expect(primes).toHaveTextContent('2, 3, 5, 7, 11, 13, 17, 19, 23, 29'); + }); + + expect(mergeParams).toEqual([ + [undefined, [2, 3, 5, 7, 11]], + [ + [2, 3, 5, 7, 11], + [13, 17, 19, 23, 29], + ], + ]); + + await act(() => + user.click(screen.getByText('Change refetch write policy')) + ); + + await act(() => user.click(screen.getByText('Refetch last'))); + + await waitFor(() => { + expect(primes).toHaveTextContent('31, 37, 41, 43, 47'); + }); + + expect(mergeParams).toEqual([ + [undefined, [2, 3, 5, 7, 11]], + [ + [2, 3, 5, 7, 11], + [13, 17, 19, 23, 29], + ], + [undefined, [31, 37, 41, 43, 47]], + ]); + }); + + it('applies `returnPartialData` on next fetch when it changes between renders', async () => { + interface Data { + character: { + __typename: 'Character'; + id: string; + name: string; + }; + } + + interface PartialData { + character: { + __typename: 'Character'; + id: string; + }; + } + + const user = userEvent.setup(); + + const fullQuery: TypedDocumentNode = gql` + query { + character { + __typename + id + name + } + } + `; + + const partialQuery: TypedDocumentNode = gql` + query { + character { + __typename + id + } + } + `; + + const mocks = [ + { + request: { query: fullQuery }, + result: { + data: { + character: { + __typename: 'Character', + id: '1', + name: 'Doctor Strange', + }, + }, + }, + }, + { + request: { query: fullQuery }, + result: { + data: { + character: { + __typename: 'Character', + id: '1', + name: 'Doctor Strange (refetched)', + }, + }, + }, + delay: 100, + }, + ]; + + const cache = new InMemoryCache(); + + cache.writeQuery({ + query: partialQuery, + data: { character: { __typename: 'Character', id: '1' } }, + }); + + const client = new ApolloClient({ + link: new MockLink(mocks), + cache, + }); + + const suspenseCache = new SuspenseCache(); + + function SuspenseFallback() { + return
Loading...
; + } + + function Parent() { + const [returnPartialData, setReturnPartialData] = React.useState(false); + + const [queryRef] = useBackgroundQuery(fullQuery, { + returnPartialData, + }); + + return ( + <> + + }> + + + + ); + } + + function Character({ queryRef }: { queryRef: QueryReference }) { + const { data } = useReadQuery(queryRef); + + return ( + {data.character.name ?? 'unknown'} + ); + } + + function App() { + return ( + + + + ); + } + + render(); + + const character = await screen.findByTestId('character'); + + expect(character).toHaveTextContent('Doctor Strange'); + + await act(() => user.click(screen.getByText('Update partial data'))); + + cache.modify({ + id: cache.identify({ __typename: 'Character', id: '1' }), + fields: { + name: (_, { DELETE }) => DELETE, + }, + }); + + await waitFor(() => { + expect(character).toHaveTextContent('unknown'); + }); + + await waitFor(() => { + expect(character).toHaveTextContent('Doctor Strange (refetched)'); + }); + }); + + it('applies updated `fetchPolicy` on next fetch when it changes between renders', async () => { + interface Data { + character: { + __typename: 'Character'; + id: string; + name: string; + }; + } + + const user = userEvent.setup(); + + const query: TypedDocumentNode = gql` + query { + character { + __typename + id + name + } + } + `; + + const mocks = [ + { + request: { query }, + result: { + data: { + character: { + __typename: 'Character', + id: '1', + name: 'Doctor Strange', + }, + }, + }, + delay: 10, + }, + ]; + + const cache = new InMemoryCache(); + + cache.writeQuery({ + query, + data: { + character: { + __typename: 'Character', + id: '1', + name: 'Doctor Strangecache', + }, + }, + }); + + const client = new ApolloClient({ + link: new MockLink(mocks), + cache, + }); + + const suspenseCache = new SuspenseCache(); + + function SuspenseFallback() { + return
Loading...
; + } + + function Parent() { + const [fetchPolicy, setFetchPolicy] = + React.useState('cache-first'); + + const [queryRef, { refetch }] = useBackgroundQuery(query, { + fetchPolicy, + }); + + return ( + <> + + + }> + + + + ); + } + + function Character({ queryRef }: { queryRef: QueryReference }) { + const { data } = useReadQuery(queryRef); + + return {data.character.name}; + } + + function App() { + return ( + + + + ); + } + + render(); + + const character = await screen.findByTestId('character'); + + expect(character).toHaveTextContent('Doctor Strangecache'); + + await act(() => user.click(screen.getByText('Change fetch policy'))); + await act(() => user.click(screen.getByText('Refetch'))); + await waitFor(() => { + expect(character).toHaveTextContent('Doctor Strange'); + }); + + // Because we switched to a `no-cache` fetch policy, we should not see the + // newly fetched data in the cache after the fetch occured. + expect(cache.readQuery({ query })).toEqual({ + character: { + __typename: 'Character', + id: '1', + name: 'Doctor Strangecache', + }, + }); + }); + + it('properly handles changing options along with changing `variables`', async () => { + interface Data { + character: { + __typename: 'Character'; + id: string; + name: string; + }; + } + + const user = userEvent.setup(); + const query: TypedDocumentNode = gql` + query ($id: ID!) { + character(id: $id) { + __typename + id + name + } + } + `; + + const mocks = [ + { + request: { query, variables: { id: '1' } }, + result: { + errors: [new GraphQLError('oops')], + }, + delay: 10, + }, + { + request: { query, variables: { id: '2' } }, + result: { + data: { + character: { + __typename: 'Character', + id: '2', + name: 'Hulk', + }, + }, + }, + delay: 10, + }, + ]; + + const cache = new InMemoryCache(); + + cache.writeQuery({ + query, + variables: { + id: '1', + }, + data: { + character: { + __typename: 'Character', + id: '1', + name: 'Doctor Strangecache', + }, + }, + }); + + const client = new ApolloClient({ + link: new MockLink(mocks), + cache, + }); + + const suspenseCache = new SuspenseCache(); + + function SuspenseFallback() { + return
Loading...
; + } + + function Parent() { + const [id, setId] = React.useState('1'); + + const [queryRef, { refetch }] = useBackgroundQuery(query, { + errorPolicy: id === '1' ? 'all' : 'none', + variables: { id }, + }); + + return ( + <> + + + + Error boundary} + > + }> + + + + + ); + } + + function Character({ queryRef }: { queryRef: QueryReference }) { + const { data, error } = useReadQuery(queryRef); + + return error ? ( +
{error.message}
+ ) : ( + {data.character.name} + ); + } + + function App() { + return ( + + + + ); + } + + render(); + + const character = await screen.findByTestId('character'); + + expect(character).toHaveTextContent('Doctor Strangecache'); + + await act(() => user.click(screen.getByText('Get second character'))); + + await waitFor(() => { + expect(character).toHaveTextContent('Hulk'); + }); + + await act(() => user.click(screen.getByText('Get first character'))); + + await waitFor(() => { + expect(character).toHaveTextContent('Doctor Strangecache'); + }); + + await act(() => user.click(screen.getByText('Refetch'))); + + // Ensure we render the inline error instead of the error boundary, which + // tells us the error policy was properly applied. + expect(await screen.findByTestId('error')).toHaveTextContent('oops'); + }); + describe('refetch', () => { it('re-suspends when calling `refetch`', async () => { const { renders } = renderVariablesIntegrationTest({ diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index b19038f51d1..8e2b0c061ac 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -29,6 +29,7 @@ import { TypedDocumentNode, split, NetworkStatus, + ErrorPolicy, } from '../../../core'; import { DeepPartial, @@ -47,6 +48,7 @@ import { ApolloProvider } from '../../context'; import { SuspenseCache } from '../../cache'; import { SuspenseQueryHookFetchPolicy } from '../../../react'; import { useSuspenseQuery } from '../useSuspenseQuery'; +import { RefetchWritePolicy } from '../../../core/watchQueryOptions'; type RenderSuspenseHookOptions = Omit< RenderHookOptions, @@ -5061,6 +5063,744 @@ describe('useSuspenseQuery', () => { expect(await screen.findByTestId('todo')).toHaveTextContent('Clean room'); }); + it('applies `errorPolicy` on next fetch when it changes between renders', async () => { + const { query, mocks: simpleMocks } = useSimpleQueryCase(); + + const successMock = simpleMocks[0]; + + const mocks = [ + successMock, + { + request: { query }, + result: { + errors: [new GraphQLError('oops')], + }, + }, + ]; + + const { result, rerender, renders } = renderSuspenseHook( + ({ errorPolicy }) => useSuspenseQuery(query, { errorPolicy }), + { mocks, initialProps: { errorPolicy: 'none' as ErrorPolicy } } + ); + + await waitFor(() => { + expect(result.current).toMatchObject({ + ...successMock.result, + networkStatus: NetworkStatus.ready, + error: undefined, + }); + }); + + rerender({ errorPolicy: 'all' }); + + act(() => { + result.current.refetch(); + }); + + await waitFor(() => { + expect(result.current).toMatchObject({ + ...successMock.result, + networkStatus: NetworkStatus.error, + error: new ApolloError({ graphQLErrors: [new GraphQLError('oops')] }), + }); + }); + + expect(renders.errorCount).toBe(0); + expect( + renders.frames.map((f) => ({ + data: f.data, + error: f.error, + networkStatus: f.networkStatus, + })) + ).toMatchObject([ + { + ...successMock.result, + networkStatus: NetworkStatus.ready, + error: undefined, + }, + { + ...successMock.result, + networkStatus: NetworkStatus.ready, + error: undefined, + }, + { + ...successMock.result, + networkStatus: NetworkStatus.error, + error: new ApolloError({ graphQLErrors: [new GraphQLError('oops')] }), + }, + ]); + }); + + it('applies `context` on next fetch when it changes between renders', async () => { + const query = gql` + query { + context + } + `; + + const link = new ApolloLink((operation) => { + return Observable.of({ + data: { + context: operation.getContext(), + }, + }); + }); + + const client = new ApolloClient({ + link, + cache: new InMemoryCache(), + }); + + const { result, rerender, renders } = renderSuspenseHook( + ({ context }) => useSuspenseQuery(query, { context }), + { client, initialProps: { context: { phase: 'initialValue' } } } + ); + + await waitFor(() => { + expect(result.current).toMatchObject({ + data: { context: { phase: 'initialValue' } }, + networkStatus: NetworkStatus.ready, + error: undefined, + }); + }); + + rerender({ context: { phase: 'rerender' } }); + + act(() => { + result.current.refetch(); + }); + + await waitFor(() => { + expect(result.current).toMatchObject({ + data: { + context: { phase: 'rerender' }, + }, + networkStatus: NetworkStatus.ready, + error: undefined, + }); + }); + + expect(renders.frames).toMatchObject([ + { + data: { context: { phase: 'initialValue' } }, + networkStatus: NetworkStatus.ready, + error: undefined, + }, + { + data: { context: { phase: 'initialValue' } }, + networkStatus: NetworkStatus.ready, + error: undefined, + }, + { + data: { context: { phase: 'rerender' } }, + networkStatus: NetworkStatus.ready, + error: undefined, + }, + ]); + }); + + // NOTE: We only test the `false` -> `true` path here. If the option changes + // from `true` -> `false`, the data has already been canonized, so it has no + // effect on the output. + it('returns canonical results immediately when `canonizeResults` changes from `false` to `true` between renders', async () => { + interface Result { + __typename: string; + value: number; + } + + interface Data { + results: Result[]; + } + + const cache = new InMemoryCache({ + typePolicies: { + Result: { + keyFields: false, + }, + }, + }); + + const query: TypedDocumentNode = gql` + query { + results { + value + } + } + `; + + const results: Result[] = [ + { __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 }, + }); + + function verifyCanonicalResults(data: Data, canonized: boolean) { + const resultSet = new Set(data.results); + const values = Array.from(resultSet).map((item) => item.value); + + expect(data).toEqual({ results }); + + if (canonized) { + expect(data.results.length).toBe(6); + expect(resultSet.size).toBe(5); + expect(values).toEqual([0, 1, 2, 3, 5]); + } else { + expect(data.results.length).toBe(6); + expect(resultSet.size).toBe(6); + expect(values).toEqual([0, 1, 1, 2, 3, 5]); + } + } + + const { result, rerender, renders } = renderSuspenseHook( + ({ canonizeResults }) => useSuspenseQuery(query, { canonizeResults }), + { cache, initialProps: { canonizeResults: false } } + ); + + verifyCanonicalResults(result.current.data, false); + + rerender({ canonizeResults: true }); + + verifyCanonicalResults(result.current.data, true); + expect(renders.count).toBe(2); + }); + + it('applies changed `refetchWritePolicy` to next fetch when changing between renders', async () => { + const query: TypedDocumentNode< + { primes: number[] }, + { min: number; max: number } + > = 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] } }, + delay: 10, + }, + { + request: { query, variables: { min: 30, max: 50 } }, + result: { data: { primes: [31, 37, 41, 43, 47] } }, + delay: 10, + }, + ]; + + const mergeParams: [number[] | undefined, number[]][] = []; + const cache = new InMemoryCache({ + typePolicies: { + Query: { + fields: { + primes: { + keyArgs: false, + merge(existing: number[] | undefined, incoming: number[]) { + mergeParams.push([existing, incoming]); + return existing ? existing.concat(incoming) : incoming; + }, + }, + }, + }, + }, + }); + + const { result, rerender } = renderSuspenseHook( + ({ refetchWritePolicy }) => + useSuspenseQuery(query, { + variables: { min: 0, max: 12 }, + refetchWritePolicy, + }), + { + cache, + mocks, + initialProps: { refetchWritePolicy: 'merge' as RefetchWritePolicy }, + } + ); + + await waitFor(() => { + expect(result.current).toMatchObject({ + ...mocks[0].result, + networkStatus: NetworkStatus.ready, + error: undefined, + }); + }); + + expect(mergeParams).toEqual([[undefined, [2, 3, 5, 7, 11]]]); + + act(() => { + result.current.refetch({ min: 12, max: 30 }); + }); + + await waitFor(() => { + expect(result.current).toMatchObject({ + data: { primes: [2, 3, 5, 7, 11, 13, 17, 19, 23, 29] }, + networkStatus: NetworkStatus.ready, + error: undefined, + }); + }); + + expect(mergeParams).toEqual([ + [undefined, [2, 3, 5, 7, 11]], + [ + [2, 3, 5, 7, 11], + [13, 17, 19, 23, 29], + ], + ]); + + rerender({ refetchWritePolicy: 'overwrite' }); + + act(() => { + result.current.refetch({ min: 30, max: 50 }); + }); + + await waitFor(() => { + expect(result.current).toMatchObject({ + ...mocks[2].result, + networkStatus: NetworkStatus.ready, + error: undefined, + }); + }); + + await waitFor(() => { + expect(result.current).toMatchObject({ + ...mocks[2].result, + networkStatus: NetworkStatus.ready, + error: undefined, + }); + }); + + expect(mergeParams).toEqual([ + [undefined, [2, 3, 5, 7, 11]], + [ + [2, 3, 5, 7, 11], + [13, 17, 19, 23, 29], + ], + [undefined, [31, 37, 41, 43, 47]], + ]); + }); + + it('applies `returnPartialData` on next fetch when it changes between renders', async () => { + const fullQuery = gql` + query { + character { + __typename + id + name + } + } + `; + + const partialQuery = gql` + query { + character { + __typename + id + } + } + `; + + const mocks = [ + { + request: { query: fullQuery }, + result: { + data: { + character: { + __typename: 'Character', + id: '1', + name: 'Doctor Strange', + }, + }, + }, + }, + { + request: { query: fullQuery }, + result: { + data: { + character: { + __typename: 'Character', + id: '1', + name: 'Doctor Strange (refetched)', + }, + }, + }, + delay: 100, + }, + ]; + + const cache = new InMemoryCache(); + + cache.writeQuery({ + query: partialQuery, + data: { character: { __typename: 'Character', id: '1' } }, + }); + + const { result, renders, rerender } = renderSuspenseHook( + ({ returnPartialData }) => + useSuspenseQuery(fullQuery, { returnPartialData }), + { cache, mocks, initialProps: { returnPartialData: false } } + ); + + expect(renders.suspenseCount).toBe(1); + + await waitFor(() => { + expect(result.current).toMatchObject({ + ...mocks[0].result, + networkStatus: NetworkStatus.ready, + error: undefined, + }); + }); + + rerender({ returnPartialData: true }); + + cache.modify({ + id: cache.identify({ __typename: 'Character', id: '1' }), + fields: { + name: (_, { DELETE }) => DELETE, + }, + }); + + await waitFor(() => { + expect(result.current.data).toEqual({ + character: { __typename: 'Character', id: '1' }, + }); + }); + + await waitFor(() => { + expect(result.current).toMatchObject({ + ...mocks[1].result, + networkStatus: NetworkStatus.ready, + error: undefined, + }); + }); + + expect(renders.count).toBe(5); + expect(renders.suspenseCount).toBe(1); + expect(renders.frames).toMatchObject([ + { + data: { character: { __typename: 'Character', id: '1' } }, + networkStatus: NetworkStatus.ready, + error: undefined, + }, + { + ...mocks[0].result, + networkStatus: NetworkStatus.ready, + error: undefined, + }, + { + data: { character: { __typename: 'Character', id: '1' } }, + networkStatus: NetworkStatus.loading, + error: undefined, + }, + { + ...mocks[1].result, + networkStatus: NetworkStatus.ready, + error: undefined, + }, + ]); + }); + + it('applies updated `fetchPolicy` on next fetch when it changes between renders', async () => { + const query = gql` + query { + character { + __typename + id + name + } + } + `; + + const mocks = [ + { + request: { query }, + result: { + data: { + character: { + __typename: 'Character', + id: '1', + name: 'Doctor Strange', + }, + }, + }, + delay: 10, + }, + ]; + + const cache = new InMemoryCache(); + + cache.writeQuery({ + query, + data: { + character: { + __typename: 'Character', + id: '1', + name: 'Doctor Strangecache', + }, + }, + }); + + const { result, renders, rerender } = renderSuspenseHook( + ({ fetchPolicy }) => useSuspenseQuery(query, { fetchPolicy }), + { + cache, + mocks, + initialProps: { + fetchPolicy: 'cache-first' as SuspenseQueryHookFetchPolicy, + }, + } + ); + + expect(result.current).toMatchObject({ + data: { + character: { + __typename: 'Character', + id: '1', + name: 'Doctor Strangecache', + }, + }, + networkStatus: NetworkStatus.ready, + error: undefined, + }); + + rerender({ fetchPolicy: 'no-cache' }); + + const cacheKey = cache.identify({ __typename: 'Character', id: '1' })!; + + act(() => { + result.current.refetch(); + }); + + await waitFor(() => { + expect(result.current.data).toEqual({ + character: { + __typename: 'Character', + id: '1', + name: 'Doctor Strange', + }, + }); + }); + + // Because we switched to a `no-cache` fetch policy, we should not see the + // newly fetched data in the cache after the fetch occured. + expect(cache.extract()[cacheKey]).toEqual({ + __typename: 'Character', + id: '1', + name: 'Doctor Strangecache', + }); + + expect(renders.count).toBe(4); + expect(renders.suspenseCount).toBe(1); + expect(renders.frames).toMatchObject([ + { + data: { + character: { + __typename: 'Character', + id: '1', + name: 'Doctor Strangecache', + }, + }, + networkStatus: NetworkStatus.ready, + error: undefined, + }, + { + data: { + character: { + __typename: 'Character', + id: '1', + name: 'Doctor Strangecache', + }, + }, + networkStatus: NetworkStatus.ready, + error: undefined, + }, + { + ...mocks[0].result, + networkStatus: NetworkStatus.ready, + error: undefined, + }, + ]); + }); + + it('properly handles changing options along with changing `variables`', async () => { + const query = gql` + query ($id: ID!) { + character(id: $id) { + __typename + id + name + } + } + `; + + const mocks = [ + { + request: { query, variables: { id: '1' } }, + result: { + errors: [new GraphQLError('oops')], + }, + delay: 10, + }, + { + request: { query, variables: { id: '2' } }, + result: { + data: { + character: { + __typename: 'Character', + id: '2', + name: 'Hulk', + }, + }, + }, + delay: 10, + }, + ]; + + const cache = new InMemoryCache(); + + cache.writeQuery({ + query, + variables: { + id: '1', + }, + data: { + character: { + __typename: 'Character', + id: '1', + name: 'Doctor Strangecache', + }, + }, + }); + + const { result, renders, rerender } = renderSuspenseHook( + ({ errorPolicy, variables }) => + useSuspenseQuery(query, { errorPolicy, variables }), + { + cache, + mocks, + initialProps: { + errorPolicy: 'none' as ErrorPolicy, + variables: { id: '1' }, + }, + } + ); + + expect(result.current).toMatchObject({ + data: { + character: { + __typename: 'Character', + id: '1', + name: 'Doctor Strangecache', + }, + }, + networkStatus: NetworkStatus.ready, + error: undefined, + }); + + rerender({ errorPolicy: 'none', variables: { id: '2' } }); + + expect(renders.suspenseCount).toBe(1); + + await waitFor(() => { + expect(result.current).toMatchObject({ + data: { + character: { + __typename: 'Character', + id: '2', + name: 'Hulk', + }, + }, + networkStatus: NetworkStatus.ready, + error: undefined, + }); + }); + + rerender({ errorPolicy: 'all', variables: { id: '1' } }); + + act(() => { + result.current.refetch(); + }); + + const expectedError = new ApolloError({ + graphQLErrors: [new GraphQLError('oops')], + }); + + await waitFor(() => { + expect(result.current).toMatchObject({ + data: { + character: { + __typename: 'Character', + id: '1', + name: 'Doctor Strangecache', + }, + }, + networkStatus: NetworkStatus.error, + error: expectedError, + }); + }); + + expect(renders.errorCount).toBe(0); + expect(renders.count).toBe(6); + expect(renders.suspenseCount).toBe(2); + expect(renders.frames).toMatchObject([ + { + data: { + character: { + __typename: 'Character', + id: '1', + name: 'Doctor Strangecache', + }, + }, + networkStatus: NetworkStatus.ready, + error: undefined, + }, + { + data: { + character: { + __typename: 'Character', + id: '2', + name: 'Hulk', + }, + }, + networkStatus: NetworkStatus.ready, + error: undefined, + }, + { + data: { + character: { + __typename: 'Character', + id: '1', + name: 'Doctor Strangecache', + }, + }, + networkStatus: NetworkStatus.ready, + error: undefined, + }, + { + data: { + character: { + __typename: 'Character', + id: '1', + name: 'Doctor Strangecache', + }, + }, + networkStatus: NetworkStatus.error, + error: expectedError, + }, + ]); + }); + it('does not oversubscribe when suspending multiple times', async () => { const query = gql` query UserQuery($id: String!) { diff --git a/src/react/hooks/useBackgroundQuery.ts b/src/react/hooks/useBackgroundQuery.ts index ce4641dcc97..e65abe29a53 100644 --- a/src/react/hooks/useBackgroundQuery.ts +++ b/src/react/hooks/useBackgroundQuery.ts @@ -127,7 +127,7 @@ export function useBackgroundQuery< const suspenseCache = useSuspenseCache(options.suspenseCache); const client = useApolloClient(options.client); const watchQueryOptions = useWatchQueryOptions({ client, query, options }); - const { fetchPolicy, variables } = watchQueryOptions; + const { variables } = watchQueryOptions; const { queryKey = [] } = options; const cacheKey = ( @@ -138,14 +138,12 @@ export function useBackgroundQuery< client.watchQuery(watchQueryOptions) ); - const { fetchPolicy: currentFetchPolicy } = queryRef.watchQueryOptions; - const [promiseCache, setPromiseCache] = useState( () => new Map([[queryRef.key, queryRef.promise]]) ); - if (currentFetchPolicy === 'standby' && fetchPolicy !== currentFetchPolicy) { - const promise = queryRef.reobserve({ fetchPolicy }); + if (queryRef.didChangeOptions(watchQueryOptions)) { + const promise = queryRef.applyOptions(watchQueryOptions); promiseCache.set(queryRef.key, promise); } diff --git a/src/react/hooks/useSuspenseQuery.ts b/src/react/hooks/useSuspenseQuery.ts index 62be53ddf43..38fd2c6c926 100644 --- a/src/react/hooks/useSuspenseQuery.ts +++ b/src/react/hooks/useSuspenseQuery.ts @@ -167,16 +167,14 @@ export function useSuspenseQuery< client.watchQuery(watchQueryOptions) ); - const { fetchPolicy: currentFetchPolicy } = queryRef.watchQueryOptions; - const [promiseCache, setPromiseCache] = useState( () => new Map([[queryRef.key, queryRef.promise]]) ); let promise = promiseCache.get(queryRef.key); - if (currentFetchPolicy === 'standby' && fetchPolicy !== currentFetchPolicy) { - promise = queryRef.reobserve({ fetchPolicy }); + if (queryRef.didChangeOptions(watchQueryOptions)) { + promise = queryRef.applyOptions(watchQueryOptions); promiseCache.set(queryRef.key, promise); } @@ -206,8 +204,7 @@ export function useSuspenseQuery< }; }, [queryRef.result]); - const result = - watchQueryOptions.fetchPolicy === 'standby' ? skipResult : __use(promise); + const result = fetchPolicy === 'standby' ? skipResult : __use(promise); const fetchMore: FetchMoreFunction = useCallback( (options) => {