mirror of
https://github.com/react/react.git
synced 2026-09-28 21:25:11 +08:00
main
760
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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. |
||
|
|
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
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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. |
||
|
|
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> |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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> |
||
|
|
81e442eaf3 |
[FlightReply] Performance improvements when decoding (#37090)
Security Patches included in 19.2.8 Co-authored-by: Sebastian Sebbie Silbermann <sebastian.silbermann@vercel.com> |
||
|
|
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. |
||
|
|
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. |
||
|
|
71ecaf8990 |
[test] Add Flight regression test for async debug info surviving Promise GC (#37037)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
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> |
||
|
|
a1a6bc8974 |
[Fizz] Stop firing onAllReady after the shell errored (#36903)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
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.
|
||
|
|
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> |
||
|
|
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 |
||
|
|
dbc37501ff | Update required references to GitHub repo (#36752) | ||
|
|
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` |
||
|
|
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` |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
f0dfee38f8 | [Flight] Avoid main-thread stalls from large debug strings (#36570) | ||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
dd453071d9 |
[FlightReply] Type hardening and performance improvements (#36425)
Co-authored-by: Hendrik Liebau <mail@hendrik-liebau.de> |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
404b38c764 | [Flight] Add more cycle protections (#36236) | ||
|
|
74568e8627 |
[Flight] Transport AggregateErrors.errors (#36156)
|
||
|
|
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. |
||
|
|
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> |
||
|
|
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()`. |
||
|
|
5e4279134d |
[noop] Typecheck react-noop-renderer against host config and renderer API (#35944)
|
||
|
|
2ba3065527 |
[Flight] Add support for transporting Error.cause (#35810)
|
||
|
|
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. |