Skip to content

Add custom URQL exchange to handle aborted operations via AbortSignal #3801

@lykianovsky

Description

@lykianovsky

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 an abort event listener
  • When AbortController.abort() is triggered:
    • Emits a teardown operation upstream
    • Emits an OperationResult with an error: new Error("Operation was aborted")
  • If signal.aborted === true at 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 AbortSignal in both function and object-based fetchOptions
    • Support early-aborted signals
    • Emit proper teardown operations
    • Return an error result on abort for consumer-side handling
    • Automatically remove event listeners after completion or teardown
  • 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 teardown operation for upstream exchanges
  • It also emits a manual OperationResult with an Error so 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
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    future 🔮An enhancement or feature proposal that will be addressed after the next release

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions