Print JSX component tags with the component's inferred name or displayName

RO
3mo ago
+111-58

Problem

  • Bun.inspect / console.log of a JSX element prints <NoName /> for the most common kinds of components: const Bar = () => null and const Bar = function () {} (name inferred from the binding), const Bar = class {}, and anything named through displayName. console.log(Bar) prints [Function: Bar] for the same values, so the two disagree. (Same symptom as #4104; the function App() {} case reported there has since been fixed, the cases above were not.)
  • An async component (async function Page() {}) prints <AsyncFunction />.
  • The test runner's formatter has the same bug, so expect(<Bar />).toMatchSnapshot() stores <NoName /> and failure diffs show <NoName />.
  • Cause: both JSX printers (print_jsx in src/jsc/ConsoleObject.rs, the Tag::JSX arm in src/runtime/test_runner/pretty_format.rs) look the tag name up with JSValue::get_name_property (JSC__JSValue__getNameProperty, src/jsc/bindings/bindings.cpp). That binding reads Symbol.toStringTag first (which is how AsyncFunction.prototype[Symbol.toStringTag] wins over the function's own name) and then only the explicit name from the function's executable; it never consults the inferred name or displayName. The function and class printers were moved to JSValue::get_name (getCalculatedDisplayName) in #6612; the JSX printers were not.

Fix

  • Adds jsx_component_tag_name next to the console formatter and uses it from both JSX printers. Lookup order: a non-empty string displayName property, else get_name (the lookup [Function: x] / [class x] use, which falls back to Symbol.toStringTag when there is no name), else NoName as before.
  • Why this is correct: displayName || name is the rule React itself uses to name a component in its warnings and devtools, and the rule jest's React element serializer uses for function components. Taking the name half from get_name means that for a function or class component the tag is the same string console.log prints for the component value ([Function: x] / [class x]), which is the consistency the report asks for.
  • displayName is read as a property rather than left to getCalculatedDisplayName (which already honors it for functions) because React.memo() / React.forwardRef() results are plain objects, and Button.displayName = "Button" is the only way they get a name; get_name returns nothing for a non-function.
  • Each tag-name branch now yields the ZigStringSlice directly instead of going through a ZigString first; the string / symbol / primitive branches produce the same bytes as before (fragments still print as <>...</>).
  • Behavior that changes besides the cases above, for the record: a component that has both a name and a Symbol.toStringTag now prints its name (before, the tag won; async and generator functions are the practical instance). A function whose displayName is the empty string prints <NoName /> (before: its function name), because getCalculatedDisplayName returns the empty displayName as the name; this is the same thing console.log already prints for such a function ([Function]), so the two printers still agree, and it is not a pattern React code uses.
  • Behavior deliberately unchanged: a memo / forwardRef object without a displayName still prints <NoName /> (naming it after the wrapped function is a separate feature), an object type with a Symbol.toStringTag still prints the tag, and a component with no name at all still prints <NoName />.
  • Not changed: get_name_property itself. pretty_format.rs still uses it to print [Function] for inferred-name functions in snapshots, which #6612 deliberately kept so existing .snap files do not change; this PR only changes the JSX tag, which was NoName before and therefore not something a snapshot could usefully depend on.
  • Verified:
    • test/js/bun/util/inspect.test.js, describe("jsx component tag name"): arrow function, anonymous function expression, function declaration, displayName over an explicit function name, class, anonymous class expression, async function, displayName on a forwardRef-style object, a memo-style object without displayName (still NoName), a function with no name at all (still NoName), the closing tag of an element with children, and the [Function: x] / [class x] output for the same values. Each element case asserts Bun.inspect and, through toMatchInlineSnapshot, the test runner's printer. 7 of the 12 tests fail on the released bun 1.4.0 (<NoName /> / <AsyncFunction /> / <NoName>child</NoName>); all pass with this change. The previous "jsx with anon component" test, which asserted <NoName /> for const Foo = () => ..., is replaced by the arrow function case.
    • The rest of inspect.test.js, bundler_jsx.test.ts, run-eval.test.ts (string tags and fragments through the restructured branches), snapshot-tests/snapshots/snapshot.test.ts and console-table.test.ts pass with the debug build.
    • The test runner formatter's output for function values themselves ([Function], [class], [Function: AsyncFunction]) is byte-identical before and after, per the note above.

Background

  • React element: the object JSX compiles to, { $$typeof: Symbol.for("react.element"), type, props, key }. type is a string for host elements ("div") and a function, class, or (for memo / forwardRef) an object for components. Both formatters detect it by $$typeof and print it back as JSX, using type's name as the tag.
  • Inferred function name: const Bar = () => null gives the arrow Bar.name === "Bar", but JSC stores that as the executable's ecmaName, separate from the explicit name of function Bar() {}. JSFunction::name() returns only the explicit one; getCalculatedDisplayName (what JSValue::get_name wraps) checks displayName, then the explicit name, then the inferred one.
  • displayName: a plain property React reads to name a component in warnings and devtools. React honors it on functions and on memo / forwardRef objects alike.
  • JSValue::get_name returns a bun_core::String holding a +1 ref; OwnedString releases it on scope exit and to_utf8() takes its own ref (or makes a copy), which is why the returned slice can outlive the OwnedString.
Before / after on the cases from the report
$ bun -e '
const Bar = () => null;
console.log(Bar);
console.log(Bun.inspect({ $$typeof: Symbol.for("react.element"), type: Bar, props: {} }));
const W = function () {}; W.displayName = "Pretty";
console.log(W);
console.log(Bun.inspect({ $$typeof: Symbol.for("react.element"), type: W, props: {} }));
async function Page() {}
console.log(Bun.inspect({ $$typeof: Symbol.for("react.element"), type: Page, props: {} }));
'
# bun 1.4.0            # this branch
[Function: Bar]        [Function: Bar]
<NoName />             <Bar />
[Function: Pretty]     [Function: Pretty]
<NoName />             <Pretty />
<AsyncFunction />      <Page />