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
+
-
-
###### `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 (
+
+ refetch()}>Refetch {name}
+
+ {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[];
}
/**