-
Notifications
You must be signed in to change notification settings - Fork 2.7k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
HttpLink
/BatchHttpLink
: Abort AbortController signal more granularly.
#11040
Changes from all commits
ae5a3a8
fd3a79b
bf6c2b6
0b36195
3d9fddb
f9f1022
766964b
0723cce
8bb804d
8767527
bf36ad4
13b2ca9
97dbfe2
fcf54d6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
--- | ||
'@apollo/client': minor | ||
--- | ||
|
||
`HttpLink`/`BatchHttpLink`: Abort the `AbortController` signal more granularly. | ||
Before this change, when `HttpLink`/`BatchHttpLink` created an `AbortController` | ||
internally, the signal would always be `.abort`ed after the request was completed. This could cause issues with Sentry Session Replay and Next.js App Router Cache invalidations, which just replayed the fetch with the same options - including the cancelled `AbortSignal`. | ||
|
||
With this change, the `AbortController` will only be `.abort()`ed by outside events, | ||
not as a consequence of the request completing. |
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Any chance you could add some of these tests for batch http link? Since the batch link doesn't use There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sure thing. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Once you're happy with how the tests are written for the HttpLink, I'll recreate them over there :) |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -13,7 +13,7 @@ import { HttpLink } from '../HttpLink'; | |
import { createHttpLink } from '../createHttpLink'; | ||
import { ClientParseError } from '../serializeFetchParameter'; | ||
import { ServerParseError } from '../parseAndCheckHttpResponse'; | ||
import { ServerError } from '../../..'; | ||
import { FetchResult, ServerError } from '../../..'; | ||
import { voidFetchDuringEachTest } from './helpers'; | ||
import { itAsync } from '../../../testing'; | ||
|
||
|
@@ -1325,46 +1325,101 @@ describe('HttpLink', () => { | |
}), | ||
); | ||
}); | ||
itAsync('supports being cancelled and does not throw', (resolve, reject) => { | ||
let called = false; | ||
class AbortController { | ||
signal: {}; | ||
abort = () => { | ||
called = true; | ||
}; | ||
} | ||
|
||
(global as any).AbortController = AbortController; | ||
describe('AbortController', () => { | ||
const originalAbortController = globalThis.AbortController; | ||
afterEach(() => { | ||
globalThis.AbortController = originalAbortController; | ||
}); | ||
|
||
fetch.mockReturnValueOnce(Promise.resolve({ text })); | ||
text.mockReturnValueOnce( | ||
Promise.resolve('{ "data": { "hello": "world" } }'), | ||
); | ||
function trackGlobalAbortControllers() { | ||
const instances: AbortController[] = [] | ||
class AbortControllerMock { | ||
constructor() { | ||
const instance = new originalAbortController() | ||
instances.push(instance) | ||
return instance | ||
} | ||
} | ||
|
||
const link = createHttpLink({ uri: 'data', fetch: fetch as any }); | ||
globalThis.AbortController = AbortControllerMock as any; | ||
return instances; | ||
} | ||
|
||
const sub = execute(link, { query: sampleQuery }).subscribe({ | ||
next: result => { | ||
reject('result should not have been called'); | ||
const failingObserver: Observer<FetchResult> = { | ||
next: () => { | ||
fail('result should not have been called'); | ||
}, | ||
error: e => { | ||
reject(e); | ||
fail(e); | ||
}, | ||
complete: () => { | ||
reject('complete should not have been called'); | ||
fail('complete should not have been called'); | ||
}, | ||
} | ||
|
||
function mockFetch() { | ||
const text = jest.fn(async () => '{}'); | ||
const fetch = jest.fn(async (uri, options) => ({ text })); | ||
return { text, fetch } | ||
} | ||
Comment on lines
+1361
to
+1365
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The global |
||
|
||
it("aborts the request when unsubscribing before the request has completed", () => { | ||
const { fetch } = mockFetch(); | ||
const abortControllers = trackGlobalAbortControllers(); | ||
|
||
const link = createHttpLink({ uri: 'data', fetch: fetch as any }); | ||
|
||
const sub = execute(link, { query: sampleQuery }).subscribe(failingObserver); | ||
sub.unsubscribe(); | ||
|
||
expect(abortControllers.length).toBe(1); | ||
expect(abortControllers[0].signal.aborted).toBe(true); | ||
}); | ||
sub.unsubscribe(); | ||
|
||
setTimeout( | ||
makeCallback(resolve, reject, () => { | ||
delete (global as any).AbortController; | ||
expect(called).toBe(true); | ||
fetch.mockReset(); | ||
text.mockReset(); | ||
}), | ||
150, | ||
); | ||
it('a passed-in signal will be forwarded to the `fetch` call and not be overwritten by an internally-created one', () => { | ||
const { fetch } = mockFetch(); | ||
const externalAbortController = new AbortController(); | ||
|
||
const link = createHttpLink({ uri: 'data', fetch: fetch as any, fetchOptions: { signal: externalAbortController.signal } }); | ||
|
||
const sub = execute(link, { query: sampleQuery } ).subscribe(failingObserver); | ||
sub.unsubscribe(); | ||
|
||
expect(fetch.mock.calls.length).toBe(1); | ||
expect(fetch.mock.calls[0][1]).toEqual(expect.objectContaining({ signal: externalAbortController.signal })) | ||
}); | ||
|
||
it('resolving fetch does not cause the AbortController to be aborted', async () => { | ||
const { text, fetch } = mockFetch(); | ||
const abortControllers = trackGlobalAbortControllers(); | ||
text.mockResolvedValueOnce('{ "data": { "hello": "world" } }'); | ||
|
||
// (the request is already finished at that point) | ||
const link = createHttpLink({ uri: 'data', fetch: fetch as any }); | ||
|
||
await new Promise<void>(resolve => execute(link, { query: sampleQuery }).subscribe({ | ||
complete: resolve | ||
})); | ||
|
||
expect(abortControllers.length).toBe(1); | ||
expect(abortControllers[0].signal.aborted).toBe(false); | ||
}); | ||
|
||
it('an unsuccessful fetch does not cause the AbortController to be aborted', async () => { | ||
const { fetch } = mockFetch(); | ||
const abortControllers = trackGlobalAbortControllers(); | ||
fetch.mockRejectedValueOnce("This is an error!") | ||
// the request would be closed by the browser in the case of an error anyways | ||
const link = createHttpLink({ uri: 'data', fetch: fetch as any }); | ||
|
||
await new Promise<void>(resolve => execute(link, { query: sampleQuery }).subscribe({ | ||
error: resolve | ||
})); | ||
|
||
expect(abortControllers.length).toBe(1); | ||
expect(abortControllers[0].signal.aborted).toBe(false); | ||
}); | ||
}); | ||
|
||
const body = '{'; | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎉 Love this change. Love seeing
any
start to disappear!