Skip to content

Commit 2675d3c

Browse files
authored
Fix issue where queryRef is recreated unnecessarily in strict mode (#11821)
1 parent c158c28 commit 2675d3c

File tree

8 files changed

+114
-52
lines changed

8 files changed

+114
-52
lines changed

.changeset/lazy-parents-thank.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@apollo/client": patch
3+
---
4+
5+
Fix a regression where rerendering a component with `useBackgroundQuery` would recreate the `queryRef` instance when used with React's strict mode.

.changeset/seven-forks-own.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@apollo/client": patch
3+
---
4+
5+
Revert the change introduced in
6+
[3.9.10](https://github.com/apollographql/apollo-client/releases/tag/v3.9.10) via #11738 that disposed of queryRefs synchronously. This change caused too many issues with strict mode.

.size-limits.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
{
2-
"dist/apollo-client.min.cjs": 39551,
2+
"dist/apollo-client.min.cjs": 39540,
33
"import { ApolloClient, InMemoryCache, HttpLink } from \"dist/index.js\" (production)": 32826
44
}

src/react/hooks/__tests__/useBackgroundQuery.test.tsx

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -536,6 +536,88 @@ it("does not recreate queryRef and execute a network request when rerendering us
536536
await expect(Profiler).not.toRerender({ timeout: 50 });
537537
});
538538

539+
// https://github.com/apollographql/apollo-client/issues/11815
540+
it("does not recreate queryRef or execute a network request when rerendering useBackgroundQuery in strict mode", async () => {
541+
const { query } = setupSimpleCase();
542+
const user = userEvent.setup();
543+
let fetchCount = 0;
544+
const client = new ApolloClient({
545+
link: new ApolloLink(() => {
546+
fetchCount++;
547+
548+
return new Observable((observer) => {
549+
setTimeout(() => {
550+
observer.next({ data: { greeting: "Hello" } });
551+
observer.complete();
552+
}, 20);
553+
});
554+
}),
555+
cache: new InMemoryCache(),
556+
});
557+
558+
const Profiler = createProfiler({
559+
initialSnapshot: {
560+
queryRef: null as QueryReference<SimpleCaseData> | null,
561+
result: null as UseReadQueryResult<SimpleCaseData> | null,
562+
},
563+
});
564+
const { SuspenseFallback, ReadQueryHook } =
565+
createDefaultTrackedComponents(Profiler);
566+
567+
function App() {
568+
useTrackRenders();
569+
const [, setCount] = React.useState(0);
570+
// Use a fetchPolicy of no-cache to ensure we can more easily track if
571+
// another network request was made
572+
const [queryRef] = useBackgroundQuery(query, { fetchPolicy: "no-cache" });
573+
574+
Profiler.mergeSnapshot({ queryRef });
575+
576+
return (
577+
<>
578+
<button onClick={() => setCount((count) => count + 1)}>
579+
Increment
580+
</button>
581+
<Suspense fallback={<SuspenseFallback />}>
582+
<ReadQueryHook queryRef={queryRef} />
583+
</Suspense>
584+
</>
585+
);
586+
}
587+
588+
renderWithClient(<App />, {
589+
client,
590+
wrapper: ({ children }) => (
591+
<Profiler>
592+
<React.StrictMode>{children}</React.StrictMode>
593+
</Profiler>
594+
),
595+
});
596+
597+
const incrementButton = screen.getByText("Increment");
598+
599+
{
600+
const { renderedComponents } = await Profiler.takeRender();
601+
602+
expect(renderedComponents).toStrictEqual([App, SuspenseFallback]);
603+
}
604+
605+
// eslint-disable-next-line testing-library/render-result-naming-convention
606+
const firstRender = await Profiler.takeRender();
607+
const initialQueryRef = firstRender.snapshot.queryRef;
608+
609+
await act(() => user.click(incrementButton));
610+
611+
{
612+
const { snapshot } = await Profiler.takeRender();
613+
614+
expect(snapshot.queryRef).toBe(initialQueryRef);
615+
expect(fetchCount).toBe(1);
616+
}
617+
618+
await expect(Profiler).not.toRerender({ timeout: 50 });
619+
});
620+
539621
it("disposes of the queryRef when unmounting before it is used by useReadQuery", async () => {
540622
const { query, mocks } = setupSimpleCase();
541623
const client = new ApolloClient({

src/react/hooks/useBackgroundQuery.ts

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -239,16 +239,21 @@ function _useBackgroundQuery<
239239
updateWrappedQueryRef(wrappedQueryRef, promise);
240240
}
241241

242-
// Handle strict mode where the query ref might be disposed when useEffect
243-
// runs twice. We add the queryRef back in the suspense cache so that the next
244-
// render will reuse this queryRef rather than initializing a new instance.
245-
// This also prevents issues where rerendering useBackgroundQuery after the
246-
// queryRef has been disposed, either automatically or by unmounting
247-
// useReadQuery will ensure the same queryRef is maintained.
242+
// This prevents issues where rerendering useBackgroundQuery after the
243+
// queryRef has been disposed would cause the hook to return a new queryRef
244+
// instance since disposal also removes it from the suspense cache. We add
245+
// the queryRef back in the suspense cache so that the next render will reuse
246+
// this queryRef rather than initializing a new instance.
248247
React.useEffect(() => {
249-
if (queryRef.disposed) {
250-
suspenseCache.add(cacheKey, queryRef);
251-
}
248+
// Since the queryRef is disposed async via `setTimeout`, we have to wait a
249+
// tick before checking it and adding back to the suspense cache.
250+
const id = setTimeout(() => {
251+
if (queryRef.disposed) {
252+
suspenseCache.add(cacheKey, queryRef);
253+
}
254+
});
255+
256+
return () => clearTimeout(id);
252257
// Omitting the deps is intentional. This avoids stale closures and the
253258
// conditional ensures we aren't running the logic on each render.
254259
});

src/react/hooks/useReadQuery.ts

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -83,17 +83,7 @@ function _useReadQuery<TData>(
8383
updateWrappedQueryRef(queryRef, internalQueryRef.promise);
8484
}
8585

86-
React.useEffect(() => {
87-
// It may seem odd that we are trying to reinitialize the queryRef even
88-
// though we reinitialize in render above, but this is necessary to
89-
// handle strict mode where this useEffect will be run twice resulting in a
90-
// disposed queryRef before the next render.
91-
if (internalQueryRef.disposed) {
92-
internalQueryRef.reinitialize();
93-
}
94-
95-
return internalQueryRef.retain();
96-
}, [internalQueryRef]);
86+
React.useEffect(() => internalQueryRef.retain(), [internalQueryRef]);
9787

9888
const promise = useSyncExternalStore(
9989
React.useCallback(

src/react/hooks/useSuspenseQuery.ts

Lines changed: 0 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -239,34 +239,6 @@ function _useSuspenseQuery<
239239
};
240240
}, [queryRef]);
241241

242-
// This effect handles the case where strict mode causes the queryRef to get
243-
// disposed early. Previously this was done by using a `setTimeout` inside the
244-
// dispose function, but this could cause some issues in cases where someone
245-
// might expect the queryRef to be disposed immediately. For example, when
246-
// using the same client instance across multiple tests in a test suite, the
247-
// `setTimeout` has the possibility of retaining the suspense cache entry for
248-
// too long, which means that two tests might accidentally share the same
249-
// `queryRef` instance. By immediately disposing, we can avoid this situation.
250-
//
251-
// Instead we can leverage the work done to allow the queryRef to "resume"
252-
// after it has been disposed without executing an additional network request.
253-
// This is done by calling the `initialize` function below.
254-
React.useEffect(() => {
255-
if (queryRef.disposed) {
256-
// Calling the `dispose` function removes it from the suspense cache, so
257-
// when the component rerenders, it instantiates a fresh query ref.
258-
// We address this by adding the queryRef back to the suspense cache
259-
// so that the lookup on the next render uses the existing queryRef rather
260-
// than instantiating a new one.
261-
suspenseCache.add(cacheKey, queryRef);
262-
queryRef.reinitialize();
263-
}
264-
// We can omit the deps here to get a fresh closure each render since the
265-
// conditional will prevent the logic from running in most cases. This
266-
// should also be a touch faster since it should be faster to check the `if`
267-
// statement than for React to compare deps on this effect.
268-
});
269-
270242
const skipResult = React.useMemo(() => {
271243
const error = toApolloError(queryRef.result);
272244

src/react/internal/cache/QueryReference.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -244,9 +244,11 @@ export class InternalQueryReference<TData = unknown> {
244244
disposed = true;
245245
this.references--;
246246

247-
if (!this.references) {
248-
this.dispose();
249-
}
247+
setTimeout(() => {
248+
if (!this.references) {
249+
this.dispose();
250+
}
251+
});
250252
};
251253
}
252254

0 commit comments

Comments
 (0)