From b21673e8e2939f332bb08496c7bb14c6c20173b7 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 07:14:19 +0000 Subject: [PATCH 1/4] bun:test: format React 19 elements as JSX in snapshots and matcher diffs 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. --- src/runtime/test_runner/pretty_format.rs | 24 ++++---- .../snapshot-tests/snapshots/snapshot.test.ts | 47 +++++++++++++++ test/js/bun/test/test-test.test.ts | 58 +++++++++++++++++++ 3 files changed, 117 insertions(+), 12 deletions(-) diff --git a/src/runtime/test_runner/pretty_format.rs b/src/runtime/test_runner/pretty_format.rs index ed3cf5371ac7..44b2962e0767 100644 --- a/src/runtime/test_runner/pretty_format.rs +++ b/src/runtime/test_runner/pretty_format.rs @@ -492,20 +492,20 @@ impl Tag { return Tag::get(value.get_proxy_target(), global_this); } - // Is this a react element? + // Is this a react element? Same set of `$$typeof` symbols as the console + // formatter (`ConsoleObject.rs`): React 19 renamed `react.element` to + // `react.transitional.element`. if js_type.is_object() && js_type != JSType::ProxyObject { if let Some(typeof_symbol) = value.get_own_truthy(global_this, "$$typeof")? { - let mut react_element = ZigString::init(b"react.element"); - let mut react_fragment = ZigString::init(b"react.fragment"); - - if typeof_symbol - .is_same_value(JSValue::symbol_for(global_this, &mut react_element), global_this)? - || typeof_symbol.is_same_value( - JSValue::symbol_for(global_this, &mut react_fragment), - global_this, - )? - { - return Ok(TagResult { tag: Tag::JSX, cell: js_type }); + const REACT_ELEMENT_SYMBOLS: [&[u8]; 3] = + [b"react.element", b"react.transitional.element", b"react.fragment"]; + + for symbol_key in REACT_ELEMENT_SYMBOLS { + let mut symbol_key = ZigString::init(symbol_key); + let react_symbol = JSValue::symbol_for(global_this, &mut symbol_key); + if typeof_symbol.is_same_value(react_symbol, global_this)? { + return Ok(TagResult { tag: Tag::JSX, cell: js_type }); + } } } } diff --git a/test/js/bun/test/snapshot-tests/snapshots/snapshot.test.ts b/test/js/bun/test/snapshot-tests/snapshots/snapshot.test.ts index bef562452a65..91db49abc7de 100644 --- a/test/js/bun/test/snapshot-tests/snapshots/snapshot.test.ts +++ b/test/js/bun/test/snapshot-tests/snapshots/snapshot.test.ts @@ -371,6 +371,53 @@ test("basic unchanging inline snapshot", () => { ); }); +// React 18 marks elements with Symbol.for("react.element"), React 19 with +// Symbol.for("react.transitional.element"). Both serialize as JSX, like Bun.inspect +// prints them, instead of as the element's fields. test/ pins React 18, so the +// elements are built by hand. +describe.each(["react.element", "react.transitional.element"])("inline snapshots of %s elements", $$typeofKey => { + const $$typeof = Symbol.for($$typeofKey); + const h = (type: unknown, props: Record, key: string | null = null) => ({ + $$typeof, + type, + key, + ref: null, + props, + }); + function Greeting() {} + + test("serialize as JSX", () => { + expect(h("div", { id: "x" })).toMatchInlineSnapshot(`
`); + expect(h("li", { children: "one" }, "a")).toMatchInlineSnapshot(`
  • one
  • `); + expect(h(Greeting, { name: "bun" })).toMatchInlineSnapshot(``); + }); + + test("child elements serialize as JSX", () => { + expect(h("ul", { className: "list", children: h("li", { children: "one" }) })) + .toMatchInlineSnapshot(`
      +
    • one
    • +
    `); + expect(h("ul", { children: [h("li", { children: "one" }, "1"), h("li", { children: "two" }, "2")] })) + .toMatchInlineSnapshot(`
      +
    • one
    • +
    • two
    • +
    `); + }); + + test("elements inside other values serialize as JSX", () => { + expect({ el: h("div", { id: "x" }) }).toMatchInlineSnapshot(` + { + "el":
    , + } + `); + expect([h("br", {})]).toMatchInlineSnapshot(` + [ +
    , + ] + `); + }); +}); + class InlineSnapshotTester { tmpdir: string; tmpid: number; diff --git a/test/js/bun/test/test-test.test.ts b/test/js/bun/test/test-test.test.ts index 1fad55319b9b..c86dc59b84f3 100644 --- a/test/js/bun/test/test-test.test.ts +++ b/test/js/bun/test/test-test.test.ts @@ -588,6 +588,64 @@ it("expect().toEqual() on objects with property indices doesn't print undefined" expect(err).not.toContain("undefined"); }); +// React 18 marks elements with Symbol.for("react.element"), React 19 with +// Symbol.for("react.transitional.element"). Matcher diffs print both as JSX, the way +// Bun.inspect does, instead of dumping the element's fields. +it.each(["react.element", "react.transitional.element"])( + "expect() diffs print %s elements as JSX", + async $$typeofKey => { + using dir = tempDir("diff-jsx-" + $$typeofKey, { + "jsx.test.js": ` + import { expect, test } from "bun:test"; + const $$typeof = Symbol.for(${JSON.stringify($$typeofKey)}); + const h = (type, props) => ({ $$typeof, type, key: null, ref: null, props }); + test("toEqual", () => { + expect(h("div", { id: "received" })).toEqual(h("div", { id: "expected" })); + }); + test("toStrictEqual with child elements", () => { + expect(h("ul", { children: [h("li", { children: "one" })] })).toStrictEqual( + h("ul", { children: [h("li", { children: "two" })] }), + ); + }); + test("not.toEqual", () => { + const el = h("div", { id: "x" }); + expect(el).not.toEqual(el); + }); + `, + }); + await using proc = spawn({ + cmd: [bunExe(), "test", "jsx.test.js"], + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + env: bunEnv, + }); + const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + // Keep only the matcher output: from each `error:` line up to its stack trace. + const diffs = [...stderr.matchAll(/^error: expect\(received\)[^]*?(?=\n\s+at )/gm)].map(m => m[0]); + expect(diffs.join("\n")).toMatchInlineSnapshot(` + "error: expect(received).toEqual(expected) + + Expected:
    + Received:
    + error: expect(received).toStrictEqual(expected) + +
      + -
    • two
    • + +
    • one
    • +
    + + - Expected - 1 + + Received + 1 + error: expect(received).not.toEqual(expected) + + Expected: not
    " + `); + expect(exitCode).toBe(1); + }, +); + it("test --preload supports global lifecycle hooks", () => { const preloadedPath = join(tmp, "test-fixture-preload-global-lifecycle-hook-preloaded.js"); const path = join(tmp, "test-fixture-preload-global-lifecycle-hook-test.test.js"); From 130982d247f9372880ab277c52d6c547bb08a10f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 12:27:28 +0000 Subject: [PATCH 2/4] test: keep the React element cases independent of the JSX printer's output 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. --- .../snapshot-tests/snapshots/snapshot.test.ts | 36 ++++++++++--------- test/js/bun/test/test-test.test.ts | 28 +++++++-------- 2 files changed, 33 insertions(+), 31 deletions(-) diff --git a/test/js/bun/test/snapshot-tests/snapshots/snapshot.test.ts b/test/js/bun/test/snapshot-tests/snapshots/snapshot.test.ts index 91db49abc7de..003984b3f9d8 100644 --- a/test/js/bun/test/snapshot-tests/snapshots/snapshot.test.ts +++ b/test/js/bun/test/snapshot-tests/snapshots/snapshot.test.ts @@ -372,9 +372,9 @@ test("basic unchanging inline snapshot", () => { }); // React 18 marks elements with Symbol.for("react.element"), React 19 with -// Symbol.for("react.transitional.element"). Both serialize as JSX, like Bun.inspect -// prints them, instead of as the element's fields. test/ pins React 18, so the -// elements are built by hand. +// Symbol.for("react.transitional.element"). Both serialize as JSX, the way React 18 +// elements always have, instead of as the element's fields. test/ pins React 18, so +// the elements are built by hand. describe.each(["react.element", "react.transitional.element"])("inline snapshots of %s elements", $$typeofKey => { const $$typeof = Symbol.for($$typeofKey); const h = (type: unknown, props: Record, key: string | null = null) => ({ @@ -393,23 +393,25 @@ describe.each(["react.element", "react.transitional.element"])("inline snapshots }); test("child elements serialize as JSX", () => { - expect(h("ul", { className: "list", children: h("li", { children: "one" }) })) - .toMatchInlineSnapshot(`
      -
    • one
    • -
    `); - expect(h("ul", { children: [h("li", { children: "one" }, "1"), h("li", { children: "two" }, "2")] })) - .toMatchInlineSnapshot(`
      -
    • one
    • -
    • two
    • -
    `); - }); - - test("elements inside other values serialize as JSX", () => { - expect({ el: h("div", { id: "x" }) }).toMatchInlineSnapshot(` + // Nested in an object so this only covers how the elements and their children are + // classified, not how a multi-line top-level value is wrapped. + expect({ + one: h("ul", { className: "list", children: h("li", { children: "one" }) }), + many: h("ul", { children: [h("li", { children: "one" }, "1"), h("li", { children: "two" }, "2")] }), + }).toMatchInlineSnapshot(` { - "el":
    , + "many":
      +
    • one
    • +
    • two
    • +
    , + "one":
      +
    • one
    • +
    , } `); + }); + + test("elements inside an array serialize as JSX", () => { expect([h("br", {})]).toMatchInlineSnapshot(` [
    , diff --git a/test/js/bun/test/test-test.test.ts b/test/js/bun/test/test-test.test.ts index c86dc59b84f3..a10ca7a6748f 100644 --- a/test/js/bun/test/test-test.test.ts +++ b/test/js/bun/test/test-test.test.ts @@ -590,7 +590,7 @@ it("expect().toEqual() on objects with property indices doesn't print undefined" // React 18 marks elements with Symbol.for("react.element"), React 19 with // Symbol.for("react.transitional.element"). Matcher diffs print both as JSX, the way -// Bun.inspect does, instead of dumping the element's fields. +// React 18 elements always have, instead of dumping the element's fields. it.each(["react.element", "react.transitional.element"])( "expect() diffs print %s elements as JSX", async $$typeofKey => { @@ -625,23 +625,23 @@ it.each(["react.element", "react.transitional.element"])( // Keep only the matcher output: from each `error:` line up to its stack trace. const diffs = [...stderr.matchAll(/^error: expect\(received\)[^]*?(?=\n\s+at )/gm)].map(m => m[0]); expect(diffs.join("\n")).toMatchInlineSnapshot(` - "error: expect(received).toEqual(expected) + "error: expect(received).toEqual(expected) - Expected:
    - Received:
    - error: expect(received).toStrictEqual(expected) + Expected:
    + Received:
    + error: expect(received).toStrictEqual(expected) -
      - -
    • two
    • - +
    • one
    • -
    +
      + -
    • two
    • + +
    • one
    • +
    - - Expected - 1 - + Received + 1 - error: expect(received).not.toEqual(expected) + - Expected - 1 + + Received + 1 + error: expect(received).not.toEqual(expected) - Expected: not
    " - `); + Expected: not
    " + `); expect(exitCode).toBe(1); }, ); From 2bfe2833287dce0035fe10cce0acb505f1b9a4a6 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 12:31:16 +0000 Subject: [PATCH 3/4] Shorten the react element comment --- src/runtime/test_runner/pretty_format.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/runtime/test_runner/pretty_format.rs b/src/runtime/test_runner/pretty_format.rs index 44b2962e0767..b35bddeadf02 100644 --- a/src/runtime/test_runner/pretty_format.rs +++ b/src/runtime/test_runner/pretty_format.rs @@ -492,13 +492,14 @@ impl Tag { return Tag::get(value.get_proxy_target(), global_this); } - // Is this a react element? Same set of `$$typeof` symbols as the console - // formatter (`ConsoleObject.rs`): React 19 renamed `react.element` to - // `react.transitional.element`. + // Is this a react element? Keep the list in sync with `ConsoleObject.rs`. if js_type.is_object() && js_type != JSType::ProxyObject { if let Some(typeof_symbol) = value.get_own_truthy(global_this, "$$typeof")? { - const REACT_ELEMENT_SYMBOLS: [&[u8]; 3] = - [b"react.element", b"react.transitional.element", b"react.fragment"]; + const REACT_ELEMENT_SYMBOLS: [&[u8]; 3] = [ + b"react.element", // React 18 and below + b"react.transitional.element", // React 19 + b"react.fragment", + ]; for symbol_key in REACT_ELEMENT_SYMBOLS { let mut symbol_key = ZigString::init(symbol_key); From fcb787ece2a6091f71942d12d8e0f75ee5a4e151 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 12:42:37 +0000 Subject: [PATCH 4/4] Link the issue that introduced the React 19 element symbol --- src/runtime/test_runner/pretty_format.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/runtime/test_runner/pretty_format.rs b/src/runtime/test_runner/pretty_format.rs index b35bddeadf02..7cbe5ec67065 100644 --- a/src/runtime/test_runner/pretty_format.rs +++ b/src/runtime/test_runner/pretty_format.rs @@ -496,8 +496,9 @@ impl Tag { if js_type.is_object() && js_type != JSType::ProxyObject { if let Some(typeof_symbol) = value.get_own_truthy(global_this, "$$typeof")? { const REACT_ELEMENT_SYMBOLS: [&[u8]; 3] = [ - b"react.element", // React 18 and below - b"react.transitional.element", // React 19 + b"react.element", + // React 19 - https://github.com/oven-sh/bun/issues/17223 + b"react.transitional.element", b"react.fragment", ];