From 579d997eacb3d30d937aa8ba3c3c746b1421f1bc Mon Sep 17 00:00:00 2001 From: Rich Lewis <1149213+RichLewis007@users.noreply.github.com> Date: Thu, 23 Oct 2025 11:43:31 -0400 Subject: [PATCH 1/3] RFC: Unified cancellation via AbortSignal --- text/0000-unified-cancellation-abortsignal.md | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 text/0000-unified-cancellation-abortsignal.md diff --git a/text/0000-unified-cancellation-abortsignal.md b/text/0000-unified-cancellation-abortsignal.md new file mode 100644 index 00000000..a080bd1c --- /dev/null +++ b/text/0000-unified-cancellation-abortsignal.md @@ -0,0 +1,25 @@ +# RFC: Unified cancellation via AbortSignal across React async APIs + +## Summary +Introduce an opt-in, web-standard cancellation model using AbortSignal that flows through startTransition, Suspense/use, and server actions. + +## Motivation +Async work spans client and server; stale work currently continues running. Unified cancellation reduces wasted work and aligns with platform fetch semantics. + +## Detailed design +- startTransition(fn, { signal? }) returns { signal, cancel }. +- use(resource, { signal }) aborts when boundary unmounts/hides. +- Server actions receive { signal } and propagate to fetch/I/O. +- DevTools surfaces cancelled work and its source. + +## Drawbacks +API surface growth; scheduling semantics must remain predictable. + +## Alternatives +Library-level patterns; per-feature ad hoc cancellation. + +## Prior art +Link to related issues you found and AbortController spec. + +## Open questions +Interaction with useOptimistic, partial pre-render, router integrations, and transitions that finish synchronously. From 88f2a1235cecc47267eed1a613a8098be757355a Mon Sep 17 00:00:00 2001 From: Rich Lewis <1149213+RichLewis007@users.noreply.github.com> Date: Thu, 23 Oct 2025 12:52:22 -0400 Subject: [PATCH 2/3] draft 1 --- .gitignore | 1 + text/0000-unified-cancellation-abortsignal.md | 176 ++++++++++++++++-- 2 files changed, 166 insertions(+), 11 deletions(-) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..e61812f4 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +notes/ diff --git a/text/0000-unified-cancellation-abortsignal.md b/text/0000-unified-cancellation-abortsignal.md index a080bd1c..6e0646a2 100644 --- a/text/0000-unified-cancellation-abortsignal.md +++ b/text/0000-unified-cancellation-abortsignal.md @@ -1,25 +1,179 @@ # RFC: Unified cancellation via AbortSignal across React async APIs +- Start Date: 2025-10-23 +- RFC PR: (leave this as a link to the PR) +- React Issue: n/a + ## Summary -Introduce an opt-in, web-standard cancellation model using AbortSignal that flows through startTransition, Suspense/use, and server actions. + +Introduce an opt-in, web-standard cancellation model using `AbortSignal` that flows consistently through React async features: `startTransition`, Suspense and `use`, and server actions. The goal is to stop stale work predictably on both client and server, reduce wasted computation and I/O, and align React with platform semantics. ## Motivation -Async work spans client and server; stale work currently continues running. Unified cancellation reduces wasted work and aligns with platform fetch semantics. + +React now spans client and server execution with transitions, Suspense, `use`, and server actions. Today, when a user navigates away or starts a superseding transition, previously initiated work often continues to run. Examples: + +- A transition that becomes obsolete continues resolving data and scheduling renders. +- A promise read via `use` keeps resolving after its Suspense boundary is abandoned. +- A server action keeps running even if the user leaves the page, causing unnecessary server load. + +Lack of a shared cancellation model makes performance tuning and correctness harder. A unified `AbortSignal` based approach would provide a predictable, composable way to stop irrelevant work and free resources early. ## Detailed design -- startTransition(fn, { signal? }) returns { signal, cancel }. -- use(resource, { signal }) aborts when boundary unmounts/hides. -- Server actions receive { signal } and propagate to fetch/I/O. -- DevTools surfaces cancelled work and its source. + +This RFC proposes a minimal cross-cutting integration that threads `AbortSignal` through core async APIs. The design is opt-in and advisory: React remains free to commit already-completed urgent work, but ongoing async work observes cancellation consistently. + +### 1) Transitions + +```ts +type TransitionOptions = { + signal?: AbortSignal; +}; + +type TransitionHandle = { + signal: AbortSignal; + cancel: () => void; +}; + +declare function startTransition( + fn: () => void, + options?: TransitionOptions +): TransitionHandle; +``` + +Behavior + +- `startTransition` returns a handle `{ signal, cancel }`. +- React aborts `signal` automatically when the transition is superseded by a newer transition or when the associated UI path is abandoned. +- Users may call `cancel()` to abort explicitly. +- Any async work within the transition that accepts a signal should observe `signal.aborted === true` and stop. + +### 2) Suspense and `use` + +```ts +declare function use( + resource: Promise | { read: () => T }, + options?: { signal?: AbortSignal } +): T; +``` + +Behavior + +- When a Suspense boundary unmounts or becomes hidden such that its work is not going to be displayed, React aborts the associated signal. +- Fetches or data loaders that participate in `use` should pass the signal to underlying I/O so that in-flight work can be cancelled by the platform. + +### 3) Server actions + +```ts +// Server +export async function action( + formData: FormData, + ctx: { signal: AbortSignal } +) { + // Pass ctx.signal to fetch and other cancellable work +} +``` + +Behavior + +- The client side transition or navigation creates a controller whose `signal` is propagated to server actions. +- If the client navigates away, supersedes the transition, or cancels explicitly, the server receives an aborted signal that propagates to `fetch` and other cancellable I/O. + +### 4) DevTools and tracing + +- DevTools can show cancelled work items with a simple trace: what was cancelled, by whom (for example, superseding transition or navigation), and when. +- This is diagnostic only. No behavior depends on DevTools being present. + +### Scheduling and guarantees + +- Cancellation is best-effort. If an update is already committed, React will not roll it back due to an abort. +- React continues to coalesce updates and preserve scheduling semantics. The `signal` allows userland and platform I/O to stop ongoing work. + +## Examples + +#### Search box with superseding transitions + +```tsx +function SearchBox() { + const [query, setQuery] = useState(""); + + const { signal, cancel } = startTransition(async () => { + const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`, { + signal, + }); + const data = await res.json(); + // set state with results + }); + + useEffect(() => () => cancel(), [cancel]); // cancel on unmount + + return setQuery(e.target.value)} />; +} +``` + +#### Suspense + `use` with cancellation + +```tsx +function UserPanel({ id }) { + const controller = new AbortController(); + useEffect(() => () => controller.abort(), []); // boundary cleanup + + const user = use(fetch(`/api/user/${id}`, { signal: controller.signal })); + + return
{user.name}
; +} +``` + +#### Server action + +```ts +export async function savePost(formData: FormData, { signal }: { signal: AbortSignal }) { + const res = await fetch("https://api.example.com/save", { + method: "POST", + body: formData, + signal, + }); + return res.ok; +} +``` + +## How we teach this + +- Teach that React does not invent a new cancel primitive. It uses the platform standard `AbortController` and `AbortSignal`. +- Show patterns for threading a signal through data loaders, fetch calls, and custom async functions. +- Emphasize that cancellation is opt-in and advisory, and that React will not undo already committed UI. ## Drawbacks -API surface growth; scheduling semantics must remain predictable. + +- Additional API surface on `startTransition` and `use`. +- Requires library authors to plumb `AbortSignal` through their async layers to capture full benefits. +- Misuse is possible if code ignores the signal, leading to partial adoption and inconsistent results. ## Alternatives -Library-level patterns; per-feature ad hoc cancellation. + +- Status quo: continue to cancel at the library level only, or rely on ad hoc patterns per feature. +- Userland state libraries: provide signal-like patterns, but lack a unified story across transitions, Suspense, and server actions. +- Promise cancellation tokens: non-standard and not aligned with the Web Platform. ## Prior art -Link to related issues you found and AbortController spec. -## Open questions -Interaction with useOptimistic, partial pre-render, router integrations, and transitions that finish synchronously. +- Web Platform `AbortController` and `AbortSignal` widely used with `fetch`. +- Data fetching libraries that support cancellation via `AbortSignal`. +- Community requests for cancellation hooks around transitions, Suspense, and server actions. + +## Unresolved questions + +- Should `startTransition` always return a handle, or only when an option is provided. +- Interaction with `useOptimistic` state and partial pre-rendering on the server. +- Router integration points for navigation driven cancellation. +- What minimal telemetry should DevTools surface by default. + +## Adoption strategy + +- Keep all new parameters optional. +- Encourage library authors and frameworks to thread `AbortSignal`. +- Provide examples and cookbook entries in docs so teams can adopt incrementally. + +## Future work + +- Integrations with router transitions and form actions in popular frameworks. +- Ergonomics helpers for composing controllers across nested transitions and boundaries. From ac2754cb51b32a76339271bd4a9a577dddba454f Mon Sep 17 00:00:00 2001 From: Rich Lewis <1149213+RichLewis007@users.noreply.github.com> Date: Thu, 23 Oct 2025 17:28:44 -0400 Subject: [PATCH 3/3] Final version of RFC --- text/0000-unified-cancellation-abortsignal.md | 805 ++++++++++++++++-- 1 file changed, 736 insertions(+), 69 deletions(-) diff --git a/text/0000-unified-cancellation-abortsignal.md b/text/0000-unified-cancellation-abortsignal.md index 6e0646a2..a722d16f 100644 --- a/text/0000-unified-cancellation-abortsignal.md +++ b/text/0000-unified-cancellation-abortsignal.md @@ -1,12 +1,12 @@ # RFC: Unified cancellation via AbortSignal across React async APIs - Start Date: 2025-10-23 -- RFC PR: (leave this as a link to the PR) -- React Issue: n/a +- RFC PR: +- React Issue: ## Summary -Introduce an opt-in, web-standard cancellation model using `AbortSignal` that flows consistently through React async features: `startTransition`, Suspense and `use`, and server actions. The goal is to stop stale work predictably on both client and server, reduce wasted computation and I/O, and align React with platform semantics. +Introduce an opt-in, web-standard cancellation model using `AbortSignal` that flows consistently through React async features: `startTransition`, Suspense with `use`, and server actions. The goal is to stop stale work predictably on both client and server, reduce wasted computation and I/O, and align React with platform semantics. All cancellation is advisory and backwards compatible—existing code continues to work unchanged. ## Motivation @@ -22,6 +22,10 @@ Lack of a shared cancellation model makes performance tuning and correctness har This RFC proposes a minimal cross-cutting integration that threads `AbortSignal` through core async APIs. The design is opt-in and advisory: React remains free to commit already-completed urgent work, but ongoing async work observes cancellation consistently. +### Design principles + +All cancellation is optional and advisory. Existing code works unchanged. `startTransition` can still be called without expecting a return value. Signals inform but don't control React's scheduling—React may commit already-prepared updates even after cancellation. Signals can be chained and passed through async boundaries, allowing userland code to participate. The design uses Web Platform `AbortSignal` and `AbortController`, not custom primitives. + ### 1) Transitions ```ts @@ -34,146 +38,809 @@ type TransitionHandle = { cancel: () => void; }; +// Overloads to maintain backwards compatibility declare function startTransition( - fn: () => void, + fn: (signal: AbortSignal) => void +): TransitionHandle; + +declare function startTransition( + fn: (signal: AbortSignal) => void, options?: TransitionOptions ): TransitionHandle; + +// useTransition hook returns startTransition with same signature +declare function useTransition(): [ + isPending: boolean, + startTransition: ( + fn: (signal: AbortSignal) => void, + options?: TransitionOptions + ) => TransitionHandle +]; ``` -Behavior +`startTransition` now returns a `TransitionHandle` containing `{ signal, cancel }`. The callback receives the `signal` as its first parameter, enabling immediate access. React aborts `signal` automatically when the transition is superseded by a newer transition or when the associated UI path is abandoned (component unmounts, navigation occurs). Users may call `cancel()` to abort explicitly at any time. + +If an external `signal` is provided via `options.signal`, React will listen to it and cancel the transition if that external signal aborts. When `options.signal` aborts, React aborts the transition's internal signal and the returned handle's `signal`. Aborting the handle also aborts the internal signal but does not change the external signal's state. If `options.signal` is already aborted at call time, React still invokes the callback synchronously and returns a handle whose `signal` is immediately aborted with the same `reason`. -- `startTransition` returns a handle `{ signal, cancel }`. -- React aborts `signal` automatically when the transition is superseded by a newer transition or when the associated UI path is abandoned. -- Users may call `cancel()` to abort explicitly. -- Any async work within the transition that accepts a signal should observe `signal.aborted === true` and stop. +Any async work within the transition that accepts a signal should observe `signal.aborted === true` and stop gracefully. + +For backwards compatibility, callbacks that ignore parameters (e.g., `startTransition(() => {...})`) remain valid; React still returns a handle, and TypeScript accepts a zero-param callback where a one-param callback is expected. Existing calls to `startTransition(fn)` that don't expect a return value continue to work. ### 2) Suspense and `use` ```ts +// React internally provides a signal when rendering within a Suspense boundary declare function use( - resource: Promise | { read: () => T }, - options?: { signal?: AbortSignal } + resource: Promise | { read: () => T } ): T; + +// Hook to access the current boundary's signal +// Returns the nearest Suspense boundary's signal +// Throws in development, returns inert signal in production if called outside boundary +declare function useSuspenseSignal(): AbortSignal; +``` + +React automatically creates and manages an `AbortSignal` for each Suspense boundary. When a Suspense boundary unmounts or becomes hidden (offscreen), React aborts its signal. The `useSuspenseSignal()` hook allows components to access the nearest ancestor Suspense boundary's signal. If called outside a Suspense boundary, the hook throws in development and returns a permanently-aborted inert signal in production. This mirrors other dev-only safety checks while keeping production code robust. When Suspense boundaries are nested, the hook returns the signal from the closest boundary. Promises passed to `use()` should be created with this signal to enable cancellation: + +```ts +function MyComponent() { + const signal = useSuspenseSignal(); + const data = use(fetch('/api/data', { signal }).then(r => r.json())); + return
{data.name}
; +} ``` -Behavior +For promises created outside the component (e.g., in a cache or loader), the promise factory pattern enables signal threading: + +```ts +// Cache or loader layer +function createDataLoader(id: string, signal: AbortSignal) { + return fetch(`/api/data/${id}`, { signal }).then(r => r.json()); +} + +// Component +function MyComponent({ id }) { + const signal = useSuspenseSignal(); + const data = use(createDataLoader(id, signal)); + return
{data.name}
; +} +``` -- When a Suspense boundary unmounts or becomes hidden such that its work is not going to be displayed, React aborts the associated signal. -- Fetches or data loaders that participate in `use` should pass the signal to underlying I/O so that in-flight work can be cancelled by the platform. +The lifecycle proceeds as follows: component renders within a Suspense boundary, `useSuspenseSignal()` returns the boundary's active signal, promise is created with that signal, and if the boundary unmounts or becomes offscreen the signal aborts. Fetch and other platform APIs automatically cancel ongoing work. ### 3) Server actions ```ts -// Server -export async function action( +// Server action with optional context parameter +export async function myAction( formData: FormData, - ctx: { signal: AbortSignal } + context?: { signal?: AbortSignal } ) { - // Pass ctx.signal to fetch and other cancellable work + // Pass context.signal to fetch and other cancellable work + const signal = context?.signal; + if (signal) { + const res = await fetch('https://api.example.com/data', { signal }); + return res.json(); + } } ``` -Behavior +When a server action is invoked from within a transition or form submission, React automatically propagates the associated abort semantics to the server runtime, which creates a corresponding `AbortSignal` for the action's execution. The `AbortSignal` object itself is not serialized across the network; the server runtime reflects client aborts into a server-side signal that behaves equivalently. The signal is passed as an optional second parameter via a context object. If the client navigates away, supersedes the transition, or cancels explicitly, the signal is aborted. The server receives notification of the abort through its signal, which propagates to `fetch` and other cancellable I/O. -- The client side transition or navigation creates a controller whose `signal` is propagated to server actions. -- If the client navigates away, supersedes the transition, or cancels explicitly, the server receives an aborted signal that propagates to `fetch` and other cancellable I/O. +For streaming RSC (React Server Components) connections, the abort signal is communicated via the existing bidirectional channel. For traditional HTTP POST actions, the client closes the connection or sends an abort message if supported. The server action runtime provides the context parameter with the signal state. + +The context parameter is optional and defaults to `undefined`. Existing server actions that don't accept a second parameter continue to work unchanged. Server actions can check for `context?.signal` to opt into cancellation support. ### 4) DevTools and tracing -- DevTools can show cancelled work items with a simple trace: what was cancelled, by whom (for example, superseding transition or navigation), and when. -- This is diagnostic only. No behavior depends on DevTools being present. +DevTools Profiler can display cancelled work items with metadata showing which transition, Suspense boundary, or server action was cancelled, the cancellation reason from `signal.reason` (such as "superseded", "navigation", or "unmount"), when cancellation occurred, and may surface counts of cancelled requests and skipped renders where instrumentation is available. Values are approximate and diagnostic only. Cancelled transitions appear with a distinctive marker in the timeline. A "Cancellation" tab shows all aborted signals during the profiling session. + +Example timeline display: + +``` +Timeline: + ├─ Transition #1 [CANCELLED] (superseded by Transition #2) + │ ├─ Fetch: /api/search?q=abc [CANCELLED] + │ └─ Render: [STOPPED] + └─ Transition #2 [COMPLETED] + └─ Fetch: /api/search?q=abcd [SUCCESS] +``` + +This is diagnostic only. No application behavior depends on DevTools being present. ### Scheduling and guarantees -- Cancellation is best-effort. If an update is already committed, React will not roll it back due to an abort. -- React continues to coalesce updates and preserve scheduling semantics. The `signal` allows userland and platform I/O to stop ongoing work. +Cancellation is advisory and best-effort. React observes signals but maintains control over commit decisions. If an update is already committed to the DOM, React will not roll it back due to an abort. React continues to coalesce updates and preserve scheduling semantics. The `signal` allows userland code and platform I/O (fetch, streams) to stop ongoing work. + +When React aborts a signal it sets `signal.reason` to a React-defined value (e.g., `"superseded"`, `"navigation"`, or `"unmount"`). Applications may branch on `signal.reason` for diagnostics or logging; behavior must not depend on undocumented reason strings as these may change. + +Ordering: React first marks the transition/boundary signal as aborted (microtask), then prevents further work tied to that signal from being scheduled. Work already committed is not rolled back; pending effects created by the aborted render do not run. + +If `cancel()` is called while React is committing, the commit completes normally. Subsequent renders in the same transition will observe the aborted state. Async work checking `signal.aborted` will stop at their next checkpoint. + +When a transition runs within a Suspense boundary, both signals coexist. If either signal aborts, the work should stop. Userland code can use `AbortSignal.any([signal1, signal2])` (proposed platform API) or similar patterns to compose signals. ## Examples -#### Search box with superseding transitions +### Example 1: Search box with superseding transitions ```tsx function SearchBox() { const [query, setQuery] = useState(""); - - const { signal, cancel } = startTransition(async () => { - const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`, { - signal, + const [results, setResults] = useState([]); + const handleRef = useRef(null); + + useEffect(() => { + if (!query) { + setResults([]); + return; + } + + // Cancel previous search if still running + handleRef.current?.cancel(); + + // Start new transition with automatic cancellation + // Note: cancellation replaces typical debouncing; + // you can still debounce setQuery if desired. + handleRef.current = startTransition((signal) => { + fetch(`/api/search?q=${encodeURIComponent(query)}`, { signal }) + .then(res => res.json()) + .then(data => { + // Only update if not cancelled + if (!signal.aborted) { + setResults(data.results); + } + }) + .catch(err => { + // AbortError is expected on cancellation + if (err?.name !== 'AbortError') { + console.error('Search failed:', err); + } + }); }); - const data = await res.json(); - // set state with results + + // Cleanup: cancel on unmount or query change + return () => handleRef.current?.cancel(); + }, [query]); + + return ( +
+ setQuery(e.target.value)} + placeholder="Search..." + /> +
    + {results.map(r =>
  • {r.title}
  • )} +
+
+ ); +} +``` + +### Example 2: Suspense + `use` with cancellation + +```tsx +function UserProfile({ userId }: { userId: string }) { + return ( + }> + + + ); +} + +function UserDetails({ userId }: { userId: string }) { + // Access the Suspense boundary's signal + const signal = useSuspenseSignal(); + + // Create promise with signal - will auto-cancel if boundary unmounts + const userPromise = fetch(`/api/user/${userId}`, { signal }) + .then(r => r.json()); + + const user = use(userPromise); + + return ( +
+

{user.name}

+

{user.email}

+
+ ); +} +``` + +### Example 3: Server action with cancellation + +```tsx +// app/actions.ts (server) +'use server'; + +export async function savePost( + formData: FormData, + context?: { signal?: AbortSignal } +) { + const signal = context?.signal; + const title = formData.get('title') as string; + const content = formData.get('content') as string; + + // Pass signal to external API calls + const res = await fetch('https://api.example.com/posts', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title, content }), + signal, // Automatically cancels if client aborts }); - useEffect(() => () => cancel(), [cancel]); // cancel on unmount + if (!res.ok) throw new Error('Failed to save'); + return res.json(); +} + +// app/components/PostForm.tsx (client) +'use client'; - return setQuery(e.target.value)} />; +import { savePost } from '../actions'; + +function PostForm() { + const [isPending, startTransition] = useTransition(); + + const handleSubmit = (formData: FormData) => { + startTransition(async (signal) => { + try { + const result = await savePost(formData, { signal }); + console.log('Saved:', result); + } catch (err) { + if (err?.name !== 'AbortError') { + console.error('Save failed:', err); + } + } + }); + }; + + return ( +
+ +