diff --git a/.changeset/curvy-monkeys-kneel.md b/.changeset/curvy-monkeys-kneel.md new file mode 100644 index 00000000000..219c3b1f78c --- /dev/null +++ b/.changeset/curvy-monkeys-kneel.md @@ -0,0 +1,5 @@ +--- +'@apollo/client': patch +--- + +Add a `queryKey` option to `useSuspenseQuery` that allows the hook to create a unique subscription instance. diff --git a/config/bundlesize.ts b/config/bundlesize.ts index 051e19e43c6..02612278864 100644 --- a/config/bundlesize.ts +++ b/config/bundlesize.ts @@ -3,7 +3,7 @@ import { join } from "path"; import { gzipSync } from "zlib"; import bytes from "bytes"; -const gzipBundleByteLengthLimit = bytes("34.5KB"); +const gzipBundleByteLengthLimit = bytes("34.52KB"); const minFile = join("dist", "apollo-client.min.cjs"); const minPath = join(__dirname, "..", minFile); const gzipByteLen = gzipSync(readFileSync(minPath)).byteLength; diff --git a/docs/shared/useSuspenseQuery-options.mdx b/docs/shared/useSuspenseQuery-options.mdx index d1150b95d36..fc6f562d6d6 100644 --- a/docs/shared/useSuspenseQuery-options.mdx +++ b/docs/shared/useSuspenseQuery-options.mdx @@ -109,6 +109,23 @@ By default, the instance that's passed down via context is used, but you can pro + + + +###### `queryKey` + +`string | number | any[]` + + + + +A unique identifier for the query. Each item in the array must be a stable identifier to prevent infinite fetches. + +This is useful when using the same query and variables combination in more than one component, otherwise the components may clobber each other. This can also be used to force the query to re-evaluate fresh. + + + + @@ -142,23 +159,6 @@ The default value is `cache-first`. -###### `nextFetchPolicy` - -`SuspenseQueryHookFetchPolicy` - - - - -Specifies the [`fetchPolicy`](#fetchpolicy) to use for all executions of this query _after_ this execution. - -For example, you can use this to switch back to a `cache-first` fetch policy after using `cache-and-network` or `network-only` for a single execution. - - - - - - - ###### `returnPartialData` `boolean` diff --git a/src/react/cache/SuspenseCache.ts b/src/react/cache/SuspenseCache.ts index 22112b9661f..b9341f85451 100644 --- a/src/react/cache/SuspenseCache.ts +++ b/src/react/cache/SuspenseCache.ts @@ -1,16 +1,9 @@ import { Trie } from '@wry/trie'; -import { - ApolloClient, - DocumentNode, - ObservableQuery, - OperationVariables, - TypedDocumentNode, -} from '../../core'; -import { canonicalStringify } from '../../cache'; +import { ObservableQuery } from '../../core'; import { canUseWeakMap } from '../../utilities'; import { QuerySubscription } from './QuerySubscription'; -type CacheKey = [ApolloClient, DocumentNode, string]; +type CacheKey = any[]; interface SuspenseCacheOptions { /** @@ -40,27 +33,21 @@ export class SuspenseCache { } getSubscription( - client: ApolloClient, - query: DocumentNode | TypedDocumentNode, - variables: OperationVariables | undefined, + cacheKey: CacheKey, createObservable: () => ObservableQuery ) { - const cacheKey = this.cacheKeys.lookup( - client, - query, - canonicalStringify(variables) - ); + const stableCacheKey = this.cacheKeys.lookupArray(cacheKey); - if (!this.subscriptions.has(cacheKey)) { + if (!this.subscriptions.has(stableCacheKey)) { this.subscriptions.set( - cacheKey, + stableCacheKey, new QuerySubscription(createObservable(), { autoDisposeTimeoutMs: this.options.autoDisposeTimeoutMs, - onDispose: () => this.subscriptions.delete(cacheKey), + onDispose: () => this.subscriptions.delete(stableCacheKey), }) ); } - return this.subscriptions.get(cacheKey)! as QuerySubscription; + return this.subscriptions.get(stableCacheKey)! as QuerySubscription; } } diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index 8cd4c220fee..16ba2e8def4 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -1153,6 +1153,352 @@ describe('useSuspenseQuery', () => { ]); }); + it('allows custom query key so two components that share same query and variables do not interfere with each other', async () => { + interface Data { + todo: { + id: number; + name: string; + completed: boolean; + }; + } + + interface Variables { + id: number; + } + + const query: TypedDocumentNode = gql` + query GetTodo($id: ID!) { + todo(id: $id) { + id + name + completed + } + } + `; + + const mocks = [ + { + request: { query, variables: { id: 1 } }, + result: { + data: { todo: { id: 1, name: 'Take out trash', completed: false } }, + }, + delay: 20, + }, + // refetch + { + request: { query, variables: { id: 1 } }, + result: { + data: { todo: { id: 1, name: 'Take out trash', completed: true } }, + }, + delay: 20, + }, + ]; + + const user = userEvent.setup(); + const suspenseCache = new SuspenseCache(); + const client = new ApolloClient({ + link: new MockLink(mocks), + cache: new InMemoryCache(), + }); + + function Spinner({ name }: { name: string }) { + return Loading {name}; + } + + function App() { + return ( + + }> + + + }> + + + + ); + } + + function Todo({ name }: { name: string }) { + const { data, refetch } = useSuspenseQuery(query, { + // intentionally use no-cache to allow us to verify each suspense + // component is independent of each other + fetchPolicy: 'no-cache', + variables: { id: 1 }, + queryKey: [name], + }); + + return ( +
+ + + {data.todo.name} {data.todo.completed && '(completed)'} + +
+ ); + } + + render(); + + expect(screen.getByText('Loading first')).toBeInTheDocument(); + expect(screen.getByText('Loading second')).toBeInTheDocument(); + + await waitFor(() => { + expect(screen.getByTestId('first.data')).toHaveTextContent( + 'Take out trash' + ); + }); + + expect(screen.getByTestId('second.data')).toHaveTextContent( + 'Take out trash' + ); + + await act(() => user.click(screen.getByText('Refetch first'))); + + // Ensure that refetching the first todo does not update the second todo + // as well + expect(screen.getByText('Loading first')).toBeInTheDocument(); + expect(screen.queryByText('Loading second')).not.toBeInTheDocument(); + + await waitFor(() => { + expect(screen.getByTestId('first.data')).toHaveTextContent( + 'Take out trash (completed)' + ); + }); + + // Ensure that refetching the first todo did not affect the second + expect(screen.getByTestId('second.data')).toHaveTextContent( + 'Take out trash' + ); + }); + + it('suspends and refetches data when changing query keys', async () => { + const { query } = useSimpleQueryCase(); + + const mocks = [ + { + request: { query }, + result: { data: { greeting: 'Hello first fetch' } }, + delay: 20, + }, + { + request: { query }, + result: { data: { greeting: 'Hello second fetch' } }, + delay: 20, + }, + ]; + + const { result, rerender, renders } = renderSuspenseHook( + ({ queryKey }) => + // intentionally use a fetch policy that will execute a network request + useSuspenseQuery(query, { queryKey, fetchPolicy: 'network-only' }), + { mocks, initialProps: { queryKey: ['first'] } } + ); + + await waitFor(() => { + expect(result.current).toMatchObject({ + data: { greeting: 'Hello first fetch' }, + networkStatus: NetworkStatus.ready, + error: undefined, + }); + }); + + rerender({ queryKey: ['second'] }); + + await waitFor(() => { + expect(result.current).toMatchObject({ + data: { greeting: 'Hello second fetch' }, + networkStatus: NetworkStatus.ready, + error: undefined, + }); + }); + + expect(renders.count).toBe(4); + expect(renders.suspenseCount).toBe(2); + expect(renders.frames).toMatchObject([ + { + data: { greeting: 'Hello first fetch' }, + networkStatus: NetworkStatus.ready, + error: undefined, + }, + { + data: { greeting: 'Hello second fetch' }, + networkStatus: NetworkStatus.ready, + error: undefined, + }, + ]); + }); + + it('suspends and refetches data when part of the query key changes', async () => { + const { query } = useSimpleQueryCase(); + + const mocks = [ + { + request: { query }, + result: { data: { greeting: 'Hello first fetch' } }, + delay: 20, + }, + { + request: { query }, + result: { data: { greeting: 'Hello second fetch' } }, + delay: 20, + }, + ]; + + const { result, rerender, renders } = renderSuspenseHook( + ({ queryKey }) => + // intentionally use a fetch policy that will execute a network request + useSuspenseQuery(query, { queryKey, fetchPolicy: 'network-only' }), + { mocks, initialProps: { queryKey: ['greeting', 1] } } + ); + + await waitFor(() => { + expect(result.current).toMatchObject({ + data: { greeting: 'Hello first fetch' }, + networkStatus: NetworkStatus.ready, + error: undefined, + }); + }); + + rerender({ queryKey: ['greeting', 2] }); + + await waitFor(() => { + expect(result.current).toMatchObject({ + data: { greeting: 'Hello second fetch' }, + networkStatus: NetworkStatus.ready, + error: undefined, + }); + }); + + expect(renders.count).toBe(4); + expect(renders.suspenseCount).toBe(2); + expect(renders.frames).toMatchObject([ + { + data: { greeting: 'Hello first fetch' }, + networkStatus: NetworkStatus.ready, + error: undefined, + }, + { + data: { greeting: 'Hello second fetch' }, + networkStatus: NetworkStatus.ready, + error: undefined, + }, + ]); + }); + + it('suspends and refetches when using plain string query keys', async () => { + const { query } = useSimpleQueryCase(); + + const mocks = [ + { + request: { query }, + result: { data: { greeting: 'Hello first fetch' } }, + delay: 20, + }, + { + request: { query }, + result: { data: { greeting: 'Hello second fetch' } }, + delay: 20, + }, + ]; + + const { result, rerender, renders } = renderSuspenseHook( + ({ queryKey }) => + // intentionally use a fetch policy that will execute a network request + useSuspenseQuery(query, { queryKey, fetchPolicy: 'network-only' }), + { mocks, initialProps: { queryKey: 'first' } } + ); + + await waitFor(() => { + expect(result.current).toMatchObject({ + data: { greeting: 'Hello first fetch' }, + networkStatus: NetworkStatus.ready, + error: undefined, + }); + }); + + rerender({ queryKey: 'second' }); + + await waitFor(() => { + expect(result.current).toMatchObject({ + data: { greeting: 'Hello second fetch' }, + networkStatus: NetworkStatus.ready, + error: undefined, + }); + }); + + expect(renders.count).toBe(4); + expect(renders.suspenseCount).toBe(2); + expect(renders.frames).toMatchObject([ + { + data: { greeting: 'Hello first fetch' }, + networkStatus: NetworkStatus.ready, + error: undefined, + }, + { + data: { greeting: 'Hello second fetch' }, + networkStatus: NetworkStatus.ready, + error: undefined, + }, + ]); + }); + + it('suspends and refetches when using numeric query keys', async () => { + const { query } = useSimpleQueryCase(); + + const mocks = [ + { + request: { query }, + result: { data: { greeting: 'Hello first fetch' } }, + delay: 20, + }, + { + request: { query }, + result: { data: { greeting: 'Hello second fetch' } }, + delay: 20, + }, + ]; + + const { result, rerender, renders } = renderSuspenseHook( + ({ queryKey }) => + // intentionally use a fetch policy that will execute a network request + useSuspenseQuery(query, { queryKey, fetchPolicy: 'network-only' }), + { mocks, initialProps: { queryKey: 1 } } + ); + + await waitFor(() => { + expect(result.current).toMatchObject({ + data: { greeting: 'Hello first fetch' }, + networkStatus: NetworkStatus.ready, + error: undefined, + }); + }); + + rerender({ queryKey: 2 }); + + await waitFor(() => { + expect(result.current).toMatchObject({ + data: { greeting: 'Hello second fetch' }, + networkStatus: NetworkStatus.ready, + error: undefined, + }); + }); + + expect(renders.count).toBe(4); + expect(renders.suspenseCount).toBe(2); + expect(renders.frames).toMatchObject([ + { + data: { greeting: 'Hello first fetch' }, + networkStatus: NetworkStatus.ready, + error: undefined, + }, + { + data: { greeting: 'Hello second fetch' }, + networkStatus: NetworkStatus.ready, + error: undefined, + }, + ]); + }); + it('responds to cache updates after changing variables', async () => { const { query, mocks } = useVariablesQueryCase(); diff --git a/src/react/hooks/useSuspenseQuery.ts b/src/react/hooks/useSuspenseQuery.ts index eb2aa1ac637..c5e1125dd58 100644 --- a/src/react/hooks/useSuspenseQuery.ts +++ b/src/react/hooks/useSuspenseQuery.ts @@ -25,6 +25,7 @@ import { useDeepMemo, useStrictModeSafeCleanupEffect, __use } from './internal'; import { useSuspenseCache } from './useSuspenseCache'; import { useSyncExternalStore } from './useSyncExternalStore'; import { QuerySubscription } from '../cache/QuerySubscription'; +import { canonicalStringify } from '../../cache'; export interface UseSuspenseQueryResult< TData = any, @@ -73,15 +74,16 @@ export function useSuspenseQuery_experimental< const suspenseCache = useSuspenseCache(options.suspenseCache); const watchQueryOptions = useWatchQueryOptions({ query, options }); const { returnPartialData = false, variables } = watchQueryOptions; - const { suspensePolicy = 'always' } = options; + const { suspensePolicy = 'always', queryKey = [] } = options; const shouldSuspend = suspensePolicy === 'always' || !didPreviouslySuspend.current; - const subscription = suspenseCache.getSubscription( - client, - query, - variables, - () => client.watchQuery(watchQueryOptions) + const cacheKey = ( + [client, query, canonicalStringify(variables)] as any[] + ).concat(queryKey); + + const subscription = suspenseCache.getSubscription(cacheKey, () => + client.watchQuery(watchQueryOptions) ); const dispose = useTrackedSubscriptions(subscription); diff --git a/src/react/types/types.ts b/src/react/types/types.ts index 2f98d814092..ff697cfa70c 100644 --- a/src/react/types/types.ts +++ b/src/react/types/types.ts @@ -133,6 +133,7 @@ export interface SuspenseQueryHookOptions< fetchPolicy?: SuspenseQueryHookFetchPolicy; suspensePolicy?: SuspensePolicy; suspenseCache?: SuspenseCache; + queryKey?: string | number | any[]; } /**