Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix issue where queryRef is recreated unnecessarily in strict mode #11821

Merged
merged 13 commits into from
May 3, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/lazy-parents-thank.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@apollo/client": patch
---

Fix a regression where rerendering a component with `useBackgroundQuery` would recreate the `queryRef` instance when used with React's strict mode.
82 changes: 82 additions & 0 deletions src/react/hooks/__tests__/useBackgroundQuery.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -536,6 +536,88 @@ it("does not recreate queryRef and execute a network request when rerendering us
await expect(Profiler).not.toRerender({ timeout: 50 });
});

// https://github.com/apollographql/apollo-client/issues/11815
it("does not recreate queryRef or execute a network request when rerendering useBackgroundQuery in strict mode", async () => {
const { query } = setupSimpleCase();
const user = userEvent.setup();
let fetchCount = 0;
const client = new ApolloClient({
link: new ApolloLink(() => {
fetchCount++;

return new Observable((observer) => {
setTimeout(() => {
observer.next({ data: { greeting: "Hello" } });
observer.complete();
}, 20);
});
}),
cache: new InMemoryCache(),
});

const Profiler = createProfiler({
initialSnapshot: {
queryRef: null as QueryReference<SimpleCaseData> | null,
result: null as UseReadQueryResult<SimpleCaseData> | null,
},
});
const { SuspenseFallback, ReadQueryHook } =
createDefaultTrackedComponents(Profiler);

function App() {
useTrackRenders();
const [, setCount] = React.useState(0);
// Use a fetchPolicy of no-cache to ensure we can more easily track if
// another network request was made
const [queryRef] = useBackgroundQuery(query, { fetchPolicy: "no-cache" });

Profiler.mergeSnapshot({ queryRef });

return (
<>
<button onClick={() => setCount((count) => count + 1)}>
Increment
</button>
<Suspense fallback={<SuspenseFallback />}>
<ReadQueryHook queryRef={queryRef} />
</Suspense>
</>
);
}

renderWithClient(<App />, {
client,
wrapper: ({ children }) => (
<Profiler>
<React.StrictMode>{children}</React.StrictMode>
</Profiler>
),
});

const incrementButton = screen.getByText("Increment");

{
const { renderedComponents } = await Profiler.takeRender();

expect(renderedComponents).toStrictEqual([App, SuspenseFallback]);
}

// eslint-disable-next-line testing-library/render-result-naming-convention
const firstRender = await Profiler.takeRender();
const initialQueryRef = firstRender.snapshot.queryRef;

await act(() => user.click(incrementButton));

{
const { snapshot } = await Profiler.takeRender();

expect(snapshot.queryRef).toBe(initialQueryRef);
expect(fetchCount).toBe(1);
}

await expect(Profiler).not.toRerender({ timeout: 50 });
});

it("disposes of the queryRef when unmounting before it is used by useReadQuery", async () => {
const { query, mocks } = setupSimpleCase();
const client = new ApolloClient({
Expand Down
2 changes: 1 addition & 1 deletion src/react/hooks/useBackgroundQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@ function _useBackgroundQuery<
// useReadQuery will ensure the same queryRef is maintained.
React.useEffect(() => {
if (queryRef.disposed) {
suspenseCache.add(cacheKey, queryRef);
queryRef.strictModeFixAddToSuspenseCache();
}
// Omitting the deps is intentional. This avoids stale closures and the
// conditional ensures we aren't running the logic on each render.
Expand Down
1 change: 1 addition & 0 deletions src/react/hooks/useReadQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ function _useReadQuery<TData>(
// handle strict mode where this useEffect will be run twice resulting in a
// disposed queryRef before the next render.
if (internalQueryRef.disposed) {
internalQueryRef.strictModeFixAddToSuspenseCache();
internalQueryRef.reinitialize();
}

Expand Down
2 changes: 1 addition & 1 deletion src/react/hooks/useSuspenseQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,7 @@ function _useSuspenseQuery<
// We address this by adding the queryRef back to the suspense cache
// so that the lookup on the next render uses the existing queryRef rather
// than instantiating a new one.
suspenseCache.add(cacheKey, queryRef);
queryRef.strictModeFixAddToSuspenseCache();
queryRef.reinitialize();
}
// We can omit the deps here to get a fresh closure each render since the
Expand Down
9 changes: 9 additions & 0 deletions src/react/internal/cache/QueryReference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ export interface QueryReference<TData = unknown, TVariables = unknown> {

interface InternalQueryReferenceOptions {
onDispose?: () => void;
addToSuspenseCache?: (queryRef: InternalQueryReference<any>) => void;
autoDisposeTimeoutMs?: number;
}

Expand Down Expand Up @@ -147,6 +148,9 @@ export class InternalQueryReference<TData = unknown> {
public result!: ApolloQueryResult<TData>;
public readonly key: QueryKey = {};
public readonly observable: ObservableQuery<TData>;
private readonly addToSuspenseCache?: (
queryRef: InternalQueryReference<TData>
) => void;

public promise!: QueryRefPromise<TData>;

Expand All @@ -168,6 +172,7 @@ export class InternalQueryReference<TData = unknown> {
this.handleError = this.handleError.bind(this);
this.dispose = this.dispose.bind(this);
this.observable = observable;
this.addToSuspenseCache = options.addToSuspenseCache;

if (options.onDispose) {
this.onDispose = options.onDispose;
Expand Down Expand Up @@ -203,6 +208,10 @@ export class InternalQueryReference<TData = unknown> {
return this.observable.options;
}

strictModeFixAddToSuspenseCache() {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm open to other names, but figured something like this makes it easier to understand the point of it.

this.addToSuspenseCache?.(this);
}

reinitialize() {
const { observable } = this;

Expand Down
9 changes: 4 additions & 5 deletions src/react/internal/cache/SuspenseCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ export class SuspenseCache {
if (!ref.current) {
ref.current = new InternalQueryReference(createObservable(), {
autoDisposeTimeoutMs: this.options.autoDisposeTimeoutMs,
addToSuspenseCache: (queryRef) => {
const ref = this.queryRefs.lookupArray(cacheKey);
ref.current = queryRef;
},
onDispose: () => {
delete ref.current;
},
Expand All @@ -47,9 +51,4 @@ export class SuspenseCache {

return ref.current;
}

add(cacheKey: CacheKey, queryRef: InternalQueryReference<unknown>) {
const ref = this.queryRefs.lookupArray(cacheKey);
ref.current = queryRef;
}
}
Loading