Skip to content

[Flight] Add support for transporting Error.cause#536

Closed
everettbu wants to merge 9 commits into
mainfrom
sebbie/02-17-_flight_add_support_for_transporting_error.cause_
Closed

[Flight] Add support for transporting Error.cause#536
everettbu wants to merge 9 commits into
mainfrom
sebbie/02-17-_flight_add_support_for_transporting_error.cause_

Conversation

@everettbu

Copy link
Copy Markdown

Mirror of facebook/react#35810
Original author: eps1lon


Summary

Adds support for transporting Error.cause (e.g. new Error(message, { cause }). React supports arbitrary values in .cause since .cause doesn't need to be an Error instance (see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/cause#description)

How did you test this change?

  • Added test

@everettbu everettbu added CLA Signed React Core Team Opened by a member of the React Core Team labels Feb 18, 2026
@greptile-apps

greptile-apps Bot commented Feb 18, 2026

Copy link
Copy Markdown

Greptile Summary

This PR contains two logically distinct changes:

1. Flight: Add support for transporting Error.cause

  • Serializes Error.cause on the server side by outlining the cause as a separate model chunk in serializeErrorValue, serializeDebugErrorValue, and emitErrorChunk
  • Deserializes cause on the client by calling reviveModel on the cause reference in resolveErrorDev, which correctly resolves Flight references (e.g. "$3") for both the $Z inline path and the E-row thrown error path
  • Refactors parseModel to replace the JSON.parse reviver callback (_fromJSON) with an explicit recursive reviveModel walk function, which enables cause resolution to work correctly in resolveErrorDev without needing the JSON.parse reviver context
  • Adds no-op setters to defineLazyGetter property descriptors to prevent strict-mode errors when reviveModel reassigns properties that were already defined as getter-only
  • Centralizes the JSONValue type in shared/ReactTypes.js and adds optional cause field to ReactErrorInfoDev

2. Fizz: Avoid unnecessary outlining for suspensey CSS during shell flush

  • When flushing the shell, CSS stylesheets with precedence are already emitted in the <head> which blocks paint — outlining a boundary for CSS alone would show a higher-level fallback unnecessarily
  • Adds flushingInShell parameter to hasSuspenseyContent so that during shell flush, only suspensey images (for ViewTransition animation reveals) trigger outlining
  • Uses a module-level flushingShell flag (consistent with existing flushingPartialBoundaries pattern) set during root segment flushing

Both changes include thorough test coverage.

Confidence Score: 4/5

  • This PR is safe to merge — both features are well-tested and the refactoring is mechanically sound.
  • The Error.cause transport feature correctly handles both the $Z inline path and E-row thrown error path through the new reviveModel function. The Fizz shell outlining optimization is clean and well-scoped. Both changes have comprehensive tests. The reviveModel refactoring is the highest-risk part as it replaces a core parsing mechanism, but the logic faithfully reproduces JSON.parse reviver semantics.
  • packages/react-client/src/ReactFlightClient.js has the most significant changes (reviveModel refactoring and cause resolution logic) and deserves the most review attention.

Important Files Changed

Filename Overview
packages/react-client/src/ReactFlightClient.js Major refactoring: replaces JSON.parse reviver with explicit reviveModel walk function, adds cause resolution in resolveErrorDev, removes _fromJSON field. No-op setters added to prevent strict-mode errors during property reassignment.
packages/react-client/src/tests/ReactFlight-test.js Adds two new tests covering Error.cause transport: one for errors passed as props ($Z inline path), and one for thrown errors (E-row path). Tests verify nested cause chains including non-Error cause values.
packages/react-server/src/ReactFlightServer.js Adds cause serialization to serializeErrorValue, serializeDebugErrorValue, and emitErrorChunk. Moves JSONValue type to shared package. Adds ReactErrorInfo to ReactClientValue union. Cause is outlined as a separate model chunk.
packages/shared/ReactTypes.js Centralizes JSONValue type definition, adds optional cause field to ReactErrorInfoDev type.
packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js Updates hasSuspenseyContent to accept flushingInShell parameter. During shell flush, only suspensey images trigger outlining since CSS stylesheets are already in the head blocking paint.
packages/react-server/src/ReactFizzServer.js Adds flushingShell module-level flag, passes flushingInShell parameter to hasSuspenseyContent calls. The flag is set during root segment flushing to differentiate shell flush from streamed boundary completions.
packages/react-dom/src/tests/ReactDOMFloat-test.js Adds three comprehensive tests: shell flush with suspensey CSS (shouldn't outline), streamed completion with suspensey CSS (should outline), and shell flush with suspensey images in ViewTransition (should still outline).

Last reviewed commit: d0fbb94

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

4 files reviewed, 2 comments

Edit Code Review Agent Settings | Greptile

Comment on lines +4175 to +4179
if ('cause' in error) {
const cause: ReactClientValue = (error.cause: any);
const causeId = outlineModel(request, cause);
errorInfo.cause = serializeByValueID(causeId);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cause serialization outside try-catch
In emitErrorChunk, the cause serialization is inside the try-catch block (lines 4263-4269), so if it fails, the error chunk is still emitted gracefully. Here in serializeErrorValue, the cause serialization is outside the try-catch. If outlineModel(request, cause) were to throw (e.g., due to an exotic cause value with throwing getters), this would propagate unhandled. Consider moving this block inside the existing try-catch for consistency with emitErrorChunk, or wrapping it in its own try-catch.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/react-server/src/ReactFlightServer.js
Line: 4175:4179

Comment:
**Cause serialization outside try-catch**
In `emitErrorChunk`, the cause serialization is inside the try-catch block (lines 4263-4269), so if it fails, the error chunk is still emitted gracefully. Here in `serializeErrorValue`, the cause serialization is outside the try-catch. If `outlineModel(request, cause)` were to throw (e.g., due to an exotic cause value with throwing getters), this would propagate unhandled. Consider moving this block inside the existing try-catch for consistency with `emitErrorChunk`, or wrapping it in its own try-catch.

How can I resolve this? If you propose a fix, please make it concise.

(error: any).environmentName = env;
const causeInfo = errorInfo.cause;
if (typeof causeInfo === 'string') {
// TODO: What bad things can I do by controling the cause reference?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typo in TODO comment
Minor: "controling" should be "controlling".

Suggested change
// TODO: What bad things can I do by controling the cause reference?
// TODO: What bad things can I do by controlling the cause reference?
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/react-client/src/ReactFlightClient.js
Line: 3564:3564

Comment:
**Typo in TODO comment**
Minor: "controling" should be "controlling".

```suggestion
    // TODO: What bad things can I do by controlling the cause reference?
```

How can I resolve this? If you propose a fix, please make it concise.

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

4 files reviewed, 1 comment

Edit Code Review Agent Settings | Greptile

Comment on lines +3562 to +3564
if ('cause' in errorInfo) {
(error: any).cause = errorInfo.cause;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cause reference not resolved on E-row path
When resolveErrorDev is called from the $Z inline path (via getOutlinedModel at line 2481), errorInfo.cause is already resolved through the Flight JSON reviver. However, when called from resolveErrorModel (line 3579), errorInfo comes from JSON.parse(row) without a reviver, so errorInfo.cause will be a raw Flight reference string like "$3" instead of the actual cause value.

This means for thrown errors (E-row path, e.g. errors caught by error boundaries), error.cause will be set to the unresolved string "$3" rather than the actual cause object. The owner field handles this correctly by manually resolving the reference via getOutlinedModel (line 3540-3543), but cause doesn't get the same treatment.

The test only covers the $Z path (error passed as a prop) and doesn't exercise the E-row path, so this issue isn't caught by the new test.

The cause should be resolved similarly to owner:

Suggested change
if ('cause' in errorInfo) {
(error: any).cause = errorInfo.cause;
}
if ('cause' in errorInfo) {
const causeRef = errorInfo.cause;
if (typeof causeRef === 'string' && causeRef[0] === '$') {
(error: any).cause = getOutlinedModel(response, causeRef.slice(1), error, 'cause', createModel);
} else {
(error: any).cause = causeRef;
}
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/react-client/src/ReactFlightClient.js
Line: 3562-3564

Comment:
**Cause reference not resolved on E-row path**
When `resolveErrorDev` is called from the `$Z` inline path (via `getOutlinedModel` at line 2481), `errorInfo.cause` is already resolved through the Flight JSON reviver. However, when called from `resolveErrorModel` (line 3579), `errorInfo` comes from `JSON.parse(row)` without a reviver, so `errorInfo.cause` will be a raw Flight reference string like `"$3"` instead of the actual cause value.

This means for thrown errors (E-row path, e.g. errors caught by error boundaries), `error.cause` will be set to the unresolved string `"$3"` rather than the actual cause object. The `owner` field handles this correctly by manually resolving the reference via `getOutlinedModel` (line 3540-3543), but `cause` doesn't get the same treatment.

The test only covers the `$Z` path (error passed as a prop) and doesn't exercise the E-row path, so this issue isn't caught by the new test.

The cause should be resolved similarly to `owner`:
```suggestion
  if ('cause' in errorInfo) {
    const causeRef = errorInfo.cause;
    if (typeof causeRef === 'string' && causeRef[0] === '$') {
      (error: any).cause = getOutlinedModel(response, causeRef.slice(1), error, 'cause', createModel);
    } else {
      (error: any).cause = causeRef;
    }
  }
```

How can I resolve this? If you propose a fix, please make it concise.

@everettbu
everettbu force-pushed the sebbie/02-17-_flight_add_support_for_transporting_error.cause_ branch from 768bf09 to ebccced Compare February 18, 2026 23:21

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

4 files reviewed, no comments

Edit Code Review Agent Settings | Greptile

@everettbu
everettbu force-pushed the sebbie/02-17-_flight_add_support_for_transporting_error.cause_ branch from ebccced to acb81d6 Compare February 19, 2026 00:37

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

4 files reviewed, no comments

Edit Code Review Agent Settings | Greptile

timneutkens and others added 9 commits February 19, 2026 08:37
…yload (#35776)

## Summary

Follow-up to vercel/next.js#89823 with the
actual changes to React.

Replaces the `JSON.parse` reviver callback in `initializeModelChunk`
with a two-step approach: plain `JSON.parse()` followed by a recursive
`reviveModel()` post-process (same as in Flight Reply Server). This
yields a **~75% speedup** in RSC chunk deserialization.

| Payload | Original (ms) | Walk (ms) | Speedup |
|---------|---------------|-----------|---------|
| Small (2 elements, 142B) | 0.0024 | 0.0007 | **+72%** |
| Medium (~12 elements, 914B) | 0.0116 | 0.0031 | **+73%** |
| Large (~90 elements, 16.7KB) | 0.1836 | 0.0451 | **+75%** |
| XL (~200 elements, 25.7KB) | 0.3742 | 0.0913 | **+76%** |
| Table (1000 rows, 110KB) | 3.0862 | 0.6887 | **+78%** |

## Problem

`createFromJSONCallback` returns a reviver function passed as the second
argument to `JSON.parse()`. This reviver is called for **every key-value
pair** in the parsed JSON. While the logic inside the reviver is
lightweight, the dominant cost is the **C++ → JavaScript boundary
crossing** — V8's `JSON.parse` is implemented in C++, and calling back
into JavaScript for every node incurs significant overhead.

Even a trivial no-op reviver `(k, v) => v` makes `JSON.parse` **~4x
slower** than bare `JSON.parse` without a reviver:

```
108 KB payload:
  Bare JSON.parse:    0.60 ms
  Trivial reviver:    2.95 ms  (+391%)
```

## Change

Replace the reviver with a two-step process:

1. `JSON.parse(resolvedModel)` — parse the entire payload in C++ with no
callbacks
2. `reviveModel` — recursively walk the resulting object in pure
JavaScript to apply RSC transformations

The `reviveModel` function includes additional optimizations over the
original reviver:
- **Short-circuits plain strings**: only calls `parseModelString` when
the string starts with `$`, skipping the vast majority of strings (class
names, text content, etc.)
- **Stays entirely in JavaScript** — no C++ boundary crossings during
the walk

## Results

You can find the related applications in the [Next.js PR
](vercel/next.js#89823 I've been testing this
on Next.js applications.

### Table as Server Component with 1000 items

Before:

```
    "min": 13.782875000000786,
    "max": 22.23400000000038,
    "avg": 17.116868530000083,
    "p50": 17.10766700000022,
    "p75": 18.50787499999933,
    "p95": 20.426249999998618,
    "p99": 21.814125000000786
```

After:

```
    "min": 10.963916999999128,
    "max": 18.096083000000363,
    "avg": 13.543286884999988,
    "p50": 13.58350000000064,
    "p75": 14.871791999999914,
    "p95": 16.08429099999921,
    "p99": 17.591458000000785
```

### Table as Client Component with 1000 items

Before:

```
    "min": 3.888875000000553,
    "max": 9.044959000000745,
    "avg": 4.651271475000067,
    "p50": 4.555749999999534,
    "p75": 4.966624999999112,
    "p95": 5.47754200000054,
    "p99": 6.109499999998661
````

After:

```
    "min": 3.5986250000005384,
    "max": 5.374291000000085,
    "avg": 4.142990245000046,
    "p50": 4.10570799999914,
    "p75": 4.392041999999492,
    "p95": 4.740084000000934,
    "p99": 5.1652500000000146
```

### Nested Suspense

Before:

```
  Requests:  200
  Min:       73ms
  Max:       106ms
  Avg:       78ms
  P50:       77ms
  P75:       80ms
  P95:       85ms
  P99:       94ms
```

After:

```
  Requests:  200
  Min:       56ms
  Max:       67ms
  Avg:       59ms
  P50:       58ms
  P75:       60ms
  P95:       65ms
  P99:       66ms
```

### Even more nested Suspense (double-level Suspense)

Before:

```
  Requests:  200
  Min:       159ms
  Max:       208ms
  Avg:       169ms
  P50:       167ms
  P75:       173ms
  P95:       183ms
  P99:       188ms
```

After:

```
  Requests:  200
  Min:       125ms
  Max:       170ms
  Avg:       134ms
  P50:       132ms
  P75:       138ms
  P95:       148ms
  P99:       160ms
```

## How did you test this change?

Ran it across many Next.js benchmark applications.

The entire Next.js test suite passes with this change.

---------

Co-authored-by: Hendrik Liebau <mail@hendrik-liebau.de>
…sh (#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.
This reverts commit c4daaf4.

We need to outline to simplify the default case where cause is an error

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

11 files reviewed, no comments

Edit Code Review Agent Settings | Greptile

@everettbu

Copy link
Copy Markdown
Author

Upstream PR was closed or merged. Code is synced via branch mirror.

@everettbu everettbu closed this Feb 20, 2026
@everettbu
everettbu deleted the sebbie/02-17-_flight_add_support_for_transporting_error.cause_ branch February 20, 2026 00:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed React Core Team Opened by a member of the React Core Team

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants