Skip to content

Commit

Permalink
Abort in-flight promises returned from useLazyQuery when component …
Browse files Browse the repository at this point in the history
…unmounts (#10427)
  • Loading branch information
jerelmiller committed Jan 13, 2023
1 parent d5f54c8 commit 28d909c
Show file tree
Hide file tree
Showing 5 changed files with 114 additions and 11 deletions.
5 changes: 5 additions & 0 deletions .changeset/brown-tomatoes-walk.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@apollo/client': patch
---

Ensure in-flight promises executed by `useLazyQuery` are rejected when `useLazyQuery` unmounts.
2 changes: 1 addition & 1 deletion config/bundlesize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
74 changes: 73 additions & 1 deletion src/react/hooks/__tests__/useLazyQuery.test.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 }) =>
<ApolloProvider client={client}>
{children}
</ApolloProvider>
});

const [execute] = result.current;

let promise: Promise<any>
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 }) =>
<ApolloProvider client={client}>
{children}
</ApolloProvider>
});

const [execute] = result.current;

let promise1: Promise<any>
let promise2: Promise<any>
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");
Expand Down
28 changes: 22 additions & 6 deletions src/react/hooks/useLazyQuery.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -27,6 +27,7 @@ export function useLazyQuery<TData = any, TVariables = OperationVariables>(
query: DocumentNode | TypedDocumentNode<TData, TVariables>,
options?: LazyQueryHookOptions<TData, TVariables>
): LazyQueryResultTuple<TData, TVariables> {
const abortControllersRef = useRef(new Set<AbortController>());
const internalState = useInternalState(
useApolloClient(options && options.client),
query,
Expand Down Expand Up @@ -71,9 +72,20 @@ export function useLazyQuery<TData = any, TVariables = OperationVariables>(

Object.assign(result, eagerMethods);

useEffect(() => {
return () => {
abortControllersRef.current.forEach((controller) => {
controller.abort();
});
}
}, [])

const execute = useCallback<
LazyQueryResultTuple<TData, TVariables>[0]
>(executeOptions => {
const controller = new AbortController();
abortControllersRef.current.add(controller);

execOptionsRef.current = executeOptions ? {
...executeOptions,
fetchPolicy: executeOptions.fetchPolicy || initialFetchPolicy,
Expand All @@ -82,12 +94,16 @@ export function useLazyQuery<TData = any, TVariables = OperationVariables>(
};

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;
}, []);
Expand Down
16 changes: 13 additions & 3 deletions src/react/hooks/useQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,10 +101,20 @@ class InternalState<TData, TVariables> {
invariant.warn("Calling default no-op implementation of InternalState#forceUpdate");
}

asyncUpdate() {
return new Promise<QueryResult<TData, TVariables>>(resolve => {
asyncUpdate(signal: AbortSignal) {
return new Promise<QueryResult<TData, TVariables>>((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();
});
}
Expand Down

0 comments on commit 28d909c

Please sign in to comment.