-
Notifications
You must be signed in to change notification settings - Fork 473
Description
Summary
Introduce a new customAbortExchange that adds native support for AbortSignal-based cancellation of GraphQL operations in urql.
This solves the problem where operations cancelled via AbortController are not reflected in urql’s exchange pipeline, leading to incomplete teardown and inconsistent error handling in consumers.
Proposed Solution
A new exchange, customAbortExchange, will be introduced. It behaves as follows:
- Hooks into the exchange pipeline and watches each incoming operation
- If the operation has a
fetchOptions.signal, it attaches anabortevent listener - When
AbortController.abort()is triggered:- Emits a
teardownoperation upstream - Emits an
OperationResultwith an error:new Error("Operation was aborted")
- Emits a
- If
signal.aborted === trueat the moment of receiving, teardown and error are emitted immediately - All event listeners are cleaned up on teardown to prevent memory leaks
This solution ensures that urql clients can handle request aborts consistently in streams or promises.
Requirements
- The exchange must:
- Detect
AbortSignalin both function and object-basedfetchOptions - Support early-aborted signals
- Emit proper
teardownoperations - Return an error result on abort for consumer-side handling
- Automatically remove event listeners after completion or teardown
- Detect
- Must not interfere with other operation kinds (e.g., subscriptions)
- Must be composable with other exchanges in the urql pipeline
🧠 Usage
import { createClient, dedupExchange, fetchExchange } from 'urql'
import { customAbortExchange } from './customAbortExchange'
const client = createClient({
url: '/graphql',
exchanges: [
dedupExchange,
customAbortExchange,
fetchExchange
],
})To cancel an operation, you can use an AbortController:
const controller = new AbortController()
client.query(MY_QUERY, {}, {
fetchOptions: {
signal: controller.signal
}
}).toPromise()
// Cancel it when needed
controller.abort()🧩 How It Works
This exchange listens for an AbortSignal on each Operation's fetchOptions.
If the signal is triggered:
- It emits a
teardownoperation for upstream exchanges - It also emits a manual
OperationResultwith anErrorso the client receives a failure result
If the signal is already aborted at the time the operation is received, it immediately emits teardown and error without forwarding.
📦 Example
const signal = new AbortController().signal
client.query(SOME_QUERY, null, {
fetchOptions: {
signal,
},
})If signal.abort() is called, the exchange will:
- Teardown the operation
- Emit an error:
new Error("Operation was aborted")
📁 Exchange source code
import { type Exchange, makeErrorResult, type Operation, type OperationResult } from 'urql'
import { makeSubject, merge, pipe, tap } from 'wonka'
// !! WARNING: USE TYPEOF OR YOUR CUSTOM FUNCTION, THIS FROM ME REPO
import { isFunction } from '@utils/guards/types'
// Custom Exchange to handle operations that were aborted via AbortSignal
export const customAbortExchange: Exchange = ({ forward }) => {
// Subject for teardown operations triggered by abort
const abortOperation = makeSubject<Operation>()
// Subject for manually returned operation results (e.g., errors from aborts)
const resultOperation = makeSubject<OperationResult>()
// Stores cleanup handlers for abort event listeners by operation key
const abortHandlerMap = new Map<number, () => void>()
return sourceOperation$ => {
// Handle incoming operations
const filteredOperation$ = pipe(
sourceOperation$,
tap(operation => {
const { kind, key } = operation
// If this is a teardown operation (manual cancel or completion), remove its abort handler
if (kind === 'teardown') {
const handler = abortHandlerMap.get(key)
if (handler) {
handler() // remove abort event listener
abortHandlerMap.delete(key)
}
return
}
// Try to extract AbortSignal from operation context
const signal = extractAbortSignal(operation)
if (!signal) {
return
}
// Define abort event handler: emit teardown and return an error result
const abortHandler = () => {
abortOperation.next({ ...operation, kind: 'teardown' })
resultOperation.next(makeErrorResult(operation, new Error('Operation was aborted')))
}
// If already aborted — immediately emit teardown and error
if (signal.aborted) {
abortHandler()
return
}
// Save cleanup function to remove the event listener later
abortHandlerMap.set(key, () => {
signal.removeEventListener('abort', abortHandler)
})
// Attach abort event listener (once)
signal.addEventListener('abort', abortHandler, { once: true })
})
)
// Merge original stream with manually triggered teardowns
const forwarded$ = forward(merge([filteredOperation$, abortOperation.source]))
// Merge the result stream with manually emitted error results
return merge([forwarded$, resultOperation.source])
}
}
// Extract AbortSignal from fetchOptions (can be a function or an object)
function extractAbortSignal(operation: Operation): AbortSignal | null | undefined {
const fetchOptions = operation.context.fetchOptions
if (!fetchOptions) {
return
}
if (isFunction(fetchOptions)) {
return fetchOptions()?.signal
}
return fetchOptions.signal
}