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

useQuery: delay unsubscribe to fix race conditions #10629

Merged
merged 7 commits into from
Mar 8, 2023
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
5 changes: 5 additions & 0 deletions .changeset/young-toes-sparkle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@apollo/client": patch
---

`useQuery`: delay unsubscribe to fix race conditions
105 changes: 99 additions & 6 deletions src/react/hooks/__tests__/useQuery.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -619,6 +619,7 @@ describe('useQuery Hook', () => {

expect(client.getObservableQueries().size).toBe(1);
unmount();
await new Promise(resolve => setTimeout(resolve));
expect(client.getObservableQueries().size).toBe(0);
});

Expand Down Expand Up @@ -1724,6 +1725,14 @@ describe('useQuery Hook', () => {
request: { query },
result: { data: { hello: "world 2" } },
},
{
request: { query },
result: { data: { hello: "world 3" } },
},
{
request: { query },
result: { data: { hello: "world 4" } },
},
];

const cache = new InMemoryCache();
Expand All @@ -1746,26 +1755,37 @@ describe('useQuery Hook', () => {
expect(result.current.data).toBe(undefined);

await waitFor(() => {
expect(result.current.loading).toBe(false);
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 test tested if a single query was made but never tested if there was any actual polling going on - that's why we never were aware of this bug.

expect(result.current.data).toEqual({ hello: "world 1" });
}, { interval: 1 });
expect(result.current.data).toEqual({ hello: "world 1" });
expect(result.current.loading).toBe(false);


await waitFor(() => {
expect(result.current.data).toEqual({ hello: "world 2" });
}, { interval: 1 });
expect(result.current.loading).toBe(false);


result.current.stopPolling();

await expect(waitFor(() => {
expect(result.current.data).toEqual({ hello: "world 2" });
expect(result.current.data).toEqual({ hello: "world 3" });
}, { interval: 1, timeout: 20 })).rejects.toThrow();
result.current.startPolling(20);

expect(requestSpy).toHaveBeenCalledTimes(1);
expect(requestSpy).toHaveBeenCalledTimes(2);
expect(onErrorFn).toHaveBeenCalledTimes(0);

await waitFor(() => {
expect(result.current.data).toEqual({ hello: "world 2" });
expect(result.current.data).toEqual({ hello: "world 3" });
}, { interval: 1 });
expect(result.current.loading).toBe(false);

await waitFor(() => {
expect(result.current.data).toEqual({ hello: "world 4" });
}, { interval: 1 });
expect(result.current.loading).toBe(false);
expect(requestSpy).toHaveBeenCalledTimes(2);
expect(requestSpy).toHaveBeenCalledTimes(4);
expect(onErrorFn).toHaveBeenCalledTimes(0);
requestSpy.mockRestore();
});
Expand Down Expand Up @@ -4548,6 +4568,7 @@ describe('useQuery Hook', () => {

expect(client.getObservableQueries('all').size).toBe(1);
unmount();
await new Promise(resolve => setTimeout(resolve));
expect(client.getObservableQueries('all').size).toBe(0);
});

Expand Down Expand Up @@ -6904,4 +6925,76 @@ describe('useQuery Hook', () => {
}, { interval: 1 });
});
});

describe("interaction with `disableNetworkFetches`", () => {
const cacheData = { something: "foo" };
const emptyData = undefined;
type TestQueryValue = typeof cacheData;

test.each<
[
fetchPolicy: WatchQueryFetchPolicy,
initialQueryValue: TestQueryValue | undefined,
shouldFetchOnFirstRender: boolean,
shouldFetchOnSecondRender: boolean
]
>([
[`cache-first`, emptyData, true, false],
[`cache-first`, cacheData, false, false],
[`cache-only`, emptyData, false, false],
[`cache-only`, cacheData, false, false],
[`cache-and-network`, emptyData, true, false],
[`cache-and-network`, cacheData, false, false],
[`network-only`, emptyData, true, false],
[`network-only`, cacheData, false, false],
[`no-cache`, emptyData, true, false],
[`no-cache`, cacheData, true, false],
[`standby`, emptyData, false, false],
[`standby`, cacheData, false, false],
])(
"fetchPolicy %s, cache: %p should fetch during `disableNetworkFetches`: %p and after `disableNetworkFetches` has been disabled: %p",
async (policy, initialQueryValue, shouldFetchOnFirstRender, shouldFetchOnSecondRender) => {
Comment on lines +6934 to +6956
Copy link
Member

Choose a reason for hiding this comment

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

I love this style of writing lists of test cases! Please feel free to refactor any other places where you see something similar but uglier, if you come across any.

const query: TypedDocumentNode<TestQueryValue> = gql`
query CallMe {
something
}
`;

const link = new MockLink([
{request: {query}, result: {data: { something: "bar" }}},
{request: {query}, result: {data: { something: "baz" }}},
]);
const requestSpy = jest.spyOn(link, 'request');

const client = new ApolloClient({
cache: new InMemoryCache(),
link,
});
if (initialQueryValue) {
client.writeQuery({ query, data: initialQueryValue });
}
client.disableNetworkFetches = true;

const { rerender } = renderHook(
() => useQuery(query, { fetchPolicy: policy, nextFetchPolicy: policy }),
{
wrapper: ({ children }) => <ApolloProvider client={client}>{children}</ApolloProvider>,
}
);

expect(requestSpy).toHaveBeenCalledTimes(shouldFetchOnFirstRender ? 1 : 0);

// We need to wait a moment before the rerender for everything to settle down.
// This part is unfortunately bound to be flaky - but in some cases there is
// just nothing to "wait for", except "a moment".
await act(() => new Promise((resolve) => setTimeout(resolve, 10)));

requestSpy.mockClear();
client.disableNetworkFetches = false;

rerender();
expect(requestSpy).toHaveBeenCalledTimes(shouldFetchOnSecondRender ? 1 : 0);
}
);
});
});
6 changes: 5 additions & 1 deletion src/react/hooks/useQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,11 @@ class InternalState<TData, TVariables extends OperationVariables> {

let subscription = obsQuery.subscribe(onNext, onError);

return () => subscription.unsubscribe();
// Do the "unsubscribe" with a short delay.
// This way, an existing subscription can be reused without an additional
// request if "unsubscribe" and "resubscribe" to the same ObservableQuery
// happen in very fast succession.
return () => setTimeout(() => subscription.unsubscribe());
Comment on lines +211 to +215
Copy link
Member

Choose a reason for hiding this comment

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

Some precedent for this technique:

if (sub) setTimeout(() => sub.unsubscribe());

If the delay between the last unsubscription and the resubscription ever gets longer than the minimum setTimeout delay, that seems like it could reintroduce this problem unpredictably, but the consequences would be the same as without this change.

Copy link
Member Author

Choose a reason for hiding this comment

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

Yup, this is mostly good if the unsubscribe and subscribe are called synchronously - for everything else, it's just a band-aid - but at some level of delay, a refetch might actually be intended.

}, [
// We memoize the subscribe function using useCallback and the following
// dependency keys, because the subscribe function reference is all that
Expand Down