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(); }); }