diff --git a/src/runtime/test_runner/pretty_format.rs b/src/runtime/test_runner/pretty_format.rs index ed3cf5371ac7..7cbe5ec67065 100644 --- a/src/runtime/test_runner/pretty_format.rs +++ b/src/runtime/test_runner/pretty_format.rs @@ -492,20 +492,22 @@ impl Tag { return Tag::get(value.get_proxy_target(), global_this); } - // Is this a react 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")? { - 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", + // React 19 - https://github.com/oven-sh/bun/issues/17223 + 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..003984b3f9d8 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,55 @@ 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, 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) => ({ + $$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", () => { + // 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(` + { + "many":
      +
    • one
    • +
    • two
    • +
    , + "one":
      +
    • one
    • +
    , + } + `); + }); + + test("elements inside an array serialize as JSX", () => { + 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..a10ca7a6748f 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 +// 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 => { + 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");