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

Allow queryRefs to be disposed of synchronously #11738

Merged
merged 14 commits into from
Apr 1, 2024
Merged
Show file tree
Hide file tree
Changes from all 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
2 changes: 2 additions & 0 deletions .api-reports/api-report-react_internal.md
Original file line number Diff line number Diff line change
Expand Up @@ -1731,6 +1731,8 @@ interface SubscriptionOptions<TVariables = OperationVariables, TData = any> {
class SuspenseCache {
constructor(options?: SuspenseCacheOptions);
// (undocumented)
add(cacheKey: CacheKey, queryRef: InternalQueryReference<unknown>): void;
// (undocumented)
getQueryRef<TData = any>(cacheKey: CacheKey, createObservable: () => ObservableQuery<TData>): InternalQueryReference<TData>;
}

Expand Down
5 changes: 5 additions & 0 deletions .changeset/fuzzy-grapes-pump.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@apollo/client": patch
---

Fix an issue where rerendering `useBackgroundQuery` after the `queryRef` had been disposed, either via the auto dispose timeout or by unmounting `useReadQuery`, would cause the `queryRef` to be recreated potentially resulting in another network request.
5 changes: 5 additions & 0 deletions .changeset/lovely-mayflies-grab.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@apollo/client": patch
---

Allow queryRefs to be disposed of synchronously when a suspense hook unmounts. This prevents some situations where using a suspense hook with the same query/variables as the disposed queryRef accidentally used the disposed queryRef rather than creating a new instance.
2 changes: 1 addition & 1 deletion .size-limits.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"dist/apollo-client.min.cjs": 39325,
"dist/apollo-client.min.cjs": 39368,
"import { ApolloClient, InMemoryCache, HttpLink } from \"dist/index.js\" (production)": 32634
}
164 changes: 163 additions & 1 deletion src/react/hooks/__tests__/useBackgroundQuery.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -410,7 +410,10 @@ it("auto resubscribes when mounting useReadQuery after naturally disposed by use
await wait(0);

expect(client.getObservableQueries().size).toBe(0);
expect(client).not.toHaveSuspenseCacheEntryUsing(query);
// We retain the cache entry in useBackgroundQuery to avoid recreating the
// queryRef if useBackgroundQuery rerenders before useReadQuery is mounted
// again.
expect(client).toHaveSuspenseCacheEntryUsing(query);

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

Expand Down Expand Up @@ -447,6 +450,92 @@ it("auto resubscribes when mounting useReadQuery after naturally disposed by use
await expect(Profiler).not.toRerender({ timeout: 50 });
});

it("does not recreate queryRef and execute a network request when rerendering useBackgroundQuery after queryRef is disposed", async () => {
Copy link
Member Author

Choose a reason for hiding this comment

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

This experiment helped me find a potential bug with useBackgroundQuery where rerendering useBackgroundQuery after the query ref is disposed (either by auto timeout or unmounting useReadQuery) could cause the queryRef to be recreated and execute another network request. Added a test here to check for that which was failing before this change 🙂

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 = createDefaultProfiler<SimpleCaseData>();
const { SuspenseFallback, ReadQueryHook } =
createDefaultTrackedComponents(Profiler);

function App() {
useTrackRenders();
const [show, setShow] = React.useState(true);
// 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" });

return (
<>
<button onClick={() => setShow((show) => !show)}>Toggle</button>
<Suspense fallback={<SuspenseFallback />}>
{show && <ReadQueryHook queryRef={queryRef} />}
</Suspense>
</>
);
}

const { rerender } = renderWithClient(<App />, { client, wrapper: Profiler });

const toggleButton = screen.getByText("Toggle");

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

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

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

expect(snapshot.result).toEqual({
data: { greeting: "Hello" },
error: undefined,
networkStatus: NetworkStatus.ready,
});
}

await act(() => user.click(toggleButton));
await Profiler.takeRender();
await wait(0);

rerender(<App />);
await Profiler.takeRender();

expect(fetchCount).toBe(1);

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

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

expect(renderedComponents).toStrictEqual([App, ReadQueryHook]);
expect(snapshot.result).toEqual({
data: { greeting: "Hello" },
error: undefined,
networkStatus: NetworkStatus.ready,
});
}

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 Expand Up @@ -3672,6 +3761,79 @@ it('does not suspend deferred queries with partial data in the cache and using a
await expect(Profiler).not.toRerender({ timeout: 50 });
});

it.each<SuspenseQueryHookFetchPolicy>([
"cache-first",
"network-only",
"cache-and-network",
])(
'responds to cache updates in strict mode while using a "%s" fetch policy',
async (fetchPolicy) => {
const { query, mocks } = setupSimpleCase();

const client = new ApolloClient({
cache: new InMemoryCache(),
link: new MockLink(mocks),
});

const Profiler = createDefaultProfiler<SimpleCaseData>();
const { SuspenseFallback, ReadQueryHook } =
createDefaultTrackedComponents(Profiler);

function App() {
useTrackRenders();
const [queryRef] = useBackgroundQuery(query, { fetchPolicy });

return (
<Suspense fallback={<SuspenseFallback />}>
<ReadQueryHook queryRef={queryRef} />
</Suspense>
);
}

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

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

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

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

expect(snapshot.result).toEqual({
data: { greeting: "Hello" },
error: undefined,
networkStatus: NetworkStatus.ready,
});
}

client.writeQuery({
query,
data: { greeting: "Updated hello" },
});

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

expect(snapshot.result).toEqual({
data: { greeting: "Updated hello" },
error: undefined,
networkStatus: NetworkStatus.ready,
});
}

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

describe("refetch", () => {
it("re-suspends when calling `refetch`", async () => {
const { query, mocks: defaultMocks } = setupVariablesCase();
Expand Down
14 changes: 14 additions & 0 deletions src/react/hooks/useBackgroundQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,20 @@ function _useBackgroundQuery<
updateWrappedQueryRef(wrappedQueryRef, promise);
}

// Handle strict mode where the query ref might be disposed when useEffect
// runs twice. We add the queryRef back in the suspense cache so that the next
// render will reuse this queryRef rather than initializing a new instance.
// This also prevents issues where rerendering useBackgroundQuery after the
// queryRef has been disposed, either automatically or by unmounting
// useReadQuery will ensure the same queryRef is maintained.
React.useEffect(() => {
if (queryRef.disposed) {
suspenseCache.add(cacheKey, queryRef);
}
// Omitting the deps is intentional. This avoids stale closures and the
// conditional ensures we aren't running the logic on each render.
});

const fetchMore: FetchMoreFunction<TData, TVariables> = React.useCallback(
(options) => {
const promise = queryRef.fetchMore(options as FetchMoreQueryOptions<any>);
Expand Down
12 changes: 11 additions & 1 deletion src/react/hooks/useReadQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,17 @@ function _useReadQuery<TData>(
updateWrappedQueryRef(queryRef, internalQueryRef.promise);
}

React.useEffect(() => internalQueryRef.retain(), [internalQueryRef]);
React.useEffect(() => {
// It may seem odd that we are trying to reinitialize the queryRef even
// though we reinitialize in render above, but this is necessary to
// handle strict mode where this useEffect will be run twice resulting in a
// disposed queryRef before the next render.
if (internalQueryRef.disposed) {
internalQueryRef.reinitialize();
}

return internalQueryRef.retain();
}, [internalQueryRef]);

const promise = useSyncExternalStore(
React.useCallback(
Expand Down
28 changes: 28 additions & 0 deletions src/react/hooks/useSuspenseQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,34 @@ function _useSuspenseQuery<
};
}, [queryRef]);

// This effect handles the case where strict mode causes the queryRef to get
// disposed early. Previously this was done by using a `setTimeout` inside the
// dispose function, but this could cause some issues in cases where someone
// might expect the queryRef to be disposed immediately. For example, when
// using the same client instance across multiple tests in a test suite, the
// `setTimeout` has the possibility of retaining the suspense cache entry for
// too long, which means that two tests might accidentally share the same
// `queryRef` instance. By immediately disposing, we can avoid this situation.
//
// Instead we can leverage the work done to allow the queryRef to "resume"
// after it has been disposed without executing an additional network request.
// This is done by calling the `initialize` function below.
React.useEffect(() => {
if (queryRef.disposed) {
// Calling the `dispose` function removes it from the suspense cache, so
// when the component rerenders, it instantiates a fresh query ref.
// 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.reinitialize();
}
// We can omit the deps here to get a fresh closure each render since the
// conditional will prevent the logic from running in most cases. This
// should also be a touch faster since it should be faster to check the `if`
// statement than for React to compare deps on this effect.
});

const skipResult = React.useMemo(() => {
const error = toApolloError(queryRef.result);

Expand Down
9 changes: 3 additions & 6 deletions src/react/internal/cache/QueryReference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,12 +242,9 @@ export class InternalQueryReference<TData = unknown> {
disposed = true;
this.references--;

// Wait before fully disposing in case the app is running in strict mode.
setTimeout(() => {
if (!this.references) {
this.dispose();
}
});
if (!this.references) {
this.dispose();
}
};
}

Expand Down
5 changes: 5 additions & 0 deletions src/react/internal/cache/SuspenseCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,9 @@ export class SuspenseCache {

return ref.current;
}

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