diff --git a/.changeset/smooth-spoons-cough.md b/.changeset/smooth-spoons-cough.md new file mode 100644 index 00000000000..dd5dc48bde1 --- /dev/null +++ b/.changeset/smooth-spoons-cough.md @@ -0,0 +1,5 @@ +--- +"@apollo/client": patch +--- + +Fix an issue where a polled query created in React strict mode may not stop polling after the component unmounts while using the `cache-and-network` fetch policy. diff --git a/.size-limits.json b/.size-limits.json index 7be0182bcaf..05c84cd4f6a 100644 --- a/.size-limits.json +++ b/.size-limits.json @@ -1,4 +1,4 @@ { - "dist/apollo-client.min.cjs": 39577, - "import { ApolloClient, InMemoryCache, HttpLink } from \"dist/index.js\" (production)": 32827 + "dist/apollo-client.min.cjs": 39581, + "import { ApolloClient, InMemoryCache, HttpLink } from \"dist/index.js\" (production)": 32832 } diff --git a/src/core/ObservableQuery.ts b/src/core/ObservableQuery.ts index 2b42f3b044e..47deef22483 100644 --- a/src/core/ObservableQuery.ts +++ b/src/core/ObservableQuery.ts @@ -781,7 +781,7 @@ Did you mean to call refetch(variables) instead of refetch({ variables })?`, options: { pollInterval }, } = this; - if (!pollInterval) { + if (!pollInterval || !this.hasObservers()) { if (pollingInfo) { clearTimeout(pollingInfo.timeout); delete this.pollingInfo; diff --git a/src/react/hooks/__tests__/useQuery.test.tsx b/src/react/hooks/__tests__/useQuery.test.tsx index a176f88903a..268ca9f3134 100644 --- a/src/react/hooks/__tests__/useQuery.test.tsx +++ b/src/react/hooks/__tests__/useQuery.test.tsx @@ -22,6 +22,7 @@ import { MockSubscriptionLink, mockSingleLink, tick, + wait, } from "../../../testing"; import { QueryResult } from "../../types/types"; import { useQuery } from "../useQuery"; @@ -1887,6 +1888,86 @@ describe("useQuery Hook", () => { requestSpy.mockRestore(); }); + // https://github.com/apollographql/apollo-client/issues/9431 + // https://github.com/apollographql/apollo-client/issues/11750 + it("stops polling when component unmounts with cache-and-network fetch policy", async () => { + const query: TypedDocumentNode<{ hello: string }> = gql` + query { + hello + } + `; + + const mocks = [ + { + request: { query }, + result: { data: { hello: "world 1" } }, + }, + { + request: { query }, + result: { data: { hello: "world 2" } }, + }, + { + request: { query }, + result: { data: { hello: "world 3" } }, + }, + ]; + + const cache = new InMemoryCache(); + + const link = new MockLink(mocks); + const requestSpy = jest.spyOn(link, "request"); + const onErrorFn = jest.fn(); + link.setOnError(onErrorFn); + + const ProfiledHook = profileHook(() => + useQuery(query, { pollInterval: 10, fetchPolicy: "cache-and-network" }) + ); + + const client = new ApolloClient({ + queryDeduplication: false, + link, + cache, + }); + + const { unmount } = render(, { + wrapper: ({ children }: any) => ( + {children} + ), + }); + + { + const snapshot = await ProfiledHook.takeSnapshot(); + + expect(snapshot.loading).toBe(true); + expect(snapshot.data).toBeUndefined(); + } + + { + const snapshot = await ProfiledHook.takeSnapshot(); + + expect(snapshot.loading).toBe(false); + expect(snapshot.data).toEqual({ hello: "world 1" }); + expect(requestSpy).toHaveBeenCalledTimes(1); + } + + await wait(10); + + { + const snapshot = await ProfiledHook.takeSnapshot(); + + expect(snapshot.loading).toBe(false); + expect(snapshot.data).toEqual({ hello: "world 2" }); + expect(requestSpy).toHaveBeenCalledTimes(2); + } + + unmount(); + + await expect(ProfiledHook).not.toRerender({ timeout: 50 }); + + expect(requestSpy).toHaveBeenCalledTimes(2); + expect(onErrorFn).toHaveBeenCalledTimes(0); + }); + it("should stop polling when component is unmounted in Strict Mode", async () => { const query = gql` { @@ -1960,6 +2041,84 @@ describe("useQuery Hook", () => { requestSpy.mockRestore(); }); + // https://github.com/apollographql/apollo-client/issues/9431 + // https://github.com/apollographql/apollo-client/issues/11750 + it("stops polling when component unmounts in strict mode with cache-and-network fetch policy", async () => { + const query: TypedDocumentNode<{ hello: string }> = gql` + query { + hello + } + `; + + const mocks = [ + { + request: { query }, + result: { data: { hello: "world 1" } }, + }, + { + request: { query }, + result: { data: { hello: "world 2" } }, + }, + { + request: { query }, + result: { data: { hello: "world 3" } }, + }, + ]; + + const cache = new InMemoryCache(); + + const link = new MockLink(mocks); + const requestSpy = jest.spyOn(link, "request"); + const onErrorFn = jest.fn(); + link.setOnError(onErrorFn); + + const ProfiledHook = profileHook(() => + useQuery(query, { pollInterval: 10, fetchPolicy: "cache-and-network" }) + ); + + const client = new ApolloClient({ link, cache }); + + const { unmount } = render(, { + wrapper: ({ children }: any) => ( + + {children} + + ), + }); + + { + const snapshot = await ProfiledHook.takeSnapshot(); + + expect(snapshot.loading).toBe(true); + expect(snapshot.data).toBeUndefined(); + } + + { + const snapshot = await ProfiledHook.takeSnapshot(); + + expect(snapshot.loading).toBe(false); + expect(snapshot.data).toEqual({ hello: "world 1" }); + expect(requestSpy).toHaveBeenCalledTimes(1); + } + + await wait(10); + + { + const snapshot = await ProfiledHook.takeSnapshot(); + + expect(snapshot.loading).toBe(false); + expect(snapshot.data).toEqual({ hello: "world 2" }); + expect(requestSpy).toHaveBeenCalledTimes(2); + } + + unmount(); + + await expect(ProfiledHook).not.toRerender({ timeout: 50 }); + + expect(requestSpy).toHaveBeenCalledTimes(2); + expect(onErrorFn).toHaveBeenCalledTimes(0); + }); + it("should start and stop polling in Strict Mode", async () => { const query = gql` {