diff --git a/.changeset/pink-guests-vanish.md b/.changeset/pink-guests-vanish.md new file mode 100644 index 00000000000..df9044e07ea --- /dev/null +++ b/.changeset/pink-guests-vanish.md @@ -0,0 +1,11 @@ +--- +"@apollo/client": patch +--- + +Fix a potential crash when calling `clearStore` while a query was running. + +Previously, calling `client.clearStore()` while a query was running had one of these results: +* `useQuery` would stay in a `loading: true` state. +* `useLazyQuery` would stay in a `loading: true` state, but also crash with a `"Cannot read property 'data' of undefined"` error. + +Now, in both cases, the hook will enter an error state with a `networkError`, and the promise returned by the `useLazyQuery` `execute` function will return a result in an error state. diff --git a/.size-limits.json b/.size-limits.json index e5433a23665..161c5d01b7a 100644 --- a/.size-limits.json +++ b/.size-limits.json @@ -1,4 +1,4 @@ { - "dist/apollo-client.min.cjs": 40252, - "import { ApolloClient, InMemoryCache, HttpLink } from \"dist/index.js\" (production)": 33052 + "dist/apollo-client.min.cjs": 40271, + "import { ApolloClient, InMemoryCache, HttpLink } from \"dist/index.js\" (production)": 33058 } diff --git a/src/core/ObservableQuery.ts b/src/core/ObservableQuery.ts index 1289a9735ef..c7bd4dec582 100644 --- a/src/core/ObservableQuery.ts +++ b/src/core/ObservableQuery.ts @@ -17,7 +17,7 @@ import { fixObservableSubclass, getQueryDefinition, } from "../utilities/index.js"; -import type { ApolloError } from "../errors/index.js"; +import { ApolloError, isApolloError } from "../errors/index.js"; import type { QueryManager } from "./QueryManager.js"; import type { ApolloQueryResult, @@ -974,6 +974,12 @@ Did you mean to call refetch(variables) instead of refetch({ variables })?`, }, error: (error) => { if (equal(this.variables, variables)) { + // Coming from `getResultsFromLink`, `error` here should always be an `ApolloError`. + // However, calling `concast.cancel` can inject another type of error, so we have to + // wrap it again here. + if (!isApolloError(error)) { + error = new ApolloError({ networkError: error }); + } finishWaitingForOwnResult(); this.reportError(error, variables); } diff --git a/src/react/hooks/__tests__/useLazyQuery.test.tsx b/src/react/hooks/__tests__/useLazyQuery.test.tsx index df38ea6adc0..e96dc5d09b0 100644 --- a/src/react/hooks/__tests__/useLazyQuery.test.tsx +++ b/src/react/hooks/__tests__/useLazyQuery.test.tsx @@ -25,6 +25,7 @@ import { import { useLazyQuery } from "../useLazyQuery"; import { QueryResult } from "../../types/types"; import { profileHook } from "../../../testing/internal"; +import { InvariantError } from "../../../utilities/globals"; describe("useLazyQuery Hook", () => { const helloQuery: TypedDocumentNode<{ @@ -1922,6 +1923,69 @@ describe("useLazyQuery Hook", () => { expect(options.fetchPolicy).toBe(defaultFetchPolicy); }); }); + + // regression for https://github.com/apollographql/apollo-client/issues/11988 + test("calling `clearStore` while a lazy query is running puts the hook into an error state and resolves the promise with an error result", async () => { + const link = new MockSubscriptionLink(); + let requests = 0; + link.onSetup(() => requests++); + const client = new ApolloClient({ + link, + cache: new InMemoryCache(), + }); + const ProfiledHook = profileHook(() => useLazyQuery(helloQuery)); + render(, { + wrapper: ({ children }) => ( + {children} + ), + }); + + { + const [, result] = await ProfiledHook.takeSnapshot(); + expect(result.loading).toBe(false); + expect(result.data).toBeUndefined(); + } + const execute = ProfiledHook.getCurrentSnapshot()[0]; + + const promise = execute(); + expect(requests).toBe(1); + + { + const [, result] = await ProfiledHook.takeSnapshot(); + expect(result.loading).toBe(true); + expect(result.data).toBeUndefined(); + } + + client.clearStore(); + + const executionResult = await promise; + expect(executionResult.data).toBeUndefined(); + expect(executionResult.loading).toBe(true); + expect(executionResult.error).toEqual( + new ApolloError({ + networkError: new InvariantError( + "Store reset while query was in flight (not completed in link chain)" + ), + }) + ); + + { + const [, result] = await ProfiledHook.takeSnapshot(); + expect(result.loading).toBe(false); + expect(result.data).toBeUndefined(); + expect(result.error).toEqual( + new ApolloError({ + networkError: new InvariantError( + "Store reset while query was in flight (not completed in link chain)" + ), + }) + ); + } + + link.simulateResult({ result: { data: { hello: "Greetings" } } }, true); + await expect(ProfiledHook).not.toRerender({ timeout: 50 }); + expect(requests).toBe(1); + }); }); describe.skip("Type Tests", () => { diff --git a/src/react/hooks/__tests__/useQuery.test.tsx b/src/react/hooks/__tests__/useQuery.test.tsx index 01a3d35e50f..d521a96a0c3 100644 --- a/src/react/hooks/__tests__/useQuery.test.tsx +++ b/src/react/hooks/__tests__/useQuery.test.tsx @@ -10082,6 +10082,54 @@ describe("useQuery Hook", () => { ); }); + test("calling `clearStore` while a query is running puts the hook into an error state", async () => { + const query = gql` + query { + hello + } + `; + + const link = new MockSubscriptionLink(); + let requests = 0; + link.onSetup(() => requests++); + const client = new ApolloClient({ + link, + cache: new InMemoryCache(), + }); + const ProfiledHook = profileHook(() => useQuery(query)); + render(, { + wrapper: ({ children }) => ( + {children} + ), + }); + + expect(requests).toBe(1); + { + const result = await ProfiledHook.takeSnapshot(); + expect(result.loading).toBe(true); + expect(result.data).toBeUndefined(); + } + + client.clearStore(); + + { + const result = await ProfiledHook.takeSnapshot(); + expect(result.loading).toBe(false); + expect(result.data).toBeUndefined(); + expect(result.error).toEqual( + new ApolloError({ + networkError: new InvariantError( + "Store reset while query was in flight (not completed in link chain)" + ), + }) + ); + } + + link.simulateResult({ result: { data: { hello: "Greetings" } } }, true); + await expect(ProfiledHook).not.toRerender({ timeout: 50 }); + expect(requests).toBe(1); + }); + // https://github.com/apollographql/apollo-client/issues/11938 it("does not emit `data` on previous fetch when a 2nd fetch is kicked off and the result returns an error when errorPolicy is none", async () => { const query = gql` diff --git a/src/utilities/observables/Concast.ts b/src/utilities/observables/Concast.ts index 73c36520f8b..476b6dd639e 100644 --- a/src/utilities/observables/Concast.ts +++ b/src/utilities/observables/Concast.ts @@ -256,7 +256,7 @@ export class Concast extends Observable { public cancel = (reason: any) => { this.reject(reason); this.sources = []; - this.handlers.complete(); + this.handlers.error(reason); }; }