Skip to content

Commit bf46fe0

Browse files
authored
fix: add AbortController support to createFetchMultipartSubscription (#13111)
1 parent 3a10356 commit bf46fe0

3 files changed

Lines changed: 147 additions & 2 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@apollo/client": patch
3+
---
4+
5+
Fix `createFetchMultipartSubscription` to support cancellation via `AbortController`
6+
7+
Previously, calling `dispose()` or `unsubscribe()` on a subscription created by `createFetchMultipartSubscription` had no effect - the underlying fetch request would continue running until completion. This was because no `AbortController` was created or passed to `fetch()`, and no cleanup function was returned from the Observable.
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import type { RequestParameters } from "relay-runtime";
2+
3+
import { createFetchMultipartSubscription } from "@apollo/client/utilities/subscriptions/relay";
4+
5+
const mockRequestParameters: RequestParameters = {
6+
cacheID: "test-cache-id",
7+
id: null,
8+
text: "subscription { test }",
9+
name: "TestSubscription",
10+
operationKind: "subscription",
11+
metadata: {},
12+
} as const;
13+
14+
describe("createFetchMultipartSubscription", () => {
15+
describe("abort controller support", () => {
16+
it("should pass an abort signal to fetch", () => {
17+
let receivedSignal: AbortSignal | undefined;
18+
19+
const mockFetch = jest.fn(
20+
(_url: string, options: RequestInit) =>
21+
new Promise<Response>(() => {
22+
// Capture the signal for verification
23+
receivedSignal = options.signal as AbortSignal;
24+
})
25+
);
26+
27+
const subscribe = createFetchMultipartSubscription("/graphql", {
28+
fetch: mockFetch as typeof fetch,
29+
});
30+
31+
const observable = subscribe(mockRequestParameters, {});
32+
33+
observable.subscribe({
34+
next: () => {},
35+
error: () => {},
36+
complete: () => {},
37+
});
38+
39+
expect(mockFetch).toHaveBeenCalled();
40+
expect(receivedSignal).toBeDefined();
41+
expect(receivedSignal?.aborted).toBe(false);
42+
});
43+
44+
it("should abort the fetch when unsubscribe is called", () => {
45+
let receivedSignal: AbortSignal | undefined;
46+
47+
const mockFetch = jest.fn(
48+
(_url: string, options: RequestInit) =>
49+
new Promise<Response>(() => {
50+
receivedSignal = options.signal as AbortSignal;
51+
})
52+
);
53+
54+
const subscribe = createFetchMultipartSubscription("/graphql", {
55+
fetch: mockFetch as typeof fetch,
56+
});
57+
58+
const observable = subscribe(mockRequestParameters, {});
59+
60+
const subscription = observable.subscribe({
61+
next: () => {},
62+
error: () => {},
63+
complete: () => {},
64+
});
65+
66+
expect(receivedSignal?.aborted).toBe(false);
67+
68+
subscription.unsubscribe();
69+
70+
expect(receivedSignal?.aborted).toBe(true);
71+
});
72+
73+
it("should not call sink.error when fetch is aborted", async () => {
74+
const errorSpy = jest.fn();
75+
76+
const mockFetch = jest.fn((_url: string, options: RequestInit) => {
77+
return new Promise<Response>((_resolve, reject) => {
78+
options.signal?.addEventListener("abort", () => {
79+
const abortError = new Error("The operation was aborted.");
80+
abortError.name = "AbortError";
81+
reject(abortError);
82+
});
83+
});
84+
});
85+
86+
const subscribe = createFetchMultipartSubscription("/graphql", {
87+
fetch: mockFetch as typeof fetch,
88+
});
89+
90+
const observable = subscribe(mockRequestParameters, {});
91+
92+
const subscription = observable.subscribe({
93+
next: () => {},
94+
error: errorSpy,
95+
complete: () => {},
96+
});
97+
98+
subscription.unsubscribe();
99+
100+
// Allow any pending promises to resolve
101+
await new Promise((resolve) => setTimeout(resolve, 0));
102+
103+
expect(errorSpy).not.toHaveBeenCalled();
104+
});
105+
106+
it("should still call sink.error for non-abort errors", async () => {
107+
const errorSpy = jest.fn();
108+
const networkError = new Error("Network failure");
109+
110+
const mockFetch = jest.fn(() => Promise.reject(networkError));
111+
112+
const subscribe = createFetchMultipartSubscription("/graphql", {
113+
fetch: mockFetch as typeof fetch,
114+
});
115+
116+
const observable = subscribe(mockRequestParameters, {});
117+
118+
observable.subscribe({
119+
next: () => {},
120+
error: errorSpy,
121+
complete: () => {},
122+
});
123+
124+
// Allow any pending promises to resolve
125+
await new Promise((resolve) => setTimeout(resolve, 0));
126+
127+
expect(errorSpy).toHaveBeenCalledWith(networkError);
128+
});
129+
});
130+
});

src/utilities/subscriptions/relay/index.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ export function createFetchMultipartSubscription(
3333
const options = generateOptionsForMultipartSubscription(headers || {});
3434

3535
return Observable.create((sink) => {
36+
const controller = new AbortController();
37+
3638
try {
3739
options.body = JSON.stringify(body);
3840
} catch (parseError) {
@@ -42,7 +44,7 @@ export function createFetchMultipartSubscription(
4244
const currentFetch = preferredFetch || maybe(() => fetch) || backupFetch;
4345
const observerNext = sink.next.bind(sink);
4446

45-
currentFetch!(uri, options)
47+
currentFetch!(uri, { ...options, signal: controller.signal })
4648
.then((response) => {
4749
const ctype = response.headers?.get("content-type");
4850

@@ -56,8 +58,14 @@ export function createFetchMultipartSubscription(
5658
sink.complete();
5759
})
5860
.catch((err: any) => {
59-
sink.error(err);
61+
if (err.name !== "AbortError") {
62+
sink.error(err);
63+
}
6064
});
65+
66+
return () => {
67+
controller.abort();
68+
};
6169
});
6270
};
6371
}

0 commit comments

Comments
 (0)