Skip to content

Commit 2a32ac6

Browse files
authored
do not overwrite own AbortSignal if an external AbortSignal is passed in (#12650)
1 parent 81b03d8 commit 2a32ac6

4 files changed

Lines changed: 97 additions & 41 deletions

File tree

.changeset/quiet-balloons-wave.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@apollo/client": patch
3+
---
4+
5+
Fix a situation where a passed-in `AbortSignal` would override internal unsubscription cancellation behaviour.

.size-limits.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
2-
"import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (CJS)": 43776,
3-
"import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (production) (CJS)": 38564,
4-
"import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\"": 33542,
5-
"import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (production)": 27587
2+
"import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (CJS)": 43876,
3+
"import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (production) (CJS)": 38663,
4+
"import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\"": 33603,
5+
"import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (production)": 27660
66
}

src/link/http/BaseHttpLink.ts

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
import { selectURI } from "./selectURI.js";
2626

2727
const backupFetch = maybe(() => fetch);
28+
function noop() {}
2829

2930
export declare namespace BaseHttpLink {
3031
/**
@@ -322,11 +323,34 @@ export class BaseHttpLink extends ApolloLink {
322323
);
323324
}
324325

325-
let controller: AbortController | undefined;
326-
if (!options.signal && typeof AbortController !== "undefined") {
327-
controller = new AbortController();
328-
options.signal = controller.signal;
326+
let controller: AbortController | undefined = new AbortController();
327+
let cleanupController = () => {
328+
controller = undefined;
329+
};
330+
if (options.signal) {
331+
const externalSignal: AbortSignal = options.signal;
332+
// in an ideal world we could use `AbortSignal.any` here, but
333+
// React Native uses https://github.com/mysticatea/abort-controller as
334+
// a polyfill for `AbortController`, and it does not support `AbortSignal.any`.
335+
336+
const listener = () => {
337+
controller?.abort(externalSignal.reason);
338+
};
339+
externalSignal.addEventListener("abort", listener, { once: true });
340+
cleanupController = () => {
341+
controller?.signal.removeEventListener("abort", cleanupController);
342+
controller = undefined;
343+
// on cleanup, we need to stop listening to `options.signal` to avoid memory leaks
344+
externalSignal.removeEventListener("abort", listener);
345+
cleanupController = noop;
346+
};
347+
// react native also does not support the addEventListener `signal` option
348+
// so we have to simulate that ourself
349+
controller.signal.addEventListener("abort", cleanupController, {
350+
once: true,
351+
});
329352
}
353+
options.signal = controller.signal;
330354

331355
if (useGETForQueries && !isMutationOperation(operation.query)) {
332356
options.method = "GET";
@@ -365,11 +389,11 @@ export class BaseHttpLink extends ApolloLink {
365389
}
366390
})
367391
.then(() => {
368-
controller = undefined;
392+
cleanupController();
369393
observer.complete();
370394
})
371395
.catch((err) => {
372-
controller = undefined;
396+
cleanupController();
373397
observer.error(err);
374398
});
375399

src/link/http/__tests__/HttpLink.ts

Lines changed: 58 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1159,52 +1159,79 @@ describe("HttpLink", () => {
11591159
expect(abortControllers[0].signal.aborted).toBe(true);
11601160
});
11611161

1162-
it("a passed-in signal will be forwarded to the `fetch` call and not be overwritten by an internally-created one", () => {
1163-
const fetch = jest.fn(async (_uri, _options) =>
1164-
Response.json({ data: { stub: { id: "foo" } } }, { status: 200 })
1165-
);
1166-
const externalAbortController = new AbortController();
1162+
it("a passed-in signal that is aborted will fail the observable with an `AbortError`", async () => {
1163+
try {
1164+
fetchMock.restore();
1165+
fetchMock.postOnce(
1166+
"data",
1167+
async () => '{ "data": { "stub": { "id": "foo" } } }',
1168+
{ delay: 100 }
1169+
);
11671170

1168-
const link = createHttpLink({
1169-
uri: "data",
1170-
fetch,
1171-
fetchOptions: { signal: externalAbortController.signal },
1172-
});
1171+
const externalAbortController = new AbortController();
1172+
const abortControllers = trackGlobalAbortControllers();
11731173

1174-
const sub = execute(link, { query: sampleQuery }).subscribe(
1175-
failingObserver
1176-
);
1177-
sub.unsubscribe();
1174+
const link = createHttpLink({
1175+
uri: "/data",
1176+
});
11781177

1179-
expect(fetch.mock.calls.length).toBe(1);
1180-
expect(fetch.mock.calls[0][1]).toEqual(
1181-
expect.objectContaining({ signal: externalAbortController.signal })
1182-
);
1178+
const observable = execute(link, {
1179+
query: sampleQuery,
1180+
context: {
1181+
fetchOptions: { signal: externalAbortController.signal },
1182+
},
1183+
});
1184+
1185+
const internalAbortController = abortControllers[0];
1186+
1187+
const stream = new ObservableStream(observable);
1188+
const externalReason = new Error("External abort reason");
1189+
1190+
externalAbortController.abort(externalReason);
1191+
1192+
await expect(stream).toEmitError(
1193+
// this not being `externalReason` is a quirk of `fetch-mock`:
1194+
// https://github.com/wheresrhys/fetch-mock/blob/605ec0afa6a5ff35066b9e01a9bcd688f3c25ce0/packages/fetch-mock/src/Router.ts#L164-L167
1195+
new DOMException("The operation was aborted.", "AbortError")
1196+
);
1197+
1198+
expect(externalAbortController).not.toBe(internalAbortController);
1199+
expect(externalAbortController.signal.aborted).toBe(true);
1200+
expect(externalAbortController.signal.reason).toBe(externalReason);
1201+
expect(internalAbortController.signal.aborted).toBe(true);
1202+
expect(internalAbortController.signal.reason).toBe(externalReason);
1203+
} finally {
1204+
fetchMock.restore();
1205+
}
11831206
});
11841207

1185-
it("a passed-in signal that is cancelled will fail the observable with an `AbortError`", async () => {
1208+
it("a passed-in signal will not fully overwrite the internally created one", () => {
11861209
try {
1210+
const externalAbortController = new AbortController();
1211+
const abortControllers = trackGlobalAbortControllers();
1212+
11871213
fetchMock.restore();
11881214
fetchMock.postOnce(
11891215
"data",
11901216
async () => '{ "data": { "stub": { "id": "foo" } } }'
11911217
);
11921218

1193-
const externalAbortController = new AbortController();
1194-
1195-
const link = createHttpLink({
1219+
const link = new HttpLink({
11961220
uri: "/data",
1197-
fetchOptions: { signal: externalAbortController.signal },
11981221
});
11991222

1200-
const error = await new Promise<Error>((resolve) => {
1201-
execute(link, { query: sampleQuery }).subscribe({
1202-
...failingObserver,
1203-
error: resolve,
1204-
});
1205-
externalAbortController.abort();
1206-
});
1207-
expect(error.name).toBe("AbortError");
1223+
const sub = execute(link, {
1224+
query: sampleQuery,
1225+
context: {
1226+
fetchOptions: { signal: externalAbortController.signal },
1227+
},
1228+
}).subscribe(failingObserver);
1229+
const internalAbortController = abortControllers[0];
1230+
1231+
sub.unsubscribe();
1232+
1233+
expect(externalAbortController.signal.aborted).toBe(false);
1234+
expect(internalAbortController.signal.aborted).toBe(true);
12081235
} finally {
12091236
fetchMock.restore();
12101237
}

0 commit comments

Comments
 (0)