console: print an empty object past the depth as {}
Problem
- Past the console depth,
console.logandBun.inspectprint an empty object as[Object ...]. Nothing is hidden. Node prints{}, and an empty Array, Map, or Set already prints in full (#35288). - The cause is
print_object(src/jsc/ConsoleObject.rs:5372). It comparesdepthtomax_depthbefore it looks at the object. - In single-line mode the marker has two spaces before it:
{ c: [Object ...] }.print_object_depth_exceededwrites a space that every caller already wrote.
Fix
- Past the depth,
print_objectreturns the marker only when the object can have something to print. Every other object falls through and prints as it does inside the depth ({},Foo {}). The extra space is removed. - The new
JSC__JSValue__isDefinitelyEmptyForEachProperty(bindings.cpp) decides. It reads structures only and runs no JS. It returnstruewhen the object and its prototypes (the object alone withsorted: true) hold only keys that the property walk never reports (theconstructorof a prototype, private names). - Verified:
test/js/bun/util/inspect.test.js(18 new cases, 15 fail on the released bun). Also seven related console and inspect suites. - Self-reviewed: 3 concerns raised, 3 addressed.
Background
Formatter(ConsoleObject.rs) is the native printer behindconsole.logandBun.inspect. Itsmax_depthis 2 by default.util.inspectis a separate JS port and already prints{}.forEachProperty(bindings.cpp) is the property walk of the printer. It reports own and prototype properties, non-enumerable ones included. So "empty" here is notObject.keys(x).length === 0.- A structure is the hidden class of a JSC object. Its property table lists the object's own named properties.
Notes
Repro
class Foo {}
console.log({ a: { b: { plain: {}, inst: new Foo(), nul: Object.create(null), arr: [], map: new Map(), full: { x: 1 } } } });
| key | bun 1.4.3-canary | this branch | node v26.3.0 |
|---|---|---|---|
plain | [Object ...] | {} | {} |
inst | [Object ...] | Foo {} | Foo {} |
nul | [Object ...] | [Object: null prototype] {} | [Object: null prototype] {} |
arr | [] | [] | [] |
map | Map {} | Map {} | Map(0) {} |
full | [Object ...] | [Object ...] | [Object] |
Bun.inspect({ a: { b: { c: { d: 1 } } } }, { compact: true, depth: 2 }) returned { a: { b: { c: [Object ...] } } }. The same extra space appeared before a marker in an array, a Map key, a Map value, a Set, and a table cell.
Why a structure check, and not a run of the property walk
The walk can run user code and native code: the ownKeys trap of a Proxy on the prototype chain, lazy property initializers, native getters. It also copies every property before the first callback. Past the depth the printer ran none of that before this PR, and it still does not. The check visits at most five structures (the object and four prototypes, the same as the walk) and stops at the first key that counts. With sorted: true the printer uses forEachPropertyOrdered, which reads own properties only, so the check reads the object only. When it returns true, print_object runs the normal path, so the text comes from the same code as inside the depth.
Not changed
- The check returns
falsewhen it cannot decide from structures alone. These values print as empty inside the depth and keep the marker past it, as before: an object that had indexed keys ({ 0: 1 }afterdelete), an object whose only own key is a non-enumerableSymbol.toStringTag,new URLSearchParams(). { constructor: X }andJSON.parse('{"constructor":1}')keep the marker, as before this PR. Node prints[Object]for them. The walk hides everyconstructortoday. #38937 (open) makes it show an own enumerable one. The check counts an own enumerableconstructoras content, so it is correct in either merge order. Aconstructoron a prototype, enumerable or not (Old.prototype = { constructor: Old }), does not count.- #38218 (open, skip non-enumerable properties) and #36595 (open, do not list prototype members) make more objects print as empty inside the depth. If one of them lands first, the check only errs toward the marker. It can be relaxed then.
- The marker text is not changed. A class instance with content still prints
[Object ...]past the depth, where Node prints[Foo]. - An empty
MapIteratororSetIteratorpast the depth still prints a marker. The printer cannot see that an iterator is empty without a step of it.
Private fields
A debug build reports private names (JSC::Options::showPrivateScriptsInStackTraces). A release build never does. The check reads the same option. The subprocess test sets BUN_JSC_showPrivateScriptsInStackTraces=0, so a debug build prints like a release build there.
Placement
The check sits in bindings.cpp next to forEachProperty because it mirrors the skip rules of that function.
Self-review
- The first version counted every
constructorkey as hidden.{ constructor: X }then printed as empty past the depth. Fixed: only a non-enumerable ownconstructoror aconstructoron a prototype is hidden. Two tests cover both directions. - One test row pinned the text
{}for an emptyFormDatainside the depth. Node printsFormData {}, so that text is a separate defect. The row is removed. TheHeadersrow covers the same path (a name prefix, thenprint_objecton thetoJSONresult). - The body did not say what the PR leaves alone. The list above is the answer.
Review history
- The first push read five prototypes. The property walk reads the object and four prototypes, so an empty object with a key on prototype 5 printed
{}inside the depth and the marker past it. The check now reads four. The rowskeyOnFifthPrototypeandkeyOnFourthPrototypecover the limit. - The walk does read a fifth prototype in one case: the object and each of its four prototypes hold a different private name. Each level then counts as a hit and the walk stays in its fast path. The check accepts a private name on the object only, so it returns
falsefor that chain. The subprocess test builds the chain. Without thelevel == 0condition that test fails. - The comments on the new code are one line each.
- With
sorted: truethe first pushes still read the prototypes, so a class instance with methods and no fields printedFoo {}inside the depth and the marker past it. The check now takes anorderedflag (43fc334b75). - A review claimed that
Object.freeze({})gets an indexed shape and keeps the marker. It does not on this WebKit:describe()frombun:jscshowsNonArrayand no butterfly for frozen, sealed, and non-extensible empty objects, on the released build and on this branch. Thefrozenrow passes locally and in CI. No change. - A review asked for one shared predicate for the walk and the check. Not done: the three paths of the walk skip different keys, the check accepts only what all of them hide, and a key it does not know keeps the marker. Five open PRs touch the walk.
Tests
- 12 values that print as empty, each in 5 forms: inside the depth, past it,
sorted,compact, and inside nested arrays. - Negative cases: 10 values with content keep the marker, an own enumerable
constructorkeeps the marker, and theownKeystrap of a Proxy prototype does not run past the depth. sorted: true: a class instance with methods and an object with an inherited key print as empty inside the depth and past it.console.login a subprocess, with a private-field class and the private-name prototype chain from the review notes.- Marker spacing in single-line mode for an object, an array, a Map key, a Map value, and a Set.
Suites run on this branch: inspect.test.js (112 pass), inspect-error.test.js, bun-inspect-table.test.ts, console-table.test.ts, console-log.test.ts, console-depth.test.ts, bun-inspect.test.ts, reportError.test.ts.
no test proof · iteration 2 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/bun/util/inspect.test.js