760 Commits
Author SHA1 Message Date
Andrew Clark 71f7255937 [Flight] Server References for arbitrary object types (#37636)
Added behind a new experimental flag, `enableFlightObjectReferences`.

Extends Server References so that a reference can point to an object,
not just a function. An object is registered with a new API,
`registerServerObjectReference`, which tags it with its module id the
same way `registerServerReference` tags a Server Function.

The client receives an opaque handle: reading a property or calling it
throws, similar to how Temporary References work inside Server
Functions. The only thing the client can do with the handle is pass it
back to the server via a Server Function, where it resolves through the
server manifest.

The motivating use case is letting a framework pass request-scoped
values by reference, so it can omit them from the request body. For
example, a framework could model `searchParams` as a module export whose
value resolves from the current request. This avoids encoding the search
params twice (they already appear in the request URL) but it also makes
the responses more cacheable: if the search params don't appear
elsewhere in the body, then a response cache can omit them from its
cache key.

This initial PR only exposes the new API in the Turbopack bindings. We
will port it to the other bundler configs if the experiment advances.
2026-09-17 12:27:10 -04:00
Leo Camus ccea5fd23e [Flight & DevTools] Strip the whole "async " prefix from V8 stack frame names (#37608)
## Summary

V8 prefixes async call sites with `async ` when it prints a stack, like
this:

```
Error: boom
    at inner (/tmp/asy.js:1:44)
    at async outerName (/tmp/asy.js:2:30)
```

`parseStackTraceFromChromeStack` captures `async outerName` as the frame
name and strips the prefix here:

```js
} else if (name.startsWith('async ')) {
  name = name.slice(5);
  isAsync = true;
}
```

`'async '` is six characters, so `slice(5)` leaves the space behind and
the frame name comes back as `' outerName'` rather than `'outerName'`.

I noticed it while reading the parser, and it is not purely cosmetic:
that name is the first element of the `ReactFunctionLocation` returned
by `extractLocationFromComponentStack` and
`extractLocationFromOwnerStack`, which `backend/fiber/renderer.js`
stores as `instance.source`. Any async component whose frame reaches
that path is recorded under a name with a stray leading space.

The same line exists in
`packages/react-server/src/ReactFlightStackConfigV8.js`, which the
DevTools file is a copy of. After review I fixed it in this PR as well,
in a second commit. There it only matters on the fallback path that
parses an already formatted stack string, when the error's `stack` was
read or assigned before React reaches it. #37130 is open on that file
too, but it does not touch these lines.

## How did you test this change?

I first confirmed the format V8 actually emits, rather than assuming it:

```
$ node -e 'async function inner(){await null;throw new Error("boom")}
async function outerName(){await inner()}
outerName().catch(e=>console.log(e.stack))'
Error: boom
    at inner ([eval]:1:52)
    at async outerName ([eval]:2:33)
```

Then I added a case to the existing `extractLocationFromComponentStack`
block in `utils-test.js`. Against `main` it fails with exactly the
leading space:

```
  ● utils › extractLocationFromComponentStack › should strip the async prefix from a frame name

    - Expected  - 1
    + Received  + 1

      Array [
    -   "Comments",
    +   " Comments",
        "https://react.dev/_next/static/chunks/848-122f91e9565d9ffa.js",
        5,
        9236,
      ]
```

With the one-character fix applied:

```
$ yarn test --build --project=devtools -r=experimental utils-test
PASS packages/react-devtools-shared/src/__tests__/utils-test.js
Tests:       63 passed, 63 total
```

I also ran the whole DevTools project before and after to check I was
not moving anything else. Both runs end at `9 failed, 4 failed suites`,
the same test names each time (`componentStacks`, `console`,
`inspectedElement`, `legacy/inspectElement`), so those failures are
pre-existing on `main` in my environment and unrelated to this change.
The only difference between the two runs is my new test: 586 passed
before, 587 after.

For the Flight side I added `ReactFlightStackConfigV8-test.js`, which
assigns a formatted stack to an error and checks what `parseStackTrace`
returns. Against `main` it fails with the same leading space (`"
outerName"`); with the fix it passes on stable and experimental in
development. It is gated to `__DEV__` because that fallback goes through
the DEV-only stack cache. `ReactFlightServer-test` and
`ReactFlightAsyncDebugInfo-test` still pass next to it (23 tests).

`prettier` and `eslint` are clean on all changed files. `yarn flow
dom-node` reported no errors for the DevTools commit; I did not rerun
Flow after the one-character Flight change.

AI tools used
2026-09-13 11:30:57 +01:00
Hendrik Liebauandsundeep8967 8f0043721e [Flight] Fix RangeError from exponential debug info growth (#37481)
The Flight Client copies the debug info of a referenced chunk into the
chunk that references it, so that the receiving chunk records what
blocked it. It copies the entries once per reference, and a referenced
chunk already carries the entries that it received itself. A response
that deduplicates the same object across a chain of rows therefore
multiplies the entries at every step. In development the array
eventually grows past what the engine can allocate for it, and the
client throws `RangeError: Invalid array length`.

The receiving chunk now takes each entry only once. The entries are
copied by reference and never cloned on this path, so a comparison by
identity is exact. The array becomes bounded by the number of distinct
entries in the response rather than by a chosen limit.

The bookkeeping costs one `Set` per chunk that receives debug info, in
development only. The set holds a reference to each entry rather than a
copy, so the entries stay shared and nothing about the debug info is
duplicated. A chunk receives entries only while it is blocked, so the
fix releases the set as soon as the chunk initializes. Debug info still
accumulates transitively, which this change does not alter.

This change also adds the `!reference.isDebug` guards that #37358
proposes for the element props branch and the default branch of
`fulfillReference`. #35795 introduced the rule that a reference resolved
during debug info resolution does not transfer, and it left those two
branches behind. The guards make that rule hold at every branch.
However, those branches reference debug chunks that carry no entries, so
the guards change no observed behaviour, and they do not fix the growth
in #37343, which comes from references in model chunks. #37343 also
reports the call in `getOutlinedModel` as unguarded, which it is not,
because #35795 already skips it there.

The rest of #37358 deduplicates the entries, which is the right
direction, but it scans the receiving array for every candidate, which
is quadratic in the size of the debug info. #37359 caps the array at a
constant instead, which stops the crash but keeps copying the duplicates
and drops debug info once a response passes the cap.

**Alternatives Considered**

- Tracking the referenced chunks rather than the entries would be
cheaper, because it would need one map entry per referenced chunk. It
would not be enough, because a chunk can reach the same entry through
two paths. A chunk can hold a client reference directly and also
reference a chunk that already received the debug info of that client
reference.
- Recording the last receiving chunk on each entry would be exact while
a chunk parses its model, where the transfers into it are consecutive.
It would break once transfers into different chunks interleave, and that
is the path the reported crash takes.
- Turning `_debugInfo` itself into a `Set` is not possible, because the
reconciler, Fizz, the Flight Server and DevTools read it by index and
depend on its order.

Fixes #37343
Closes #37358
Closes #37359

Co-authored-by: sundeep8967 <71071718+sundeep8967@users.noreply.github.com>
2026-09-02 15:57:40 +02:00
Sebastian "Sebbie" SilbermannandClaude Code 3c397fe760 [test] Stop leaking mocked performance clocks into later test files (#37379)
Test files running in the Node.js Jest environment share the worker
process's `performance` object, because [`jest-environment-node`
installs it by
reference](https://github.com/jestjs/jest/blob/v29.7.0/packages/jest-environment-node/src/index.ts#L85-L103)
rather than by copy. When a test mocked the clock with
`Object.defineProperty(performance, 'now', ...)`, the mutation hit the
shared object and was never undone, since Jest only restores
`jest.spyOn` mocks when a file's runtime is torn down ([`jest-runtime`'s
`teardown()` calls
`restoreAllMocks()`](https://github.com/jestjs/jest/blob/v29.7.0/packages/jest-runtime/src/index.ts#L1358-L1359)).
Every subsequent test file in the same worker then observed the fake
clock, including jsdom-based files, whose [`performance.now()` subtracts
a window-creation timestamp from the shared object's
`now()`](https://github.com/jsdom/jsdom/blob/v22.1.0/lib/jsdom/living/hr-time/Performance-impl.js#L13-L14).

This change switches the six affected test files to
`jest.spyOn(performance, 'now')` and `jest.spyOn(performance,
'timeOrigin', 'get')`, which Jest restores automatically at teardown.
`ReactFlightDOMEdge-test.js` runs in jsdom and therefore did not leak,
but it used the same pattern and is converted for consistency.

This change is mostly for test hygiene. Was discovered while
investigating a flaky `{"time":NaN}` serialisation bug (e.g.
https://github.com/react/react/actions/runs/32878999825/job/97903912119)

Co-authored-by: Claude Code (kimi-k3[1m]) <noreply@anthropic.com>
2026-08-26 12:49:48 +02:00
Hendrik Liebau 3d050805e8 [Fizz] Construct the render lifetime controller only when it is needed (#37357)
This follows #37315, which added the render lifetime controller to bound
the abort listener that `attachAbortSignal` attaches to a caller's
signal. `RequestInstance` constructed one for every request, so a render
that is given no signal allocated a controller, aborted it on
completion, and nothing ever observed either.

The controller is now created in `attachAbortSignal`, and the three
places that end the lifetime go through `endRenderLifetime`, which does
nothing when there is no controller. Callers that pass a signal are
unaffected. Callers that do not no longer allocate one, and `signal` is
optional in every browser, edge and static entry point, while
`renderToPipeableStream` and `resumeToPipeableStream` accept no signal
at all.

They also no longer reach `AbortController` at all, which matters more
than the allocation. Fizz had no runtime dependency on it before #37315,
and an unconditional one reaches environments that provide the API
through a polyfill. An incomplete polyfill can then fail a render that
never asked for abort support.

The new test asserts that no controller is constructed when no signal is
passed. It fails with the eager construction restored, since nothing
else in the suite would notice a regression to it.
2026-08-24 19:46:15 +02:00
danandClaude Opus 5 dc631ef588 [Flight] Outline and dedupe repeated strings (#37147)
Flight has two ways to write a string:

1. Small ones are inlined into the JSON model.
2. Large ones (>= 1024 chars) are outlined into a binary text row so
they don't get double-encoded and double-parsed.

Neither is ever deduplicated. That's most visible in client reference
metadata, where a route repeats the same bundler chunk URLs across every
client reference (for example,
https://github.com/vercel/next.js/issues/95559).

**This PR adds a dedupe map for strings inside import metadata.** A
string is written once into its own row, and every occurrence is a
reference to it.

## How it works

When we're about to write a string in import metadata at least as long
as the threshold, we look it up in the request's map:

- **If it's not in the map:** Emit a row containing the string, store
that row's reference in the map, and write the reference here.
- **If it is:** Write the reference.

So every string goes on the wire once, and every occurrence costs a few
bytes. An earlier version waited for the second occurrence before
outlining, which is the right default for arbitrary strings where most
never repeat (it's what #27537 does for objects). Import metadata is the
opposite case: a chunk is listed by every client module that lives in
it, so a chunk name that appears once is the exception. In the bench
app's three routes, every chunk string at least 16 characters long
appears more than 20 times and none appears once. Outlining on first
sight saves the inline copy, and a string that never repeats costs 7
bytes more than inlining it.

Import metadata needs its own map and its own queue. The client resolves
a client reference as soon as it parses the import row, and import
chunks flush ahead of model rows, so the string row has to be in the
same queue to arrive first.

The client needs no protocol changes. It already resolves `$N`
references, and a row holding a string resolves to that string. It does
get a check that import metadata never blocks on a row that hasn't
arrived, which is the other end of the queue ordering above, and
`getOutlinedModel` stops allocating a path array for references without
one.

Model strings are left alone. An earlier version of this PR deduped them
too, but we're not going to do this now per review (maybe in a
follow-up). Metadata on the debug channel is also left alone: it's a
separate serialization path, and deduping across the two would make the
main payload depend on whether a debug channel is attached.

## Threshold

The trigger is 16, low compared to what a model-side threshold would
want, because import metadata is repetitive but its parts are short. How
much this saves depends on how many client references share a chunk
list, so measuring one string on its own is misleading. For a chunk path
of realistic length today:

| references sharing the chunk | before | after |
|---|---|---|
| 1 | 80 B | 87 B |
| 2 | 160 B | 122 B |
| 3 | 240 B | 157 B |
| 5 | 401 B | 228 B |
| 10 | 813 B | 416 B |
| 40 | 3273 B | 1526 B |
| 80 | 6594 B | 3047 B |

(Import and string rows only.) It costs 7 bytes at 1 reference and wins
from 2. Chunk paths in this app are 47 characters, so a threshold of 48
or higher saves nothing at all here. That's why it's 16: picking a
number just under one bundler's path length gives you something that
quietly stops working on the next bundler.

The map is bounded by the combined length of the strings it holds, 32
KiB. Once the budget is spent, new strings are written inline every time
while strings already outlined keep deduping. That makes the savings
depend on the order strings are first seen: a shared chunk URL first
encountered after 32 KiB of unique module ids won't be deduped. That's
main's behavior, so it's a missed win rather than a regression, but a
manifest-heavy dev route could hit it.

## Byte measurements

Three routes of a Next.js app, serial requests:

| route | Flight | document | document (gzip) |
|---|---|---|---|
| `/dashboard` | −48.4% (710.1 → 366.5 KB) | −34.3% | −7.8% |
| `/docs` | −5.8% (555.2 → 523.1 KB) | −5.0% | −0.7% |
| `/blog` | −5.4% (878.8 → 831.7 KB) | −4.4% | −1.7% |

The difference between the routes is how many client references each one
has. On `/dashboard` the import rows shrink from 388.0 KB to about 32 KB
with the row *count* unchanged at 114, because every client reference
repeats the same 49 chunk URLs.

gzip already collapses repeated strings, so −48.4% raw is only −7.8%
compressed. The bytes still have to be escaped, encoded and copied
before they reach the compressor, which is where most of the speedup
below comes from.

## Speed measurements

Benchmarked end-to-end through a Next.js app on Vercel Sandbox VMs (x86
Xeon), 16 boots, paired ABBA within each boot, boot as the unit of
replication. Base is the merge-base with main, `eafeac09`; candidate is
the current head, `e0b4614c`.

| cell | effect | 95% CI | p |
|---|---|---|---|
| `/dashboard` serial req/s | **+16.9%** | ±1.7 | <0.0001 |
| `/dashboard` serial p95 latency | −18.2% | ±2.4 | <0.0001 |
| `/dashboard` serial TTFB | −23.2% | ±1.2 | <0.0001 |
| `/dashboard` under load req/s | **+17.0%** | ±3.4 | <0.0001 |
| `/dashboard` under load median latency | −13.9% | ±2.5 | <0.0001 |
| `/docs` serial req/s | +3.0% | ±1.4 | 0.0003 |
| `/docs` serial TTFB | −3.1% | ±1.0 | <0.0001 |
| `/blog` serial req/s | +2.4% | ±1.0 | 0.0001 |
| `/blog` serial median latency | −2.4% | ±0.7 | <0.0001 |

No detected difference: `/blog` and `/docs` under load (p=0.13–0.56).
All 16 boots are positive on both `/dashboard` cells. The `/dashboard`
headline has now been measured in four separate 16-boot runs across four
heads of this branch and is p<0.0001 in each; the small routes cleared
p<0.01 only on this head, after the serializer change below, having sat
at p=0.02–0.06 on the three earlier heads.

The previous head, `4569e1d6`, which outlined on the second occurrence
rather than the first, measured +14.9% ±2.3 on `/dashboard` serial req/s
and −17.3% ±5.7 on TTFB against the same base. Those intervals overlap
the ones above, so the switch is not a measurable speedup on its own;
the bytes it saves are about 1% of the payload.

In a real browser on `/dashboard` (measured on an earlier commit of this
branch, `aed4d523`), hydration is −2.8% ±1.3 (p=0.0003) / −2.4% ±0.9
(p=0.0001) and LCP is −5.3% ±2.0 (p<0.0001) / −3.6% ±2.6 (p=0.009).
Client navigation is under the noise floor in both.

### Where the time goes

32 CPU profiles, taken after the timed runs with an identical request
count in both arms, so absolute sampled milliseconds are comparable. One
pass per boot, no replication statistics — directional, not a claim.
These profiles are from `aed4d523`. The current head also walks the
metadata into a copy before a plain `stringify`, after a detour through
a `stringify` replacer that measured 2.3× slower in isolation (a
replacer function takes V8 off its fast path for the whole call); the
`transformImportMetadata` frame below is a fair proxy for the current
cost.

Cheaper:

| base | candidate | frame |
| ---: | ---: | --- |
| 23.6 s | 5.7 s | ReactDOM `preinitScript` |
| 24.9 s | 10.6 s | `serializeClientReference` |
| 81.8 s | 67.3 s | `utf8Write` |
| 93.9 s | 80.8 s | `createFromString` |
| 50.7 s | 39.2 s | Next's `htmlEscapeJsonString` |

More expensive:

| base | candidate | frame |
| ---: | ---: | --- |
| 0 | 9.3 s | `transformImportMetadata` |
| 2.0 s | 10.3 s | `getOutlinedModel` (SSR-side Flight client) |
| 7.7 s | 11.7 s | `parseModelString` |
| 154.8 s | 158.2 s | `resolveModelToJSON` |

About +31 s of new work against −79 s inside the runtime bundle and −45
s in node's buffer and string layer.

`getOutlinedModel` resolving references is the mechanism working, not a
warning sign. Next.js runs a Flight client on the server to read its own
payload, and a `/dashboard` payload goes from 0 references inside import
rows to 4964, so a frame that barely ran before now runs once per
reference. Each call is a lookup on a row that has already been
initialized: the string row goes into the import queue ahead of the
import row that reads it, so it has always arrived and nothing blocks.
`parseModelString` grows for the same reason.

`preinitScript` doesn't get cheaper from writing fewer bytes. It does
two dictionary lookups keyed by the chunk URL per call, and the call
count and argument values are unchanged — the resolved models are
identical. What changes is string identity: in the base build every one
of the 5013 chunk-URL occurrences is a fresh string out of `JSON.parse`
whose hash has to be computed before the lookup, and with dedupe the 49
distinct URLs are parsed once and every reference yields the same
string, so V8's cached hash makes the repeat lookups nearly free. Some
of the `htmlEscapeJsonString` and buffer-layer drops have the same
cause.

### React-level CPU in isolation

The e2e numbers above include everything downstream of React (escaping,
encoding, compression, the SSR client). To see React's own serialization
cost, 114 import rows of dashboard-shaped metadata (49 shared
74-character chunk names per row) were rendered against one request on
the production bundles with a no-op destination, arms interleaved,
median of 5 rounds × 200:

| | main | this PR |
|---|---|---|
| 49 names shared by all rows | 0.502 ms | **0.322 ms** (−36%) |
| 5586 unique names, nothing to dedupe | 0.477 ms | 0.771 ms (+62%) |

The second row is the worst case for this change, a manifest where every
chunk name appears once. It costs about 40 ns per unique string, plus
about 130 ns for each row the budget lets it outline, against a payload
that is otherwise unchanged.

The metadata is serialized by copying it with the strings already
replaced and then calling plain `JSON.stringify`; a `stringify` replacer
function would keep V8 off its fast path for the whole call (measured
2.3× slower than plain in isolation, even writing a sixth of the bytes).
The copy covers plain JSON only and falls back to the replacer for
anything else (`toJSON`, class instances, keys that exist on
`Object.prototype`, depth over four, which is how cycles end up throwing
stringify's own error). Equivalence of the two paths was checked by a
harness that runs both on identical requests and compares the JSON and
the resulting request state: 2,656,142 cases, including exhaustive
enumeration of small trees over adversarial atoms, 100k seeded random
values, and the cases from two independent adversarial reviews — 0
divergences outside four stated assumptions that no bundler manifest
violates (no Proxies, no index accessors polluted onto
`Array.prototype`, no primitive wrappers with a swapped prototype,
side-effect-free property access).

## Cost where there's nothing to dedupe

React's own `flight-ssr-bench` fixture has about ten client modules and
no repeated chunk paths, so the dedupe never fires and the change can
only cost. It costs a little, if anything. Over 16 boots at `aed4d523`
the Flight+Fizz Node sync variant was +0.9% ±0.7 on median inject time
(p=0.008), worse on 14 of 16 boots. On the current head the four
Flight+Fizz inject cells are between +0.4% and +0.8% on the median, none
below p=0.07; across all 88 fixture metrics (Fizz and Flight+Fizz, Node
and Edge, sync and async, inject and HTTP at c=1/c=10) nothing reaches
p<0.01 and `heapMb` is flat to ±0.1%. So the no-dedupe cost is somewhere
around half a percent of inject time on this fixture, at the edge of
what it can resolve.

I couldn't localize it past that. It isn't the per-request `Map`, which
is about 22 ns against a 14 ms render, and it isn't allocation — `gcMs`
and `heapMb` are flat. Using the Fizz-only variants as a within-boot
control, since nothing in `ReactFlightServer.js` can reach them, the
Flight-specific residual on that cell is +0.8% ±0.5 and the other three
variants scatter around zero (+0.4%, +0.1%, −0.2%). A build that
re-inlines `escapeStringValue` back into the string branch, which is the
only change here that runs for every string in the model rather than
only for import metadata, doesn't recover it either (+0.2% ±0.3 on the
same cell, another 16 boots). So this looks like code layout rather than
a specific added operation, and it's near the resolution limit of the
fixture.

<details>
<summary>Verification</summary>

- Both arms' payloads for `/dashboard` were parsed and their
`$`-references resolved recursively, then deep-compared: the resolved
models are identical. The 49 extra model rows are exactly the 49
distinct chunk URLs. All 114 import rows match after resolution.
- Arms fingerprint distinctly (`a898f40a7bbd` vs `87fb4b7ba15e`), so the
two builds are genuinely different.
- Build fingerprints differ between arms (`04440a11435d` vs
`43d09027ce58`) and the arm version strings carry the expected shas.
- Per-boot deltas are printed by the harness; on `/dashboard` serial
req/s all 16 boots are positive (range +11.2% to +22.9%).
- The bench fixture sets a deployment id, so every chunk URL carries a
`?dpl=` query param that exactly doubles its length (74 chars vs 37). An
app without one would see roughly half the absolute byte saving on this
route. The CPU wins that come from string identity rather than byte
count should degrade less than proportionally, but that wasn't measured.
- Not measured: payloads that exceed the 32 KiB tracking budget, and
whether 16 is optimal rather than merely low enough.

</details>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 18:46:34 +01:00
Hendrik Liebau 055705ca01 [Flight] Abort the cache signal when debug objects are retained (#37342)
A debug channel with a readable side lets the client fetch debug objects
lazily. For example, React serializes each component's props into the
debug model and defers the part of an object tree that exceeds the
model's object limit. A deferred object stays retained, and its debug
chunk stays pending, until the client asks for it or the channel closes.
The render can therefore complete while such an object is still
outstanding.

When that happens, `flushCompletedChunks` closes the main stream, sets
the request status to `CLOSED`, and returns before it reaches the block
that releases the render's resources. Once the client closes the debug
channel and the retained objects drop, a later flush does reach that
block, but the cache controller is only aborted while the status is
below `ABORTING`, and `CLOSED` is above it. The signal is therefore
never aborted at all rather than merely released late, so anything that
waits on `cacheSignal()` to clean up resources waits for the lifetime of
the process.

This change moves the cache controller abort above the debug stream
bookkeeping. An empty pending chunk count already means the render is
complete, and debug chunks carry development-only instrumentation rather
than the render's output, so the cache signal can abort at that point no
matter what the debug stream is still doing. The path that writes debug
chunks on the main stream can now reach this code on several flushes,
which is safe because aborting an aborted controller does nothing a
second time.

The taint queue cleanup stays where it is. A tainted typed array,
`DataView` or blob is checked against the taint registry as its chunk is
written, and such a write can happen long after the render completes,
either because the client queried a deferred debug object or because a
blob's stream resolved late. Moving it up would let those writes through
unchecked, and it would fix nothing, because unlike the abort it never
sat behind the status guard.
2026-08-22 18:05:44 +02:00
Hendrik Liebau 77ed3f5452 [Flight/Fizz] Stop the caller's signal from retaining a finished render (#37315)
Every server entry point that accepts a `signal` attached an abort
listener to it and only ever removed that listener from inside the
listener itself. On the success path the signal never aborts, so the
listener stayed attached and its closure kept the whole `Request`, and
therefore the entire rendered output, reachable for as long as the
caller's signal lived.

This matters most for composite signals from `AbortSignal.any()` and for
timeout signals, because the runtime retains those for as long as they
carry a non-weak abort listener, and releases them only when the last
listener is removed or the signal aborts. A composite passed to
`prerender()` therefore became a garbage collection root holding a
finished render for the lifetime of the process. A plain
`AbortController` signal is never retained that way, but it still keeps
the render reachable for as long as the caller holds the controller.

Each listener is now bound to a lifetime signal passed to
`addEventListener`, so the runtime removes the listener as soon as that
signal aborts and nothing has to track a teardown function. Flight
reuses `request.cacheController`, which already aborts on a fatal error,
at the completion of the flush loop (depends on #37342), and in
`abort()`. Fizz has no equivalent, so it gains a
`renderLifetimeController` that aborts at those same three points.
`processReply` creates its controller only when a caller passes a
signal, so a reply without one allocates nothing. Since `abort()`
returns early once the request is past `OPEN`, removing the listener at
those points cannot change observable behavior. The fifty-two copies of
the listener block across the entry points collapse to a single
`attachAbortSignal` call each.

Binding the listener to the render also covers a cancelled stream, which
calls `abort()` without the request ever reaching a terminal status, so
a teardown driven by that status would have left the listener attached.
Fizz ends the lifetime in `fatalError` rather than at the `CLOSING` to
`CLOSED` transition, because a shell error rejects before the caller
receives a stream. Nothing then consumes the request, it never closes,
and a listener waiting for that transition would never come off.

The two new controllers are aborted with an explicit reason. A call to
`abort()` without one constructs an `AbortError` DOMException. Capturing
the stack trace dominates that cost, and the cost grows with the depth
of the stack, so every render and every reply would pay for an object
that no code reads.

`processReply` no longer returns its `abort` function, because that
return value existed only so each `encodeReply` implementation could
wire the signal up itself, and nothing uses it now that the wiring lives
inside. A reply whose model settles synchronously gets no listener,
since aborting it was already a no-op.

The tests assert on the lifetime signal, because the runtime's removal
does not go through `removeEventListener` and is therefore invisible to
a patched signal. `ReactFlightDOMNode-test` asserts the removal itself
with `getEventListeners` from `node:events`, which jsdom has no
equivalent for.

Two cases stay open. A request whose stream is neither consumed nor
cancelled never ends, and a reply with a part that never settles never
settles either, so both keep their listener.
2026-08-22 17:15:56 +02:00
Josh Story 807d21fdfd Add lazy reasons to browser() (#37241)
Changes `ReactDOM.browser()` to return a cheap branded recoverable token
instead of eagerly constructing an `Error`. It accepts an optional
reason string or initializer that runs only when a server renderer
consumes the token and may return any value; the client renderer ignores
the reason without invoking the initializer, so browser-only rendering
does not pay for an unused stack.

When Fizz consumes the token through `use()` or `abort()`, it creates a
consistent browser-bailout error at the consumption point so its stack
identifies the relevant operation. The initialized reason is preserved
unchanged as the optional `cause`, allowing strings, errors, and
structured framework metadata without runtime validation. If an
initializer throws, Fizz substitutes a stable diagnostic fallback so
reason generation cannot change rendering control flow. Successful
recoveries report the error through `onBrowserBailout`.

When no Suspense boundary can recover the render, Fizz clones the
branded recoverable error into an unbranded fatal diagnostic while
preserving its cause and consumption frames. During an abort, the
request retains the original branded error so every remaining task
observes the same reason; fatal clones are created only when reporting a
fatal root or closing the stream. Centralized recoverable logging uses
the brand to route successful bailouts through `onBrowserBailout` and
fatal clones through `onError`. The empty recoverable digest and client
hydration suppression behavior remain unchanged.

Tests cover omitted and direct reasons, lazy string, error, structured,
and primitive reasons, repeated use sites, throwing initializers, lazy
client behavior, consumption stacks, flattened fatal errors, recoverable
and fatal use and abort paths, nested aborts, direct throws, debug
tools, and development and production rendering.
2026-08-10 08:42:47 -07:00
Josh Story 2042572329 Add onBrowserBailout Fizz option (#37193)
Adds a new Fizz option, `onBrowserBailout`, for observing intentional
server-render bailouts caused by `ReactDOM.browser()` and future APIs
that use the same recoverable error mechanism. The callback receives the
original recoverable error and `ErrorInfo`, defaults to a noop, and runs
only when Fizz successfully recovers by deferring work to the browser.

Recoverables consumed within Suspense or used to abort recoverable
boundaries are reported through `onBrowserBailout` without also invoking
`onError`. A bailout outside Suspense remains fatal and reports only
through `onError`, with the original recoverable preserved as its cause,
while directly throwing the value continues to behave like a normal
render error.

Plumbs the option through the streaming, resume, and prerender entry
points for Node, browser, Edge, Bun, FB, markup, and noop renderers
while preserving the positional Fizz request API for callers that do not
expose the option.

Uses an environment-neutral browser-only rendering message for the
isomorphic `browser()` value and updates the production error mapping.
Tests cover successful browser bailouts, recoverable abort reasons,
root-fatal behavior, component stack information, the default noop
behavior, and direct throws in development and production.
2026-08-07 19:31:46 -07:00
Andrew Clark 9b5b4d51e5 [Flight] Add 'pending_weak' to Flight thenable protocol (#37154)
Added behind a new experimental flag, `enableFlightWeakThenables`.

Adds a new thenable status to the Flight protocol: `'pending_weak'`.
Unlike a regular pending thenable, a weak thenable does not block the
stream from closing. If it settles while the stream is still open, its
value is emitted like a normal pending thenable. Otherwise its reference
is left unfulfilled and on the client it stays forever pending, without
erroring, even when the connection closes. It's up to the client to
handle the unresolved promise in an appropriate way.

The motivating use case is being able to encode metadata about a Flight
stream into the response itself. For example, a framework might want to
track whether a page varies by search params. It could represent this in
the response as a `Promise<boolean>` that resolves to `true` as soon as
the component being rendered in the stream accesses search params. If
the thenable never resolves by the time the stream closes, then the
client knows that no search params were ever accessed.

In the future we could add a higher-level API for encoding this kind of
information. For now, we intentionally start with the low-level
primitive so frameworks can experiment in userspace without adding
significantly to React's surface area.

Internally, Flight already uses its own private thenable statuses, like
`'resolved_model'`, and the protocol is designed to treat any status
besides `'fulfilled'` and `'rejected'` as equivalent to `'pending'`, so
`'pending_weak'` slots into the existing machinery. On the wire, a weak
reference is encoded as `$w<id>`, next to `$@<id>` for regular promises,
so the client knows its row may intentionally never arrive. On the
client, a weak reference behaves like any other pending promise until
the response closes; then, instead of erroring, it is left forever
pending.
2026-07-31 13:22:30 -04:00
Josh Story 15f7cd693e Add ReactDOM browser() API (#37143)
## Summary

Adds a new API to `react-dom` called `browser()`.

`browser()` returns a "usable" that will error during SSR and resolve
during rendering in the browser. The purpose is to allow you to express
the idea that a component should suspend on the server but not in the
browser. The method is not available inside a `react-server`
environment. This is a client only feature.

This is a `react-dom` API because the concept of browser doesn't apply
generally to React itself.

This codifies a pattern that is common in some apps where you error
during SSR to prevent rendering some component on the server and you end
up suppressing the error that is reported in the client to avoid this
appearing like a problem rather than intended behavior. Unfortunately
this is not an option for many because hacking around to prevent errors
from being logged is not practical for many

By making this a React API we enabled this common pattern in any React
using library or application

```tsx
import {use, Suspense} from 'react';
import {browser} from 'react-dom';

function BrowserOnly() {
  use(browser());
  return <ClientContent />;
}

function App() {
  return (
    <Suspense fallback={<Fallback />}>
      <BrowserOnly />
    </Suspense>
  );
}
```

It is an error to `use(browser())` outside of a Suspense boundary
because you cannot recover from the root. this restriction may be lifted
in the future but is part of the current limitations of the API

## Implementation

Deferring rendering to a downstream system is modeled in React already
as recoverable errors. The idea is that in some environments you might
not want to report something directly as an error because a later
environment has an opportunity to recover from it without alerting the
user to the mishap. This concept also shows up in RSC with halted
references. They can "recover" in a later render by eventually resolving
to some value.

To model the idea of "render in the browser" we are really just modeling
an intentional recoverable error. However since you don't want to treat
this kind of error as exceptional we intentionally suppress logging.
Additionally since aborting a server render is semantically equivalent
to "erroring" in every unfinished task we also support aborting with a
`browser()` so you can describe ending a stream with intentional holes
that won't be logged as errors in the browser when hydrating.

One interesting thing we do with this particular API is it returns an
object that is isomorphic and it's the `use` or `abort` function that
handles differing behaviors. This means you can create these objects in
module scope and use them even in complex scenarios like server
rendering inside the browser while React is rendering.

This implementation is flagged so we can disable the feature quickly if
we decide to not ship this in a stable. It is going into React
unprefixed for now because the semantics are clear and the utility is
widely known.

## Alternatives

We considered `useBrowser()` or a similar hook however this means you
must call it unconditionally. There are use cases where props might
influence whether you want to allow something to render during SSR or
not. for instance you might have a data fetching library that accepts
initial data on the server but if it doesn't receive initial data it
falls back to browser only rendering.

Another consideration is a throwing function like just calling
`browser()` would throw if called during an SSR render. The main reason
we do not think this is a good idea is because you can then call this
arbitrarily deep and the throw can be caught and might be suppressed
accidentally. By making it a usable it can only be done in hooks or
hook-like contexts.
2026-07-30 13:15:13 -07:00
Sebastian "Sebbie" SilbermannandClaude Fable 5 9ceb1e7d9e [Flight] Define Flight chunk .then with Object.defineProperty (#37109)
[Secure Ecmascript](https://github.com/tc39/proposal-ses) would freeze
the prototype of intrinsics. Since `ReactPromise` inherits the prototype
from `Promise`, it also copies over the writable definition.

Using `defineProperty` on an inherited property is compatible with SES
though. That's also closer to how classes are specced in JS.


---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 15:30:19 -04:00
Josh StoryandSebastian Sebbie Silbermann 81e442eaf3 [FlightReply] Performance improvements when decoding (#37090)
Security Patches included in 19.2.8

Co-authored-by: Sebastian Sebbie Silbermann <sebastian.silbermann@vercel.com>
2026-07-21 09:26:12 -07:00
Jack Pope 83840902c8 [Fizz] Support nested enter/exit ViewTransition animations (#36917)
Adds SSR support for nested parentEnter/parentExit View Transitions.
Fizz now emits vt-parent-enter/vt-parent-exit annotations during
streaming, and the client picks them up on hydration, so nested
enter/exit animations work for Suspense reveals.
2026-07-19 15:59:13 -04:00
Jack Pope 689a4fa441 [Fizz] Extend stack overflow recovery to retries (#36977)
Ran into this test failure as part of
https://github.com/react/react/pull/36917 - it seems that the added code
was just enough to increase stack size and fail the deep tree recovery
test in CI. Looking into that, there appears to be a gap here with
retries, including a TODO test case for the scenario.

Fizz recovers from stack overflows in extremely deep trees by catching
the first overflow in the `renderNode` trampoline and spawning a
continuation task. That continuation is retried via `retryRenderTask →
retryNode`, which has no trampoline above it. So if the remaining tree
still doesn't fit in one fresh stack, the overflow was treated as a
fatal error instead of recovering again.

This fix re-schedules the task when a retried render overflows but
`task.node` advanced (proving forward progress was made). If this is a
real in-component overflow, `task.node` doesn't advance and we still
fail.

The existing test used `n={1000}`, which only required one recovery
round and didn't catch this gap in source mode. It's updated to
`n={1200}`, which reliably requires multiple recovery rounds.
2026-07-19 13:13:40 -04:00
Sebastian "Sebbie" SilbermannandClaude Fable 5 71ecaf8990 [test] Add Flight regression test for async debug info surviving Promise GC (#37037)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 14:50:17 +02:00
danandClaude Fable 5 3039f5d3f6 [Flight] Recognize Node 22+ V8 frames for Promise statics in debug info (#36969)
## Summary

Newer V8 versions (Node.js ≥ ~22) name the stack frames of static
methods on the `Promise` constructor `Promise.all`, `Promise.race`,
etc., where older versions named them `Function.all`, `Function.race`,
etc. The `isPromiseCreationInternal` and `isPromiseAwaitInternal`
allowlists in `ReactFlightServer` only matched the old spellings.

## How did you test this change?

- `yarn test --no-watchman ReactFlightAsyncDebugInfo` — 18/18 pass on
both Node 20.19.0 and Node 24.16.0 (previously, "can track async
information when awaited" failed on Node 24)
- `yarn test --silent --no-watchman packages/react-server/src/__tests__
packages/react-server-dom-webpack` — 243/243 pass on both Node versions,
default and `-r=experimental` channels
- `yarn test-www --silent --no-watchman
packages/react-server/src/__tests__` with `__VARIANT__` true and false —
pass
- `yarn flow dom-node`, `yarn prettier`, `yarn linc` — pass

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 13:02:09 +01:00
Sebastian "Sebbie" SilbermannandClaude Opus 4.7 7ce677d406 [Fizz] Guard the shell-error callbacks instead of nulling them out on the request (#36916)
Follow-up to #36903

The previous change stopped `onAllReady` from firing after a fatal shell
error by assigning `noop` to `request.onAllReady` inside `fatalError`.
This mutated a completion callback on the request as a side channel,
mirroring the existing `request.onShellError = noop` in `completeShell`.
This refactor removes both noop assignments and instead guards the calls
at the point where they would happen, so the callbacks on the request
stay the ones the caller passed in.

For `onAllReady`, `erroredTask` now returns right after `fatalError` in
the root (`boundary === null`) case, so it no longer falls through to
the `allPendingTasks === 0` check that calls `completeAll`. This matches
the sibling `finishAbortedTask`, which already returns after a fatal
root error. Later task completions cannot reach `completeAll` either,
because `performWork` bails out once the request status is past `OPEN`.

For `onShellError`, `fatalError` now decides whether to call it based on
the same condition that governed the assignment: the shell is complete
exactly when `request.pendingRootTasks === 0`, and `completeShell` only
runs at that point, so guarding the call is equivalent to the previous
nulling out. `onFatalError` still always fires because the error is
always fatal to the request.

This is a behavior-preserving refactor of the fix landed in #36903; the
tests added there continue to pass unchanged.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-02 11:27:44 +02:00
Sebastian "Sebbie" SilbermannandClaude Opus 4.7 a1a6bc8974 [Fizz] Stop firing onAllReady after the shell errored (#36903)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-01 12:07:32 +02:00
Hendrik Liebau e2659d9d50 [Flight] Tailor the warning for binary data with a toJSON (Node Buffer) (#36873)
A Node.js `Buffer` carries a `toJSON` method, so Flight serializes it
through that method instead of as binary, and it is deserialized as a
plain `{type: 'Buffer', data: [...]}` object rather than a `Buffer` or
`Uint8Array`. The warning React emits in this case reads "Uint8Array
objects are not supported", which is misleading on two counts: the value
is actually a `Buffer`, reported as `Uint8Array` only because that is
its `Object.prototype.toString` tag, and a real `Uint8Array` is in fact
supported, since it has no `toJSON` and serializes as binary. This makes
the message especially confusing when binary data such as a font is
passed as a serialized value.

The first commit adds a test that pins down the current behavior,
capturing both the misleading warning and the deserialization to a plain
`{type: 'Buffer', data: [...]}` object. The second commit replaces the
warning, for values that are `ArrayBuffer.isView`, with one that names
the actual cause and the fix: the data is serialized through `toJSON`
instead of as binary, so a `Uint8Array` or `ArrayBuffer` should be
passed to send binary data. The new branch only fires for a typed array
that also carries a `toJSON`, which in practice is a Node `Buffer`; a
plain `Uint8Array` or `ArrayBuffer` has no `toJSON`, never reaches this
branch, and continues to serialize as binary.
2026-06-24 21:04:03 +02:00
Jude GaoandSebastian Sebbie Silbermann b1786c319e [Fizz] Finalize postponed nextSegmentId after the prelude flush (#36779)
`getPostponedState` snapshots `request.nextSegmentId` at `onAllReady`,
before the Fizz stream is flowing. At this point we have visited all
Suspense boundaries and know which ones suspended by user code or not.
However, only when the stream is flowing are we counting the size of
each boundary. When we detect large boundaries, we suspend them i.e. we
outline them instead of keeping them inline. This results in more
segments being written while the postponed state holds a stale count.

We now keep a reference to the returned postponed state and mutate the
segment IDs when we outline. That way serializing `postponed` after the
`prelude` has flushed writes the latest postponed state.

The current API design means that you can potentially serialize stale
postponed state. We're considering a redesign to make these issues
impossible (e.g. https://github.com/react/react/pull/36815).

For now, the postponed state should only be serialized or passed onto a
`resume` once the `prelude` has flushed e.g.:

```js
const { createWriteStream, writeFileSync } = require('node:fs');
const { createWriteStream } = require('node:stream');

const { prelude, postponed } = prenderToNodeStream(...)
// serializing `postponed` now would write a stale state.
// serialize prelude
const destination = createWriteStream('prelude.html')

prelude.pipe(destination)

await finished(prelude)
// now we can serialize postponed
writeFileSync('postponed.json', JSON.stringify(postponed))
```

---------

Co-authored-by: Sebastian Sebbie Silbermann <sebastian.silbermann@vercel.com>
2026-06-18 17:07:26 +02:00
Michael Hart ad78e251e2 [Flight] Resolve models before JSON.stringify (#36795)
Move the toJSON handling out of the JSON.stringify replacer path and
into an explicit recursive resolution step that uses v8's optimized
single-arg JSON.stringify call.

This has been pulled out of the original implementation in
https://github.com/react/react/pull/36053 on the advice of
https://github.com/react/react/pull/36181 (thanks @unstubbable)

### NB: \_\_proto\_\_

The only difference is surrounding the treatment of `{}` vs
`Object.create(null)`. https://github.com/react/react/pull/36053 uses
the latter, which avoids `__proto__` issues, but is slower in
microbenchmarks due to v8 semantics.
https://github.com/react/react/pull/36181 uses the former (`{}`) which
is faster in micro benchmarks but doesn't special-case `__proto__`
according to spec. This PR does both (`{}` and special case), with no
measurable performance difference that I could produce.

---

The rest of this PR's description is reproduced from
https://github.com/react/react/pull/36181:

---

### Problem

When serializing a Flight chunk, `emitChunk` currently calls
`JSON.stringify(value, task.toJSON)`. The `task.toJSON` replacer is
called for every key-value pair in the serialized JSON. While the logic
inside the replacer is lightweight, the C++ to JavaScript boundary
crossing on every node adds up — V8's `JSON.stringify` is implemented in
C++, and calling back into JavaScript for every property incurs overhead
that scales with the number of keys in the output.

### Change

Replace the replacer with a two-step process:

1. `resolveModel()` recursively walks the rendered value, calling
`renderModel()` on each child — doing the same transformation the
replacer used to do, but entirely in JavaScript without C++ boundary
crossings.
2. `JSON.stringify()` is called with no replacer, staying entirely in
C++.

The `resolveModel` walk also replicates `JSON.stringify`'s `toJSON`
semantics for `Date` objects.

### Results

Measured using the Flight SSR benchmark fixture (#36180) on a dashboard
app with ~25 components, 200 product rows (~325KB Flight payload).
Tested across Node 20, 22, and 24.

- **`bench:bare`** (in-process, no script injection): Flight+Fizz sync
median improves by **~4-5%** consistently across all three Node
versions.
- **`bench:server`** (HTTP, c=1): Flight+Fizz sync throughput improves
by **~3-6%** across Node versions. Async results vary between runs but
trend positive.

### Future opportunity

While the immediate performance improvement is moderate, this change
also sets up a potential future optimization: a Flight mode that renders
to an object instead of a stream ([#36143
(comment)](https://github.com/facebook/react/issues/36143#issuecomment-4155701790)).
Since `resolveModel()` already produces a plain JS object tree before
`JSON.stringify` is called, this intermediate representation could
potentially be passed to the SSR client without the
serialization-deserialization roundtrip that the current stream-based
approach requires.

closes #36181
2026-06-16 10:01:50 +02:00
Sebastian "Sebbie" Silbermann dbc37501ff Update required references to GitHub repo (#36752) 2026-06-12 09:50:15 +02:00
Sam Zhou 900ae094d8 [flow] Bump flow to v0.317.0 (#36701)
## Summary

Mostly changing the casting syntax from `(x: Y)` to `x as Y`, as the old
syntax was deprecated and always causing an error in Flow

## How did you test this change?

`yarn flow-ci`
2026-06-05 19:17:18 -04:00
Sam Zhou fbb137059e [flow] Bump flow to v0.307.1 (#36199)
## Summary

Notable changes:
- Only $FlowFixMe, $FlowExpectedError suppression comments are supported
- All suppression comments need error code
- A lot of new invalid-compare and constant-condition errors suppressed.
These errors reveal potential logical bugs

## How did you test this change?

`yarn flow-ci`
2026-06-05 13:54:38 -04:00
Josh Story 43bcbf8006 [Fizz] Allow Pending Work to Specialize Abort Reasons (#36586)
# Allow Pending Work to Specialize Abort Reasons

## Summary

Fizz currently reports every unfinished task using the same request-wide
abort reason. This makes it possible to observe that a render did not
finish, but not to understand why any individual suspended slot remained
incomplete.

This change allows suspended tasks to report a more specific rejection
reason when the wakeable they are blocked on rejects after abort begins
and before Fizz finalizes that task. Tasks that do not reject during
this window continue to report the general abort reason.

This is primarily motivated by partial prerendering, where aborting is
not merely an exceptional termination mechanism. It is the API used to
intentionally finish a prerender while leaving some work unresolved.

## Motivation

For an ordinary server render, a request abort generally means that the
result is no longer needed. Reporting the same abort reason for every
unfinished task is usually sufficient.

For a partial prerender, the meaning is different. The caller
intentionally aborts in order to produce a partial result. The
unfinished work is then useful information: it identifies which parts of
the tree prevented the prerender from completing.

Today, all of those tasks receive the same abort reason:

```text
slot A -> prerender aborted
slot B -> prerender aborted
slot C -> prerender aborted
```

That says which work was incomplete, but not whether different slots
should be interpreted differently.

For example, an application may have:

- A slow API that is permitted to miss the prerender deadline and should
not produce actionable logging.
- Other work that is expected to finish during prerendering and should
be reported when it does not.
- A data source that can provide additional telemetry about why it did
not finish once it learns that the prerender has been aborted.

With a single request-wide abort reason, `onError` cannot distinguish
these cases.

## Proposed Behavior

When abort begins, Fizz still associates a general abort reason with the
request. That reason remains the fallback for every unfinished task.

However, if a task is suspended on a wakeable and that wakeable rejects
during the interval between:

1. The request beginning to abort.
2. Fizz finalizing that task as aborted.

then Fizz reports the wakeable's rejection reason for that task instead
of the general abort reason.

Conceptually:

```text
slot A -> rejected during abort with TimeoutError("optional recommendations timed out")
slot B -> still pending when abort finishes -> Error("prerender deadline reached")
slot C -> rejected during abort with QueryError("inventory lookup canceled")
```

A rejection that arrives after its task has already been finalized is
ignored.

## Intended Usage

The canonical usage is for the caller to use the same `AbortSignal` both
to terminate the prerender and to notify data sources that may still be
blocking suspended work. A data source can reject with a more specific
error whose `cause` preserves its relationship to the overall abort.

```js
const controller = new AbortController();
const abortReason = new Error('prerender deadline reached');

const result = prerender(<App signal={controller.signal} />, {
  signal: controller.signal,
  onError(error) {
    if (error === abortReason) {
      // This task was unfinished but did not provide a more specific reason.
      return;
    }

    if (error instanceof Error && error.cause === abortReason) {
      // This task reported a specialized failure caused by the prerender abort.
      // For example, suppress an expected optional timeout or record telemetry.
      return;
    }

    // Interpret an ordinary rendering error.
  },
});

controller.abort(abortReason);
```

An interested data source can observe that signal and reject pending
work with an operation-specific reason that records the abort as its
cause:

```js
signal.addEventListener(
  'abort',
  () => {
    reject(
      new Error('optional recommendations timed out', {
        cause: signal.reason,
      }),
    );
  },
  {once: true},
);
```

If this rejection arrives before Fizz finishes aborting the suspended
task, `onError` receives the operation-specific error instead of the
general abort reason. Work that does not provide a specialized rejection
continues to report `abortReason` directly.

This allows applications to:

- Suppress logging for intentionally optional or deadline-limited work.
- Surface unfinished work that should be investigated.
- Include operation-specific telemetry or context in aborted-slot
reporting.
- Retain an explicit causal relationship between a specialized error and
the request-wide abort.

## Causality And Scope

Fizz does not attempt to prove that a wakeable rejected because of the
abort signal.

The precise behavior is temporal:

- If a suspended wakeable rejects after abort begins and before its task
is finalized, its rejection specializes that task's abort reason.
- If it does not reject during that interval, the task receives the
general abort reason.
- If it rejects after finalization, the rejection is ignored for Fizz
error reporting.

Using the same `AbortSignal` to notify data sources is the intended
protocol, but Fizz cannot distinguish a rejection caused by that signal
from any unrelated rejection that happens to occur during the abort
window.

Likewise, `signal.aborted` in `onError` lets callers distinguish errors
observed before abort initiation from errors observed after it began. It
does not independently prove causality for an arbitrary rejection.

## Implementation

Previously, a suspended task attached the same ping callback for both
fulfillment and rejection:

```js
wakeable.then(ping, ping);
```

That is correct during ordinary rendering because retrying the task
allows a rejected wakeable to throw through the normal render path,
preserving regular error handling and stack construction.

During abort, however, retrying general work is intentionally
suppressed. To preserve a rejection that arrives during the abort
window, the task now stores distinct fulfillment and rejection ping
callbacks:

```js
wakeable.then(ping.resolve, ping.reject);
```

Before abort begins, `ping.reject` retains existing behavior by
scheduling the task for retry.

After abort begins, `ping.reject` attempts to claim the still-pending
aborted task from its owning abort set. If successful, Fizz finalizes
that task immediately using the rejection reason. The later scheduled
abort finish processes only tasks that remain in their abort sets, using
the general abort reason.

This avoids adding another top-level property to `Task`, whose
production shape is already at the current field-count threshold, while
also covering suspension mechanisms such as `React.lazy` that cannot be
handled by inspecting `use()` thenable state.

## Tests

The tests cover:

- A rejected suspended task reporting a specialized reason while
unrelated pending work still reports the general abort reason.
- Specialization for `React.lazy`, ensuring this is not limited to
`use()` suspension.
- A rejection arriving after abort finalization being ignored.
- The prerender scheduling window in both static Browser and Node APIs,
where abort listeners can reject pending work before abort completion.
2026-06-03 12:06:08 -07:00
Josh Story 557e28fae7 [Fizz] Abort tasks that suspend after aborting during render (#36585)
Stacked on #36580

When a task calls `abort()` while it is rendering, Fizz intentionally
leaves that task alone during the synchronous abort sweep so it can
unwind normally. If the task then suspends before reaching a normal
abort check, however, it currently remains pending and does not report
the abort reason.

This change completes an aborted task once it has unwound back to the
retry loop. Instead of treating it as an ordinary render error, it is
routed through the existing abort task completion path so prerenders
continue to postpone aborted work correctly and replay tasks use aborted
resume semantics.

If the task suspended through `use()`, preserve its thenable state
before completing the abort. This allows DEV async debug info to replay
the suspended call site and include it in the owner stack, even though
the task began aborting before it suspended.

Add coverage for render, prerender, and resumed replay tasks that
suspend after initiating an abort, including a real-timer test verifying
the suspended call site is retained in DEV owner stacks.
2026-06-01 08:18:06 -07:00
Josh Story 3c882b4ab6 [Fizz] Finish abort in a scheduled task (#36580)
Stacked on #36584

`abort()` currently performs both the synchronous transition into an
aborted request and the reporting/completion of every unfinished task in
the same call. This change splits those phases. Aborting now
synchronously marks the request as aborted, captures the abort reason,
claims pending tasks so already scheduled work cannot continue rendering
them, and captures any DEV async debug information needed at the point
of abort. Reporting and completing the claimed tasks is then performed
from a scheduled `finishAbort()` callback.

This split does not yet allow a promise rejected by an abort listener to
replace the abort reason: work remains blocked once the request has been
aborted, and tests assert that abort-time rejections still report the
original abort reason. It establishes the task boundary needed for a
follow-up change to selectively process rejected suspended work before
completing the remaining aborted tasks.

This is observable for streaming renders because abort cleanup may now
happen after already available output is read. A Suspense boundary that
was previously converted to client rendering before it could be
serialized may instead be emitted as pending first and receive its
client-render instruction when the scheduled abort completion runs.

The scheduled finish must also preserve abort-during-render behavior in
renderers whose scheduler executes synchronously. The request tracks its
currently executing task, and both abort phases leave that task alone so
it can unwind through its normal abort path rather than being completed
twice or reporting an internal control-flow value.
2026-05-31 23:32:58 -07:00
Josh Story de8e00548f [Fizz] Fix aborts during resumed rendering (#36584)
Stacked on #36583

Fizz previously identified a task that was rendering during an abort by
marking its blocked Segment as `RENDERING`. This does not work for
resumed replay tasks because they do not have a Segment, so an abort
during replay could eagerly abort the task before it unwound and then
report the internal `null` throw instead of the abort reason.

Track the task currently executing on the Request so aborting can leave
the in-flight task to unwind through its normal error path for both
render and replay tasks. Since this replaces the only purpose of the
Segment `RENDERING` status, remove that status and its associated
bookkeeping.

When resumed work unwinds after aborting, use the request's abort reason
in the replay catch paths so aborting while replaying a prerendered tree
or while rendering a resumed segment reports the meaningful abort reason
instead of the internal control-flow value.
2026-05-31 14:01:29 -07:00
Josh Story ddcb58f0c4 [Fizz] Track abort state on Request (#36583)
Previously Fizz represented an active abort using the `ABORTING` request
status. This is ambiguous because aborting a task can synchronously
fatal the request, transitioning it to `CLOSING` or `CLOSED` while
another task is still unwinding from the same abort. Once that happened,
the in-flight task no longer observed that the request was aborted and
could fail to report its abort error.

This change removes the `ABORTING` status and instead tracks whether the
request was aborted independently on the Request. The existing
`fatalError` field continues to store the abort reason. As a result,
tasks that were rendering when an abort occurred continue to observe the
abort even if another aborted task has already fataled the request,
allowing all relevant unfinished task errors to be reported. DEV stalled
replays temporarily mask the aborted state so they can continue to
reconstruct suspended call sites as before.

This also establishes explicit abort state on the Request for follow-up
work that delays abort completion and allows rejected suspended work to
provide more specific abort errors.
2026-05-31 13:48:18 -07:00
Josh Story 05ca66ad9c [Fizz] model fb bundle's external work scheduling explicitly (#36576)
There are parts of Fizz that need to schedule work regardless of whether
the primary rendering pathway is drive externally through performWork.
Historically scheduleWork and later s cheduleMicrotask were noops in
this bundle but it makes it hard to reason about the code because you
cannot be assured that calling scheduleWork will actually result in the
function
ever executing. Now we model this explicitly through config. For builds
that drive work through external calls to performaWork we simply omit
any work scheduling in startWork or pi
ngTask. Now that this is modeled explicitly we can implement
scheduleMicrotask and scheduleWork to actually provide a guarantee that
the callbacks will get invoked.

For now I've implemented these two for the fb build as synchronous
however it is likely that queueMicrotask and setTimeout or similar are
preferred.
2026-05-30 13:30:53 -07:00
Josh Story f39ed9fd12 [Fizz] Continue reporting aborted task errors after a fatal abort (#36575)
Stacked on #36574

Normally, a fatal error transitions the request to CLOSED or CLOSING,
which prevents later aborted root tasks from reporting their errors.
Errors inside Suspense boundaries can still be reported, but other
pending root tasks are hidden once the first one fatally errors.

That behavior is useful for a normal fatal render error, where
subsequent work does not need to be processed. During an abort, however,
the abort reason is already the source of failure for every unfinished
task. Treating the first root task visited during abort cleanup as the
only observable fatal error privileges arbitrary task ordering and hides
useful information about the unfinished render.

Continue logging errors for pending tasks aborted after the request has
already fatally errored, while still only failing the shell once.
2026-05-30 08:43:05 -07:00
Josh Story f1af67e196 [Fizz] Do not allow abort reentrancy (#36574)
Aborting is a gate you can only pass through once. A request that is
already aborting, already completed, or already fataled cannot be
aborted a second time. Previously this was generally functionally true
but you could contrive sequences where an onError would fire after a
render fataled. This change makes it more explicit that this is not
semantically correct by bailing out of abort if the request is in a
status that cannot be aborted.
2026-05-30 08:32:47 -07:00
Hendrik Liebau f0dfee38f8 [Flight] Avoid main-thread stalls from large debug strings (#36570) 2026-05-29 14:27:50 +02:00
Hendrik Liebau c014813413 [Flight] Fix stranded row content under Node stream backpressure (#36516)
The Flight Server emits Text and TypedArray rows as two chunks: a header
that gives the row's id, type, and content length, followed by the
content itself. These two chunks were pushed into
`completedRegularChunks` (and `completedDebugChunks` in DEV) as separate
items, so when the destination signaled backpressure between them, the
flush would write the header and then break out before reaching the
content. The content chunk was left stranded at the head of the queue.
Async work running while the destination was paused appended new rows to
`completedImportChunks` / `completedHintChunks`, and the next drain
flushed those queues first — splicing the newly-arrived bytes into the
position the Flight Client expects to read as the original row's
content. From there the Flight Client read rows from the wrong byte
offsets and the model failed to deserialize.

This only surfaced on the Node stream path.
`createFakeWritableFromReadableStreamController`, used by
`renderToReadableStream`, always returns `true` from `write()`, so the
flush loop never saw backpressure.

The fix pushes a `NEXT_TWO_CHUNKS_ARE_ATOMIC` sentinel ahead of each
`headerChunk` / `contentChunk` pair in `completedRegularChunks` and
`completedDebugChunks`. The flush loops detect the sentinel and write
the two chunks that follow it together before re-checking backpressure,
so backpressure can still break between rows but never within one.

### Alternatives considered

- **Pushing the pair as a `[headerChunk, contentChunk]` tuple.** Simpler
but allocates an array per row. The required `isArray` branch in the
flush hot path is likely comparable with the symbol check. This also
violates the opaque `Chunk` type boundary.
- **Concatenating header and content into one chunk.** Bad for memory —
typed-array content can be large.
- **Storing atomic groups in a separate queue.** Conceptually wrong and
risks breaking reference-ordering assumptions between rows.
- **Ignoring backpressure until the regulars queue is empty.** Defeats
the point of backpressure.
- **Wrapping the tuple behind a host-config API** (`writeAdjacentChunks`
/ `isAdjacentChunks` / `chunksToAdjacentChunks` /
`getAdjacentChunksLength`). Keeps the implementation opaque but adds
four exports per host config. Also has the tuple overhead.
- **Begin/end sentinels for variable-length atomic groups.** Not needed
— only Text and TypedArray rows use this pattern, and both are pairs.
2026-05-27 22:19:49 +02:00
Sebastian "Sebbie" Silbermann c0cd4d5d30 Throw special error if rejected Promises are incorrectly instrumented (#36328)
When a rejected `Promise` is instrumented in userspace while setting the
rejection reason in the wrong field (e.g. `error` instead of `reason`),
React will throw undefined (because `usable.reason` doesn't exist). This
makes it incredibly hard to find the actual rejection reason.

React is now throwing a generic error if we couldn't find the rejection
reason. That will produce a callstack pointing into the problematic
Promise from where you can hopefully extract the real rejection reason
(alongside fixing the bad instrumentation).

We're doing this in prod since this is unlikely to surface in dev. We're
only doing runtime type-checking for the rejected case. That should be
hit rarely and therefore hopefully have negligible runtime impact.
2026-05-27 12:00:17 +02:00
Janka Uryga 37fa36ced3 [Fizz] Fix crash when capturing the callsite of a stalled use() of a Flight chunk that was rejected in the meantime (#36544)
`ensureSuspendableThenableStateDEV` patches `then` in fulfilled
thenables to avoid triggering a custom thenable's `then` in an
unexpected state. However, we weren't doing the same for rejected
thenables.

This affected `ReactPromise`, the type used for thenables passed from
server to client. if a `ReactPromise` passed to `use` was pending during
the render but became rejected between the abort and
`pushSuspendedCallSiteOnComponentStack`, then `ReactPromise#then` would
crash. (see the added test for a reprouction)
This is because we were putting the ReactPromise into an invalid state:
a `PendingChunk` expects to have a `value: null | Array<...>`, but we
were deleting `value` altogether, and tgus hitting `TypeError: can't
access property "push" of undefined` here:

https://github.com/facebook/react/blob/75b0945b18f4a60c80c931fd8067d9c715957879/packages/react-client/src/ReactFlightClient.js#L309
Bypassing the suspended thenable's `then` avoids this crash.
2026-05-26 21:48:03 +02:00
Sebastian "Sebbie" SilbermannandHendrik Liebau dd453071d9 [FlightReply] Type hardening and performance improvements (#36425)
Co-authored-by: Hendrik Liebau <mail@hendrik-liebau.de>
2026-05-06 19:39:45 +02:00
Xinzi Zhou ad5dfc82b7 Add react-flight-server-fb package for Meta's internal bundler (#36309)
<!--
  Thanks for submitting a pull request!
We appreciate you spending the time to work on these changes. Please
provide enough information so that others can review your pull request.
The three fields below are mandatory.

Before submitting a pull request, please make sure the following is
done:

1. Fork [the repository](https://github.com/facebook/react) and create
your branch from `main`.
  2. Run `yarn` in the repository root.
3. If you've fixed a bug or added code that should be tested, add tests!
4. Ensure the test suite passes (`yarn test`). Tip: `yarn test --watch
TestName` is helpful in development.
5. Run `yarn test --prod` to test in the production environment. It
supports the same options as `yarn test`.
6. If you need a debugger, run `yarn test --debug --watch TestName`,
open `chrome://inspect`, and press "Inspect".
7. Format your code with
[prettier](https://github.com/prettier/prettier) (`yarn prettier`).
8. Make sure your code lints (`yarn lint`). Tip: `yarn linc` to only
check changed files.
  9. Run the [Flow](https://flowtype.org/) type checks (`yarn flow`).
  10. If you haven't already, complete the CLA.

Learn more about contributing:
https://reactjs.org/docs/how-to-contribute.html
-->

## Summary

<!--
Explain the **motivation** for making this change. What existing problem
does the pull request solve?
-->

- Adds a new react-flight-server-fb package providing RSC Flight
bindings for Meta's internal bundler stack
- Unlike webpack/turbopack integrations, this uses no manifest. Module
metadata is self-contained in ClientReference objects and sent over the
wire as-is
- Registers dom-browser-fb and dom-node-fb host configs for Rollup
builds targeting FB_WWW_DEV and FB_WWW_PROD

 Key design differences from other bundler

 - No build-time manifest
- Module IDs use Haste module names (e.g. `"MyComponent"`), with named
exports encoded as `"Module#export"`, rather than file paths resolved
through a manifest
- Client-side loading uses `Bootloader.handlePayload()` +
`JSResource().load()`
- `resolveClientReferenceMetadata` and `resolveClientReference` are
pass-throughs

## How did you test this change?

<!--
Demonstrate the code is solid. Example: The exact commands you ran and
their output, screenshots / videos if the pull request changes the user
interface.
How exactly did you verify that your PR solves the issue you wanted to
solve?
  If you leave this empty, your PR will very likely be closed.
-->

E2E integration test is set up on Meta's internal system.
2026-04-27 09:48:37 -07:00
Josh Story 4b073f4887 [Fizz] add additional task reentrancy protections (#36291)
The prior fix for finishedTask reentrancy solved an observed failure.
This change adds a bit of defensive bookeeping to protect against other
theoretical reentrant task finishing that might fail in simlar ways but
where we don't have a clear demonstration of the bug.
2026-04-16 14:15:06 -07:00
Josh Story ea6792026f [Fizz] prevent reentrant finishedTask from calling completeAll multiple times (#36287)
It is possible for the fallback tasks from a Suspense boundary to
trigger an early `completeAll` call which is later repeated due to
`finishedTask` reentrancy. For node.js in particular this might be
problematic since we invoke a callback on each `completeAll` call but in
general it just isn't the right semantics since the call is running
slightly earlier than the completion of the last `finishedTask`
invocation. This change ensures that any reentrant `finishedTask` calls
(due to soft aborting fallback tasks) omit the `completeAll` call by
temporarily incrementing the total pending tasks.
2026-04-16 13:26:34 -07:00
Sebastian "Sebbie" Silbermann 404b38c764 [Flight] Add more cycle protections (#36236) 2026-04-08 21:01:27 +02:00
Sebastian "Sebbie" Silbermann 74568e8627 [Flight] Transport AggregateErrors.errors (#36156) 2026-03-28 18:18:21 -07:00
o-m12a 677818e4a2 Fix typos in tests and comments (#35627)
<!--
  Thanks for submitting a pull request!
We appreciate you spending the time to work on these changes. Please
provide enough information so that others can review your pull request.
The three fields below are mandatory.

Before submitting a pull request, please make sure the following is
done:

1. Fork [the repository](https://github.com/facebook/react) and create
your branch from `main`.
  2. Run `yarn` in the repository root.
3. If you've fixed a bug or added code that should be tested, add tests!
4. Ensure the test suite passes (`yarn test`). Tip: `yarn test --watch
TestName` is helpful in development.
5. Run `yarn test --prod` to test in the production environment. It
supports the same options as `yarn test`.
6. If you need a debugger, run `yarn test --debug --watch TestName`,
open `chrome://inspect`, and press "Inspect".
7. Format your code with
[prettier](https://github.com/prettier/prettier) (`yarn prettier`).
8. Make sure your code lints (`yarn lint`). Tip: `yarn linc` to only
check changed files.
  9. Run the [Flow](https://flowtype.org/) type checks (`yarn flow`).
  10. If you haven't already, complete the CLA.

Learn more about contributing:
https://reactjs.org/docs/how-to-contribute.html
-->

## Summary

<!--
Explain the **motivation** for making this change. What existing problem
does the pull request solve?
-->

I just fixed typos as followings.

- `succesful` → `successful`
- `becuase` → `because`
- `enought` → `enough`
- `defualt` → `default`

## How did you test this change?

<!--
Demonstrate the code is solid. Example: The exact commands you ran and
their output, screenshots / videos if the pull request changes the user
interface.
How exactly did you verify that your PR solves the issue you wanted to
solve?
  If you leave this empty, your PR will very likely be closed.
-->

This PR only includes test case description, dummy strings for test, and
comments updates, so it has no impact on runtime behavior.
Therefore, I manually reviewed changed texts to ensure correctness.
2026-03-27 14:53:32 -07:00
Sebastian "Sebbie" SilbermannandHendrik Liebau 12ba7d8129 [Flight Reply] Early bailout if backing entry for Blob deserialization is not a Blob (#36055)
Co-authored-by: Hendrik Liebau <mail@hendrik-liebau.de>
2026-03-17 11:50:27 +01:00
Hendrik Liebau 5e9eedb578 [Flight] Clear chunk reason after successful module initialization (#36024)
When `requireModule` triggers a reentrant `readChunk` on the same module
chunk, the reentrant call can fail and set `chunk.reason` to an error.
After the outer `requireModule` succeeds, the chunk transitions to
initialized but retains the stale error as `reason`.

When the Flight response stream later closes, it iterates all chunks and
expects `reason` on initialized chunks to be a `FlightStreamController`.
Since the stale `reason` is an `Error` object instead, calling
`chunk.reason.error()` crashes with `TypeError: chunk.reason.error is
not a function`.

The reentrancy can occur when module evaluation synchronously triggers
`readChunk` on the same chunk — for example, when code called during
evaluation tries to resolve the client reference for the module that is
currently being initialized. In Fizz SSR, `captureOwnerStack()` can
trigger this because it constructs component stacks that resolve lazy
client references via `readChunk`. The reentrant `requireModule` call
returns the module's namespace object, but since the module is still
being evaluated, accessing the export binding throws a TDZ (Temporal
Dead Zone) `ReferenceError`. This sets the chunk to the errored state,
and the `ReferenceError` becomes the stale `chunk.reason` after the
outer call succeeds.

This scenario is triggered in Next.js when a client module calls an
instrumented API like `Math.random()` in module scope, which
synchronously invokes `captureOwnerStack()`.
2026-03-12 19:17:24 +01:00
Sebastian "Sebbie" Silbermann 5e4279134d [noop] Typecheck react-noop-renderer against host config and renderer API (#35944) 2026-03-04 13:52:11 +01:00
Sebastian "Sebbie" Silbermann 2ba3065527 [Flight] Add support for transporting Error.cause (#35810) 2026-02-19 15:50:34 -08:00
Josh Story 38cd020c1f Don't outline Suspense boundaries with suspensey CSS during shell flush (#35824)
When flushing the shell, stylesheets with precedence are emitted in the
`<head>` which blocks paint regardless. Outlining a boundary solely
because it has suspensey CSS provides no benefit during the shell flush
and causes a higher-level fallback to be shown unnecessarily (e.g.
"Middle Fallback" instead of "Inner Fallback").

This change passes a flushingInShell flag to hasSuspenseyContent so the
host config can skip stylesheet-only suspensey content when flushing the
shell. Suspensey images (used for ViewTransition animation reveals)
still trigger outlining during the shell since their motivation is
different.

When flushing streamed completions the behavior is unchanged — suspensey
CSS still causes outlining so the parent content can display sooner
while the stylesheet loads.
2026-02-19 12:29:21 -08:00