inspect: print an own enumerable Symbol.toStringTag and hide the non-enumerable __esModule marker on the fast property walk too - #39045
Conversation
…ag and __esModule in the fast property walk The structure-based fast path of JSC__JSValue__forEachPropertyImpl skipped Symbol.toStringTag unconditionally and __esModule whenever it was inherited, while the getOwnPropertyNames slow path and forEachPropertyOrdered skip both only when the property is non-enumerable. Which keys console.log and Bun.inspect printed therefore depended on whether the object happened to have a getter: an own enumerable toStringTag was hidden on data-only objects, and the non-enumerable __esModule marker of compiled CommonJS modules was printed for data-only exports objects but not for ones with re-export getters. Hide __proto__, Symbol.toStringTag and __esModule in the fast path under the same DontEnum condition the slow path uses.
|
Updated 10:05 AM PT - Aug 15th, 2026
❌ @robobun, your commit dfce16b has 1 failures in 🧪 To try this PR locally: bunx bun-pr 39045That installs a local version of the PR into your bun-39045 --bun |
|
Status: reproduced and fixed, waiting on CI. Reproduced on bun 1.4.0 with |
|
Warning Review limit reached
Next review available in: 1 minute Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the 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 configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (2)
Comment |
There was a problem hiding this comment.
LGTM — aligns the fast property walk's skip rule for __proto__/Symbol.toStringTag/__esModule with the slow path's existing DontEnum gate.
What was reviewed:
- Confirmed the new fast-path condition at bindings.cpp:5534 exactly matches the slow-path rule at :5642 and the ordered walker's rule at :5806 (which never skipped
__esModule, as the description notes). - Checked that
constructoris still unconditionally skipped and that the droppedobjectToUse != objectguard for__esModuleis subsumed by theDontEnumcheck for the module-namespace prototype accessor it targeted. - Tests cover fast/slow/sorted × own/prototype × enumerable/non-enumerable for both keys, including the guard case that non-enumerable stays hidden.
Extended reasoning...
Overview
This PR changes one if condition in JSC__JSValue__forEachPropertyImpl's fast-path lambda in src/jsc/bindings/bindings.cpp. Previously the fast path unconditionally hid __proto__ and Symbol.toStringTag, and hid __esModule only when inherited; the slow path (getOwnPropertyNames-based) already hid all three only when DontEnum. The change makes the fast path use the same DontEnum gate, so Bun.inspect output no longer depends on which enumeration path an object happens to take. Five new test blocks in test/js/bun/util/inspect.test.js cover the matrix.
Security risks
None. This is purely presentational — it affects which property keys Bun.inspect/console.log prints for an object. No parsing of untrusted input, no auth/crypto/permissions, no memory management changes. entry.attributes() is already read on the line above the change.
Level of scrutiny
Low-to-moderate. The C++ change is a single condition rewrite; I verified against the current source that the slow path (bindings.cpp:5642-5646) and forEachPropertyOrdered (bindings.cpp:5806-5810) already implement the exact rule the fast path is being aligned to, so this is a consistency fix rather than a new policy. The constructor skip is preserved unchanged. The dropped objectToUse != object heuristic for __esModule is explained in the description (added for the module-namespace prototype's __esModule accessor, which is DontEnum and so still hidden).
Other factors
- Test coverage is thorough: each of the three walkers (fast, slow via getter, sorted) is exercised for own-enumerable, own-non-enumerable, and prototype cases of both
Symbol.toStringTagand__esModule. One test is an explicit no-regression guard (non-enumerable stays hidden). - The PR description enumerates the before/after for every shape and cross-references the related console/esModule test suites that were run.
- No prior reviewer comments to address; CI is building. The change is small, self-contained, and the reasoning for each dropped/kept clause is documented.
|
This PR may be a duplicate of:
🤖 Generated with Claude Code |
|
Not a duplicate of either, though both touch the same statement:
|
Problem
console.log({ a: 1, [Symbol.toStringTag]: "Tag" })printsTag { a: 1 }, but adding any getter to the same object makes the key appear:Tag { a: 1, g: [Getter], [Symbol(Symbol.toStringTag)]: "Tag" }.Bun.inspect(obj, { sorted: true })and node print the key in both cases.console.log(require("./compiled.cjs"))for a tsc/babel-compiled module prints the non-enumerable__esModule: truemarker when the module only has data exports, and hides it as soon as the module has a re-export getter. Node hides it in both cases.JSC__JSValue__forEachPropertyImplinsrc/jsc/bindings/bindings.cpphas two enumeration paths that apply different rules to these two keys. The fast path (structure->forEachPropertylambda, line 5528 on main) skippedSymbol.toStringTagunconditionally and__esModulewhenever it came from a prototype; the slow path (line 5635) andforEachPropertyOrdered(line 5799) skip them only when the property is non-enumerable. Which path an object takes is an implementation detail (a getter or indexed properties, for example, force the slow path), so the output depended on it.Fix
__proto__,Symbol.toStringTagand__esModuleunder the same condition as the slow path: only when the entry isDontEnum. No other line changes; theconstructorskip and the slow path are untouched.get [Symbol.toStringTag]()on a class prototype,Object.defineProperty(prototype, Symbol.toStringTag, ...)) and how compiled CommonJS marks itself (Object.defineProperty(exports, "__esModule", { value: true })). That is the form the skip exists for, and it stays hidden. An enumerable one was put there by a literal or an assignment and is user data; the formatter already prints every other enumerable property, including inherited ones, and two of the three walkers already printed these.__esModulerule that is dropped (hide when inherited) was added in Fixes #14411 #14691 alongside the slow path'sDontEnumrule, for the__esModuleaccessor bun installs on the prototype of module namespace objects. That accessor isDontEnum, so it is still hidden (and namespace objects never take the fast path anyway).Symbol.toStringTag(now printed) or own a non-enumerable__esModule(now hidden), which in both cases is what the same object already printed once it had a getter.{ [Symbol.toStringTag]: "x" }with no other properties already printed the key (the empty fast walk fell through to the slow path), so the committedconsole-log.expected.txtis unchanged.forEachPropertyOrdered(expect diffs, snapshots,sorted: true) never hides__esModule; it is a separate walker with a consistent rule of its own, and changing it would alter existing snapshots.bun bd test test/js/bun/util/inspect.test.js: the newSymbol.toStringTag propertyand__esModule propertytests cover the fast, slow (getter) and sorted walkers for own enumerable, own non-enumerable and prototype properties; 4 of the 5 fail on the build without this change, the fifth (non-enumerable stays hidden) guards against over-correcting.test/js/web/console,test/js/bun/console,test/cli/run/esm-defineProperty.test.ts,test/js/bun/resolve/esModule*.test.*and the rest oftest/js/bun/utilpass.Background
console.logandBun.inspectdo not enumerate properties themselves; the Rust formatter callsJSC__JSValue__forEachProperty, which lists the object's own properties and then walks up to five prototypes so that class methods are shown. When the object's JSCStructureallows it (no accessors, no indexed properties, no custom property lookup) it reads the property table directly (fast path); otherwise it callsgetOwnPropertyNamesand looks each property up (slow path).forEachPropertyOrderedis a third walker that lists own properties sorted by name; it backssorted: trueand the test runner's diff printer.DontEnumis JSC's attribute forenumerable: false. Class methods and accessors,Object.definePropertydefaults and bun's native prototypes use it; object literals and plain assignment create enumerable properties. Both formatters print non-enumerable properties in general (that is how prototype methods show up), which is why hiding the branding keys needs an explicit rule rather than falling out of an enumerable-only listing.Before / after for the shapes the tests cover