Skip to content

fix(runner): promote React 18 boundary logs so they carry a component stack (DEV-2875) - #335

Merged
demtario merged 1 commit into
masterfrom
fix/DEV-2875-react-boundary-promotion
Sep 9, 2026
Merged

fix(runner): promote React 18 boundary logs so they carry a component stack (DEV-2875)#335
demtario merged 1 commit into
masterfrom
fix/DEV-2875-react-boundary-promotion

Conversation

@demtario

@demtario demtario commented Sep 9, 2026

Copy link
Copy Markdown
Member

Sentry DEMOS-4PThe above error occurred in the <ExampleComponent> component: arrives stackless, and its issues shard per component. Deferred by DEV-2858 because monitor.ts was owned by DEV-2853 at the time; that merged, so it is unblocked.

Diagnostic-quality, not user-facing. Nothing a visitor experiences changes. The value is that errors in previewed demos currently arrive without a stack, which makes them untriageable.

Three premise corrections up front

The original ticket got the mechanism wrong three ways, and each one changed the design:

1. React 18 passes ONE joined string, not a two-part call. react-dom@18.3.1/cjs/react-dom.development.js:18689-18704 builds componentNameMessage + "\n" + componentStack + "\n\n" + errorBoundaryMessage and passes it as a single argument. DEMOS-4P's event body confirms it — the relayed message is the whole block, component stack included, and Sentry shows only its first line as the title.

That is why recognition must be in-page: the stack lives inside the message, which send truncates at MONITOR_MESSAGE_MAX (500). A parent-side rule would only ever see the truncated remains.

2. "No sibling exception exists and none ever will" was false, and I had stated it as structural fact — including in a Sentry comment, now corrected. DEMOS-77 is kind: error with a full JS stack, sharing DEMOS-76's trace. React 18 dev surfaces the throw to window.onerror via the guarded-invoke fake-event trick, which is exactly why React's own wording is "The above error occurred".

3. React 19 needs no fix. It calls console.error("%o\n\n%s\n\n%s\n", error, …), so the Error is an argument and errorArgReport already re-homes it. Scope is Tier-1 docs examples pinned to react-dom: '18.x'. The react 19.2.7 context tag on these events is the authoring app's React, set parent-side.

The fix

reactBoundaryReport in the injected reporter, placed after errorArgReport so React 19 keeps winning. Four required conditions, and the fourth is the safety property:

  1. exactly one argument, and a string — React 19's 4-arg %o form is excluded by arity alone
  2. React's prefix at index 0 — covers both the <NAME> component: and one of your React components:
  3. "error boundary" present in the tail
  4. at least one at -form frame

Condition 4 means promotion is conditional on carrying the component stack that justifies it, so React prose with no frames stays exactly where it is today. at -form only, deliberately: sentry.ts synthesises DemoError: <msg>\n<stack> for Sentry's Chrome parser, and the legacy in X (at file:line) form parses to zero frames — worse than staying on the console channel.

Promoted as kind: "error" with no new MonitorKind. That is the only lever reaching captureException, which is the sole path by which a relayed stack reaches Sentry's parser at all — captureMessage drops it.

The component name is elided to <component> in-page, not in normalizeMonitorMessage. That function feeds only the fingerprint, so a rule there would fix grouping while leaving the title flapping per visitor-chosen component name — precisely the DEV-2854 defect. In-page fixes both with zero parent-side edits. The name is not lost: it is the first component-stack frame.

What actually improves

The duplication is not the defect. Today the companion's fingerprint is [demo-runtime, console-error, normalizeMonitorMessage(whole block)] — and that block contains the component stack, so it mints a new issue per component, file and line. After this, it is one permanent bucket.

Per-fault event count is unchanged at two (the real error plus the companion), never three — the branch is exclusive, so one console.error yields one relay. And the ticket's hope that "the pairing should collapse" cannot be satisfied client-side: React logs the companion after the browser saw the error, and it carries no error message to correlate on. Suppressing it when an error was relayed recently would delete the component stack exactly where it is most useful. The trace id is the pivot, and it already works.

Relay budget is never worse, sometimes better. console-error and error share one counter, so promotion spends the same slot it spends today. On repeats it is cheaper: today's dedupe key contains the up-to-500-char block, so a boundary looping over different components mints a new key each time; the constant message bounds that to component count.

Two corrections the tests caught

  • The plan said the stack is "capped at MONITOR_STACK_MAX". truncate appends a 3-char ellipsis, so the real bound is STACK_MAX + 3 (2003). Asserted exactly — a loose < 3000 would not notice the cap being removed, which is the thing worth pinning.
  • The plan's stack-cap test wanted the stack to both exceed the cap and retain the last frame. Those cannot both hold; truncation drops around frame 36 of 40. The test now asserts the bound and the truncation marker instead.

Verification

  • pipeline/monitor-inject.test.mjs75/75 (66 pre-existing + 9 new)
  • pnpm test1148 tests / 1146 pass / 0 fail / 2 todo (todos pre-existing)
  • pnpm typecheck — 0 errors
  • REPORTER_MODULE_LINE measured at 14.9 KB, matching the updated comments in monitor.ts and sandpack.ts

Revert-check, rebuilding between every run — that spec imports from packages/runtime/dist/, so a targeted run against a stale dist passes while exercising the old reporter, and the check would prove nothing. Removing the reactBoundaryReport call: 71 pass / 4 fail, and the four are exactly the fix-provers (promotion, stack-under-cap, host redaction, one-fault-twice). Guards stayed green. Restored and rebuilt: 75/75.

The four guards pass either way and are labelled as guards in-file: an ordinary console.error is untouched; React prose with no frames is not promoted; React 19's Error-argument form still wins via errorArgReport; and a second argument declines promotion. The reporter also still satisfies the pre-existing acorn ES5 parse, </script and one-physical-line assertions.

⚠️ This PR is self-reviewed

Every other PR in this batch went through an independent review agent. That budget is exhausted for the session, and the coding agent for this task died mid-run on the same limit — I finished the tests by hand. So the usual second pair of eyes is missing here, and Bugbot is the only independent gate on it. Given that Bugbot caught a test of mine earlier today that passed for the wrong reason, I would treat its verdict as load-bearing rather than a formality, and I am happy to run a proper review pass once the limit resets.

After deploy

The new fingerprint opens a new issue, so resolve DEMOS-4P as fixed and record the successor bucket. DEMOS-76 stays a duplicate of DEMOS-77 and will not recur in that sharded form.

Two things stated out loud because no test can catch them: this population moves from warning to error level, so it will fire once against any level-keyed alert rule; and if the docs pin ever moves to react-dom: 19.x, this rule goes dead silently (harmless — 19 needs no fix — but nothing would fail).

🤖 Generated with Claude Code


Note

Medium Risk
Changes demo-runtime telemetry classification and Sentry grouping for a narrow React 18 console shape; mis-detection could re-home or mis-fingerprint errors, but guards limit scope and there is no user-facing or auth impact.

Overview
Promotes React 18 dev error-boundary console.error logs from console-error to error so Sentry gets a real component stack instead of a truncated one-line message (DEV-2875 / DEMOS-4P).

The injected preview reporter gains reactBoundaryReport, run only after errorArgReport declines: it matches React 18’s single-string boundary log (prefix, "error boundary", and at least one at frame), splits frames into stack, and replaces the visitor component name with <component> for stable issue titles and fingerprints (DEV-2854). Ordinary console.error calls and React 19’s Error-argument form are unchanged.

sandpack.ts / monitor.ts comments are updated for the larger one-line reporter (~14.9 KB). Nine pipeline tests cover promotion, caps, redaction, dedupe, and guard rails.

Reviewed by Cursor Bugbot for commit 87ed830. Bugbot is set up for automated code reviews on this repo. Configure here.

… stack (DEV-2875)

Sentry DEMOS-4P arrives stackless and its issues shard per component.

React 18 passes ONE joined string to console.error --
react-dom@18.3.1 cjs/react-dom.development.js:18689-18704 builds
componentNameMessage + "\n" + componentStack + "\n\n" + errorBoundaryMessage
-- so the component stack lives inside the message. `send` truncates
messages at MONITOR_MESSAGE_MAX (500), which is why recognition has to
happen in-page: parent-side, the stack would already be gone.

`reactBoundaryReport` runs after `errorArgReport`, so React 19 keeps
winning (it passes the Error as an argument and is already re-homed).
Promotion requires four things, and the fourth is the safety property:
one string argument, React's prefix at index 0, "error boundary" in the
tail, and at least one `at `-form frame. No frames means no promotion, so
React prose alone stays on the console channel. `at `-form only --
`sentry.ts` synthesises `DemoError: <msg>\n<stack>` for Sentry's Chrome
parser, and the legacy `in X (at file:line)` form parses to zero frames,
which is worse than staying put.

The real defect is not the duplication. Today the companion's fingerprint
contains the component stack, so it mints a new issue per component, file
and line; the constant message makes it one permanent bucket. The pair
itself cannot be collapsed client-side -- React logs the companion after
the browser already saw the error, and it carries no error message to
correlate on. The trace id is the pivot, and it already works. Two
events per fault, never three: the branch is exclusive.

The component name is elided to <component> in-page rather than in
`normalizeMonitorMessage`, which feeds only the fingerprint -- a rule
there would fix grouping and leave the title flapping per visitor-chosen
component name, which is the DEV-2854 defect.

Two corrections to the plan, both caught by the tests:

- It claimed the stack is "capped at MONITOR_STACK_MAX". `truncate`
  appends a 3-char ellipsis, so the real bound is STACK_MAX + 3 (2003).
  Asserted exactly, because a loose `< 3000` would not notice the cap
  being removed.
- Its stack-cap test wanted the stack to both exceed the cap and retain
  the last frame. Those cannot both hold -- truncation drops around frame
  36 of 40. The test now asserts the bound and the truncation marker.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@demtario demtario self-assigned this Sep 9, 2026
@demtario
demtario merged commit 672076c into master Sep 9, 2026
6 checks passed
@demtario
demtario deleted the fix/DEV-2875-react-boundary-promotion branch September 9, 2026 13:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants