diff --git a/.changeset/brown-tomatoes-walk.md b/.changeset/brown-tomatoes-walk.md
new file mode 100644
index 00000000000..8b48e7b12d7
--- /dev/null
+++ b/.changeset/brown-tomatoes-walk.md
@@ -0,0 +1,5 @@
+---
+'@apollo/client': patch
+---
+
+Ensure in-flight promises executed by `useLazyQuery` are rejected when `useLazyQuery` unmounts.
diff --git a/config/bundlesize.ts b/config/bundlesize.ts
index 6fd99531b19..8b94928a831 100644
--- a/config/bundlesize.ts
+++ b/config/bundlesize.ts
@@ -3,7 +3,7 @@ import { join } from "path";
import { gzipSync } from "zlib";
import bytes from "bytes";
-const gzipBundleByteLengthLimit = bytes("32.03KB");
+const gzipBundleByteLengthLimit = bytes("32.12KB");
const minFile = join("dist", "apollo-client.min.cjs");
const minPath = join(__dirname, "..", minFile);
const gzipByteLen = gzipSync(readFileSync(minPath)).byteLength;
diff --git a/src/react/hooks/__tests__/useLazyQuery.test.tsx b/src/react/hooks/__tests__/useLazyQuery.test.tsx
index 829a65b41f6..9f5f780e090 100644
--- a/src/react/hooks/__tests__/useLazyQuery.test.tsx
+++ b/src/react/hooks/__tests__/useLazyQuery.test.tsx
@@ -1,7 +1,7 @@
import React from 'react';
import { GraphQLError } from 'graphql';
import gql from 'graphql-tag';
-import { renderHook, waitFor } from '@testing-library/react';
+import { act, renderHook, waitFor } from '@testing-library/react';
import { ApolloClient, ApolloLink, ErrorPolicy, InMemoryCache, NetworkStatus, TypedDocumentNode } from '../../../core';
import { Observable } from '../../../utilities';
@@ -1014,6 +1014,78 @@ describe('useLazyQuery Hook', () => {
await wait(50);
});
+ it('aborts in-flight requests when component unmounts', async () => {
+ const query = gql`
+ query {
+ hello
+ }
+ `;
+
+ const link = new ApolloLink(() => {
+ // Do nothing to prevent
+ return null
+ });
+
+ const client = new ApolloClient({ link, cache: new InMemoryCache() })
+
+ const { result, unmount } = renderHook(() => useLazyQuery(query), {
+ wrapper: ({ children }) =>
+
+ {children}
+
+ });
+
+ const [execute] = result.current;
+
+ let promise: Promise
+ act(() => {
+ promise = execute()
+ })
+
+ unmount();
+
+ await expect(promise!).rejects.toEqual(
+ new DOMException('The operation was aborted.', 'AbortError')
+ );
+ });
+
+ it('handles aborting multiple in-flight requests when component unmounts', async () => {
+ const query = gql`
+ query {
+ hello
+ }
+ `;
+
+ const link = new ApolloLink(() => {
+ return null
+ });
+
+ const client = new ApolloClient({ link, cache: new InMemoryCache() })
+
+ const { result, unmount } = renderHook(() => useLazyQuery(query), {
+ wrapper: ({ children }) =>
+
+ {children}
+
+ });
+
+ const [execute] = result.current;
+
+ let promise1: Promise
+ let promise2: Promise
+ act(() => {
+ promise1 = execute();
+ promise2 = execute();
+ })
+
+ unmount();
+
+ const expectedError = new DOMException('The operation was aborted.', 'AbortError');
+
+ await expect(promise1!).rejects.toEqual(expectedError);
+ await expect(promise2!).rejects.toEqual(expectedError);
+ });
+
describe("network errors", () => {
async function check(errorPolicy: ErrorPolicy) {
const networkError = new Error("from the network");
diff --git a/src/react/hooks/useLazyQuery.ts b/src/react/hooks/useLazyQuery.ts
index 109a140788e..77dfbe466c4 100644
--- a/src/react/hooks/useLazyQuery.ts
+++ b/src/react/hooks/useLazyQuery.ts
@@ -1,6 +1,6 @@
import { DocumentNode } from 'graphql';
import { TypedDocumentNode } from '@graphql-typed-document-node/core';
-import { useCallback, useMemo, useRef } from 'react';
+import { useCallback, useEffect, useMemo, useRef } from 'react';
import { OperationVariables } from '../../core';
import { mergeOptions } from '../../utilities';
@@ -27,6 +27,7 @@ export function useLazyQuery(
query: DocumentNode | TypedDocumentNode,
options?: LazyQueryHookOptions
): LazyQueryResultTuple {
+ const abortControllersRef = useRef(new Set());
const internalState = useInternalState(
useApolloClient(options && options.client),
query,
@@ -71,9 +72,20 @@ export function useLazyQuery(
Object.assign(result, eagerMethods);
+ useEffect(() => {
+ return () => {
+ abortControllersRef.current.forEach((controller) => {
+ controller.abort();
+ });
+ }
+ }, [])
+
const execute = useCallback<
LazyQueryResultTuple[0]
>(executeOptions => {
+ const controller = new AbortController();
+ abortControllersRef.current.add(controller);
+
execOptionsRef.current = executeOptions ? {
...executeOptions,
fetchPolicy: executeOptions.fetchPolicy || initialFetchPolicy,
@@ -82,12 +94,16 @@ export function useLazyQuery(
};
const promise = internalState
- .asyncUpdate() // Like internalState.forceUpdate, but returns a Promise.
- .then(queryResult => Object.assign(queryResult, eagerMethods));
+ .asyncUpdate(controller.signal) // Like internalState.forceUpdate, but returns a Promise.
+ .then(queryResult => {
+ abortControllersRef.current.delete(controller);
- // Because the return value of `useLazyQuery` is usually floated, we need
- // to catch the promise to prevent unhandled rejections.
- promise.catch(() => {});
+ return Object.assign(queryResult, eagerMethods);
+ });
+
+ promise.catch(() => {
+ abortControllersRef.current.delete(controller);
+ });
return promise;
}, []);
diff --git a/src/react/hooks/useQuery.ts b/src/react/hooks/useQuery.ts
index 6486118d329..1bd4bd0087e 100644
--- a/src/react/hooks/useQuery.ts
+++ b/src/react/hooks/useQuery.ts
@@ -101,10 +101,20 @@ class InternalState {
invariant.warn("Calling default no-op implementation of InternalState#forceUpdate");
}
- asyncUpdate() {
- return new Promise>(resolve => {
+ asyncUpdate(signal: AbortSignal) {
+ return new Promise>((resolve, reject) => {
+ const watchQueryOptions = this.watchQueryOptions;
+
+ const handleAborted = () => {
+ this.asyncResolveFns.delete(resolve)
+ this.optionsToIgnoreOnce.delete(watchQueryOptions);
+ signal.removeEventListener('abort', handleAborted)
+ reject(signal.reason);
+ };
+
this.asyncResolveFns.add(resolve);
- this.optionsToIgnoreOnce.add(this.watchQueryOptions);
+ this.optionsToIgnoreOnce.add(watchQueryOptions);
+ signal.addEventListener('abort', handleAborted)
this.forceUpdate();
});
}