Skip to content

bun:test: format React 19 elements as JSX in snapshots and matcher diffs - #38934

Open
robobun wants to merge 4 commits into
mainfrom
farm/0d647d27/pretty-format-react-19-elements
Open

bun:test: format React 19 elements as JSX in snapshots and matcher diffs#38934
robobun wants to merge 4 commits into
mainfrom
farm/0d647d27/pretty-format-react-19-elements

Conversation

@robobun

@robobun robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • bun test prints a React 19 element as a plain object in snapshots (toMatchSnapshot, toMatchInlineSnapshot) and in the Expected/Received output of toEqual, toStrictEqual, toMatchObject, toHaveBeenCalledWith and the other matchers that render a diff:
    Expected: not {
      "$$typeof": Symbol(react.transitional.element),
      "key": null,
      "props": {
        "id": "x",
      },
      "ref": null,
      "type": "div",
    }
    
    The React 18 version of the same element prints as <div id="x" />, and Bun.inspect / console.log print the React 19 one as JSX too.
  • So a project that upgrades from React 18 to React 19 sees every committed element snapshot flip from JSX to the object dump above, and bun test disagrees with console.log about the same value.
  • Cause: Tag::get in src/runtime/test_runner/pretty_format.rs (line 495) only compares $$typeof against Symbol.for("react.element") and Symbol.for("react.fragment"). React 19 marks elements with Symbol.for("react.transitional.element"). The console formatter (src/jsc/ConsoleObject.rs, line 2249) was taught that symbol in the fix for JSX logging no longer works in react 19 #17223; the test runner formatter was not.
  • Children are classified through the same Tag::get, so a React 19 element whose props.children is an element (or an array of them) also prints its children as objects.

Fix

  • Tag::get checks the same three symbols as the console formatter. The fixing change is the react.transitional.element entry in REACT_ELEMENT_SYMBOLS; the rest of the hunk turns the two chained comparisons into a loop over that list. Nothing else changes: a React 19 element now takes exactly the path a React 18 element takes.
  • Why this is right: the symbol is the only difference between a React 18 and a React 19 element, so whatever bun test prints for one is what it should print for the other; ConsoleObject.rs already treats the symbol that way, and so does vitest's pretty-format (its ReactElement plugin accepts an element if either the React 18 or the React 19 copy of react-is does). Bun.markdown.react() also emits react.transitional.element elements by default.
  • Behavior change: a committed .snap that holds a React 19 element was written in the object form and needs bun test -u after this change. React 18 snapshots are unchanged (every test below runs with both symbols).
  • This PR does not touch the JSX printer itself, so React 19 elements inherit its open defects exactly as React 18 elements have them today:
  • Verification:
    • test/js/bun/test/snapshot-tests/snapshots/snapshot.test.ts, inline snapshots of %s elements: hand-built elements (test/ pins React 18, and the symbol is the only thing that differs) with a prop, a key, a component type, a single child element, array children, and an element inside an array. The react.transitional.element cases fail on the unfixed build with the object dump above; the react.element cases pass before and after.
    • test/js/bun/test/test-test.test.ts, expect() diffs print %s elements as JSX: spawns bun test on a file whose toEqual, toStrictEqual (child elements) and not.toEqual assertions fail, and snapshots the matcher output. Same fail-before pattern.
    • Both files pass in full with bun bd test. The one other failure in snapshot.test.ts locally (error snapshots) also fails on main when stderr is not a TTY and is addressed by test: make the error snapshots test pass with colors disabled #38833.

Background

  • A React element is the object JSX compiles to: { $$typeof, type, key, ref, props }. $$typeof is a registered symbol that marks the object as an element. React 18 and older use Symbol.for("react.element"); React 19 renamed it to Symbol.for("react.transitional.element") so that elements created by the two versions are not mistaken for each other.
  • Bun has two value formatters. src/jsc/ConsoleObject.rs backs console.log and Bun.inspect; src/runtime/test_runner/pretty_format.rs is the jest pretty-format equivalent used to serialize snapshots and to render the Expected/Received values that DiffFormatter diffs. Each one classifies a value with its own Tag::get before printing, and the Tag::JSX result selects that formatter's JSX printer.

The test runner's value formatter only recognized an object as a React
element when its $$typeof was Symbol.for("react.element") or
Symbol.for("react.fragment"). React 19 marks elements with
Symbol.for("react.transitional.element"), so toMatchSnapshot,
toMatchInlineSnapshot and the Expected/Received output of toEqual,
toStrictEqual, toMatchObject and the mock call matchers dumped those
elements as plain objects, while Bun.inspect already printed them as JSX.

Check the same set of symbols as the console formatter. Child elements go
through the same check, so nested React 19 trees print as JSX too.
@coderabbitai

coderabbitai Bot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 1 minute

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 961c1140-554a-4be6-bf02-a03d32414f59

📥 Commits

Reviewing files that changed from the base of the PR and between df4a040 and fcb787e.

📒 Files selected for processing (3)
  • src/runtime/test_runner/pretty_format.rs
  • test/js/bun/test/snapshot-tests/snapshots/snapshot.test.ts
  • test/js/bun/test/test-test.test.ts

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Reproduced on bun 1.4.0 with a hand-built element ({ $$typeof: Symbol.for("react.transitional.element"), type: "div", key: null, ref: null, props: { id: "x" } }): expect(el).not.toEqual(el) prints Expected: not { "$$typeof": Symbol(react.transitional.element), ... } and toMatchInlineSnapshot serializes the same object form, while the Symbol.for("react.element") version of the element prints <div id="x" /> in both places.

Fix in this PR: src/runtime/test_runner/pretty_format.rs Tag::get checks the same $$typeof symbols as the console formatter. Tests: test/js/bun/test/snapshot-tests/snapshots/snapshot.test.ts (inline snapshots of %s elements) and test/js/bun/test/test-test.test.ts (expect() diffs print %s elements as JSX); the react.transitional.element cases fail on the unfixed build, the react.element cases pass before and after. The tests are written so they pass whether this lands before or after the open JSX printer fixes (#38957, #38935, #38939, #38947); see the PR description for how those interact.

CI: every test job that ran is green (177 of 179 on the latest build). The remaining two are the darwin 14 aarch64 - test-bun jobs, which expired without starting; that queue has had no agents for every build in the pipeline this afternoon, so this is not specific to this change. Ready for review.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM — brings the test-runner formatter in line with ConsoleObject.rs for React 19's Symbol.for("react.transitional.element").

What was reviewed:

  • Tag::get loop: same three symbol keys ConsoleObject.rs checks; the refactor from two chained is_same_value calls to a loop over a const array preserves behavior for react.element/react.fragment.
  • Snapshot tests: describe.each runs every case (props, key, component type, single/array children, nested in object/array) for both symbols, so React 18 output is proven unchanged.
  • Diff test: uses tempDir/bunEnv, drains stdout/stderr/exited concurrently, and the regex extractor is anchored so an empty match set would fail the inline snapshot rather than pass vacuously.
Extended reasoning...

Overview

The PR adds b"react.transitional.element" to the set of $$typeof symbol keys that Tag::get in src/runtime/test_runner/pretty_format.rs classifies as Tag::JSX, and rewrites the two chained is_same_value checks as a loop over a three-entry const [&[u8]; 3]. Two test files gain coverage: snapshot.test.ts adds a describe.each over both symbol keys exercising toMatchInlineSnapshot for elements with props, keys, component types, child elements, array children, and elements nested inside objects/arrays; test-test.test.ts adds an it.each that spawns a fixture whose toEqual/toStrictEqual/not.toEqual fail and snapshots the matcher diff output.

Security risks

None. This is a display-only classification change in the test-runner value formatter. No parsing of untrusted input, no allocation-size arithmetic, no auth/crypto/permissions surface.

Level of scrutiny

Low. The functional change is a single new byte-string entry in a list whose other two entries were already present, aligning this formatter with src/jsc/ConsoleObject.rs:2253-2257 which already checks the same three keys. The loop refactor uses the exact same helpers (ZigString::init, JSValue::symbol_for, is_same_value) with ? propagation preserved, and short-circuits on the first match just as the || chain did. No new error paths, no lifetime or GC concerns (ZigString::init borrows a 'static slice per iteration).

Other factors

The tests follow repo conventions closely: tempDir + using, bunEnv/bunExe, Promise.all over stdout/stderr/exited, inline snapshots with exact JSX output, and expect(exitCode) asserted after content. Running both symbol keys through the same assertions guards against regressing React 18 formatting. The PR description notes both files were run under bun bd test and that the react.transitional.element cases fail on the unfixed build, satisfying the fails-for-the-right-reason bar. The one noted pre-existing local failure (error snapshots when stderr is not a TTY) is unrelated and tracked separately.

…utput shape

Nest the multi-line elements in an object so the snapshot only depends on how
the elements are classified, and re-indent the diff inline snapshot the way
bun test writes it.
Comment thread src/runtime/test_runner/pretty_format.rs Outdated
Comment thread src/runtime/test_runner/pretty_format.rs Outdated
@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 5:42 AM PT - Aug 15th, 2026

@robobun, your commit fcb787e is building: #98081

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM — the comment nit from the earlier pass is addressed in fcb787e.

What was reviewed:

  • Tag::get in pretty_format.rs: the loop over REACT_ELEMENT_SYMBOLS preserves the existing react.element/react.fragment paths and ?-propagates from is_same_value exactly as before; only the new react.transitional.element entry changes behavior.
  • Confirmed the symbol set matches the sibling check in ConsoleObject.rs:2250-2257.
  • Test coverage: .each over both symbol keys, so the react.element cases guard against regression while react.transitional.element cases exercise the fix; snapshot, child-element, array, and matcher-diff paths are all covered.
Extended reasoning...

Overview

The PR adds Symbol.for("react.transitional.element") (React 19's element marker) to the set of $$typeof symbols that Tag::get in src/runtime/test_runner/pretty_format.rs recognizes as a JSX element, so React 19 elements serialize as JSX in bun test snapshots and matcher diffs instead of as plain object dumps. The Rust change is ~15 lines: two chained is_same_value checks become a loop over a 3-entry const array, with the new symbol as the middle entry. Two test files add describe.each/it.each blocks over both react.element and react.transitional.element covering inline snapshots (single prop, key, component type, child element, array children, element-in-array) and matcher diffs (toEqual, toStrictEqual with children, not.toEqual).

Security risks

None. This is display-only formatting for test-runner output; no untrusted input reaches new allocation, syscall, or FFI paths. The refactored loop calls the same ZigString::init / JSValue::symbol_for / is_same_value helpers as before, with ? on the fallible call preserved.

Level of scrutiny

Low. The change mirrors the already-landed fix in ConsoleObject.rs (#17223) at the sibling formatter — I verified the symbol list at ConsoleObject.rs:2252-2257 matches. A React 19 element differs from a React 18 element only in this symbol, so routing it down the existing Tag::JSX path is the whole fix; the JSX printer itself is unchanged. The refactor from ||-chain to loop is behavior-preserving for the two existing symbols (same early-return on first match, same exception propagation).

Other factors

My one prior comment on this PR (multi-line code comment) and the comment-cop flag were both addressed in fcb787e — the header is back to one line and the react.transitional.element entry carries the #17223 link, matching the ConsoleObject.rs convention. All inline threads are resolved. The tests follow harness conventions (tempDir, bunEnv, concurrent pipe drain, toMatchInlineSnapshot on normalized output, exit code asserted last), and the .each structure means the react.element half guards the pre-existing behavior against regression. The bug-hunting pass found nothing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant