Skip to content

[Flight] Fix small debug info bugs found by the fuzzer - #37130

Open
gaearon wants to merge 5 commits into
gaearon-flight-debug-fuzzfrom
gaearon-debuginfo-tweaks
Open

gaearon wants to merge 5 commits into
gaearon-flight-debug-fuzzfrom
gaearon-debuginfo-tweaks

Conversation

@gaearon

@gaearon gaearon commented Jul 26, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #37129.

I'm not sure these are important and show up in practice vs which are purely test artifacts. This is the stuff found by the fuzzer so I figured we might want to fix these. See individual commits.

Strip hook dispatch stack frames by module instead of position

The await stack parse skipped a fixed count of async_hooks dispatch frames. This is a bit fragile (it is already wrong for plain fs callbacks). The proposal is to skip our own frame by count and the dispatch frames by module instead. The fuzzer now checks IO frame names; without the fix that fails on 5 of 20 seeds.

Don't turn fulfillment-only chunk subscriptions into unhandled rejections

In DEV, calling chunk.then(onFulfill) with no rejection callback still wires up a real native Promise inside the wrapper. If the chunk errors, that Promise rejects, and nothing can catch it: the caller didn't pass a rejection callback, and our .then() returns undefined so there's nothing to attach one to. Node then kills the process for an unhandled rejection. In practice only our test utils subscribe like this today, so this mostly makes tests around rejections not blow up. The fix is to only forward the rejection when the caller passed a rejection callback, which is what prod does.

Don't let debug info failures corrupt the data they describe

The server degrades a debug model to a placeholder string when it fails to serialize, and a prop that throws on property access triggers that today. The client then threw while initializing entries that reference the degraded model — writing a fake stack location onto an owner that's now a string — and the throw rejected the entry's pending data chunk. When the chunk's real row arrived, resolveModelChunk assumed that this means it's a stream chunk (because the chunk was no longer pending) and crashed the whole stream with controller.enqueueModel is not a function.

Now the failed entry is dropped instead (its slot gets a minimal component info). The next commit fixes the trigger, so this is not strictly necessary for the tests to pass, but it prevents similar crashes if there are more bugs like it.

Keep the shape of debug models that fail to serialize

JSON.stringify probes toJSON on every raw object before the replacer can substitute it, so a value whose property access throws replaced the whole model with a placeholder string, breaking every reference into it.

Probe each object the replacer returns first. If it throws, copy with a placeholder per broken value. The fuzzer now delivers promise props behind throwing proxies; without the fix that fails on 3 of 20 seeds.

@meta-cla meta-cla Bot added the CLA Signed label Jul 26, 2026
@github-actions github-actions Bot added the React Core Team Opened by a member of the React Core Team label Jul 26, 2026
@gaearon gaearon changed the title gaearon debuginfo tweaks [Flight] Fixes for debug info Jul 26, 2026
@gaearon
gaearon force-pushed the gaearon-debuginfo-tweaks branch 23 times, most recently from 965ee29 to 5e11c1d Compare July 26, 2026 13:53
@gaearon
gaearon force-pushed the gaearon-debuginfo-tweaks branch 3 times, most recently from 9f7170e to 53d0486 Compare July 26, 2026 14:12
@gaearon
gaearon force-pushed the gaearon-debuginfo-tweaks branch 5 times, most recently from 82badec to 6aadee2 Compare July 26, 2026 17:12
@gaearon
gaearon marked this pull request as ready for review July 26, 2026 17:44
@gaearon gaearon changed the title [Flight] Fixes for debug info [Flight] Fix small debug info bugs found by the fuzzer Jul 26, 2026
@gaearon
gaearon requested review from eps1lon and unstubbable and removed request for eps1lon July 26, 2026 17:44
gaearon and others added 2 commits July 26, 2026 20:01
The await stack parse skipped a fixed 5 frames of async_hooks dispatch,
and 3 for non-Promise resources. Those counts are wrong for some
resource types: I/O started from an fs callback was attributed one
frame too deep, naming its io entries after whatever frame sat there.
They are also artifacts of Node's dispatch internals rather than a
contract: if anything else in the process registers v8.promiseHooks,
Node dispatches through node:internal/promise_hooks as well and every
captured stack grows a frame.

Instead, skip our own callback frame and then the leading frames that
belong to node:internal/async_hooks and node:internal/promise_hooks.
The skip happens inside prepareStackTrace before the frames are
collected, so the dispatch frames are still never materialized; the
only added cost is reading the script name of the skipped frames.
Only those modules are skipped, and only at the top of the stack: an
await can legitimately sit inside other node internals and those
frames are what classifies it as not being in user space, and
node:internal/async_hooks itself shows up mid-stack legitimately
through AsyncResource.runInAsyncScope.

The new test pins the fs naming. The fuzzer checks every io entry's
name and top stack frame against the leaf's recorded kind; with the
fixed counts that check fails on 5 of the 20 seeds.

Co-authored-by: Claude Fable 5 <[email protected]>
…ed rejections

In DEV with enableAsyncDebugInfo, ReactPromise.prototype.then wraps
every subscription in a native Promise so that awaits on chunks get
picked up by the async tracking, and the wrapper unconditionally
registers a rejection listener. A subscriber that didn't pass a
rejection callback (chunk.then(fn)) then produces a derived native
promise that rejects with no handler and no way to attach one, since
our .then() doesn't return a chained promise. Under Node's default
--unhandled-rejections=throw this kills the process. In prod the same
subscription is a no-op.

Our own ReadableStream and async iterable paths subscribe without
rejection callbacks, and so can anything holding a chunk. The usual
way to reach an errored chunk in an otherwise healthy render is the
debug info of a rejection that a Server Component caught itself: the
render succeeded, but inspecting the awaited value with a
fulfillment-only subscription kills the process.

Subscriptions must all defer through the wrapper so they fire in
registration order regardless of whether they passed a rejection
callback (the stream paths mix both on the same chunk), so the fix is
to swallow the wrapper's rejection when the subscriber didn't pass a
rejection callback, matching prod.

The fuzzer now inspects every awaited debug value with a
fulfillment-only subscription; without the fix that crashes the test
process on any seed with a caught rejection. The new test reads the
debug info of a caught rejection back through getDebugInfo, whose
bare then() is such a subscription, which also reverts the earlier
workaround there.

Co-authored-by: Claude Fable 5 <[email protected]>
@gaearon
gaearon force-pushed the gaearon-debuginfo-tweaks branch 2 times, most recently from 8a6a949 to 72aba02 Compare July 27, 2026 03:33
gaearon and others added 3 commits July 27, 2026 05:02
A prop whose serialization into the debug channel makes JSON.stringify
throw makes the server degrade the component's outlined debug info to
a placeholder string. JSON.stringify probes toJSON on every raw object
before the replacer runs. Our own ClientReference proxies exempt that
probe specifically, but userland reference proxies of the same shape
throw on it, and so does a throwing toJSON getter. If the component
also awaits I/O, the client then initializes an awaited debug entry
whose owner is that string, throws while writing to it, and rejected
the still-pending data chunk. When the chunk's real model row arrived,
resolveModelChunk assumed a second row must be a stream chunk and
crashed the whole stream with TypeError: controller.enqueueModel is
not a function.

Debug info must never change the data it describes:

- A failure to initialize a debug chunk drops the entry instead of
  rejecting the data chunk it belongs to. The entry's reserved slot is
  filled with a minimal component info, like errors are represented in
  debug info elsewhere, so the array doesn't end up with a hole that
  index-based consumers would trip over.
- initializeFakeStack tolerates an owner that was degraded to a
  string, and initializeIOInfo tolerates a whole io model that was.
  The io info path runs synchronously inside row processing with no
  error boundary, so before this a degraded io model took down the
  entire stream.
- Fizz's server component stack walk tolerates holes in the debug
  info array. A slot stays a hole until its debug entry arrives (e.g.
  over a slow debug channel), so an SSR pass rendering the object
  mid-stream can observe one.

Co-authored-by: Claude Fable 5 <[email protected]>
When JSON.stringify of an outlined debug model threw, the whole model
was replaced with a placeholder string. The row's type changed out
from under everything that referenced it: rows that named the
component as an owner now pointed at a string, references into the
row's properties pointed into JSON that was never written since the
references are registered while the failed pass runs, and the client
threw away the rest of that component's debug info trying to
initialize them.

JSON.stringify reads every property of the objects the replacer
returns and probes each object property's toJSON before the replacer
can substitute it, so a value whose property access throws unwound the
entire stringify before the replacer's per-property error handling
could run. Our own ClientReference proxies exempt the toJSON probe
specifically, but userland reference proxies of the same shape throw
on it. The debug path already reads the original values to bypass
toJSON (#34759); the probe was the remaining way for a user value to
take down the whole model.

Run the same probes over each object the replacer returns before
JSON.stringify does. The common case is a scan that allocates nothing
and returns the model as is; only a model that would have thrown gets
copied, with the values that can't be probed replaced by the
placeholder string, per value instead of per model. Since the pass
never fails, no references are ever registered against JSON that
doesn't get written.

The fuzzer now exercises this: promise props are sometimes delivered
behind a proxy that throws for any property access outside the
thenable protocol. Values that reach a component through a userland
thenable are recorded as such, since the io entry that unblocks them
describes whatever context settled the thenable rather than the leaf
that produced the value, and React cannot see through the thenable to
forward the real chain.

Co-authored-by: Claude Fable 5 <[email protected]>
… oracle

The computed oracle derives three more expectations from what each
seed's program recorded:

- For a combinator over plain single-leaf parts, the part whose promise
  completed last (for all) or first (for race) is the one whose io
  unblocked it, and that leaf must be attributed in the initiating
  component's render; the other parts stay exempt. Completion order is
  recorded live because a part may take extra microtask hops after its
  leaf settles, so leaf settle order is not completion order.
- A single-leaf promise passed down as a prop is awaited by the child,
  so it must be attributed inside the child's render, owned by the
  child.
- Rejected awaits must attribute their io as precisely as resolved
  ones, and a leaf must settle the way it was created to settle.

Awaited entries also carry an ownership expectation now: for an
unshared fetch the entry must belong to the task of the component the
generator recorded invoking the source.

Two behaviors the checks have to model: a debug value that is deferred
without a debug channel stays pending forever, so such an entry counts
as a wildcard sighting against its owner instead of being
indistinguishable from missing attribution; and a derived chain nested
inside another composite attributes whichever io the enclosing await
chain surfaces, not necessarily its own, so those leaves are exempt
from completeness like combinator losers.
@gaearon
gaearon force-pushed the gaearon-debuginfo-tweaks branch from 72aba02 to af31235 Compare July 27, 2026 04:04

@dianatofficial dianatofficial 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.

Clean implementation. Types and docs are up to date.

hoxyq pushed a commit that referenced this pull request Sep 13, 2026
…me 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
github-actions Bot pushed a commit that referenced this pull request Sep 13, 2026
…me 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

DiffTrain build for [ccea5fd](ccea5fd)
github-actions Bot pushed a commit to code/lib-react that referenced this pull request Sep 13, 2026
…me names (react#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. react#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

DiffTrain build for [ccea5fd](react@ccea5fd)
github-actions Bot pushed a commit to code/lib-react that referenced this pull request Sep 13, 2026
…me names (react#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. react#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

DiffTrain build for [ccea5fd](react@ccea5fd)
github-actions Bot pushed a commit to seshan18/react that referenced this pull request Sep 13, 2026
…me names (react#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. react#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

DiffTrain build for [ccea5fd](react@ccea5fd)
github-actions Bot pushed a commit to seshan18/react that referenced this pull request Sep 13, 2026
…me names (react#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. react#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

DiffTrain build for [ccea5fd](react@ccea5fd)

This branch has not been deployed

No deployments
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.

2 participants