console: print an empty object past the depth as {}

RO
2mo ago
+217-5

Problem

  • Past the console depth, console.log and Bun.inspect print 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 compares depth to max_depth before it looks at the object.
  • In single-line mode the marker has two spaces before it: { c: [Object ...] }. print_object_depth_exceeded writes a space that every caller already wrote.

Fix

  • Past the depth, print_object returns 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 returns true when the object and its prototypes (the object alone with sorted: true) hold only keys that the property walk never reports (the constructor of 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 behind console.log and Bun.inspect. Its max_depth is 2 by default. util.inspect is 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 not Object.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 } } } });
keybun 1.4.3-canarythis branchnode v26.3.0
plain[Object ...]{}{}
inst[Object ...]Foo {}Foo {}
nul[Object ...][Object: null prototype] {}[Object: null prototype] {}
arr[][][]
mapMap {}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 false when 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 } after delete), an object whose only own key is a non-enumerable Symbol.toStringTag, new URLSearchParams().
  • { constructor: X } and JSON.parse('{"constructor":1}') keep the marker, as before this PR. Node prints [Object] for them. The walk hides every constructor today. #38937 (open) makes it show an own enumerable one. The check counts an own enumerable constructor as content, so it is correct in either merge order. A constructor on 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 MapIterator or SetIterator past 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

  1. The first version counted every constructor key as hidden. { constructor: X } then printed as empty past the depth. Fixed: only a non-enumerable own constructor or a constructor on a prototype is hidden. Two tests cover both directions.
  2. One test row pinned the text {} for an empty FormData inside the depth. Node prints FormData {}, so that text is a separate defect. The row is removed. The Headers row covers the same path (a name prefix, then print_object on the toJSON result).
  3. 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 rows keyOnFifthPrototype and keyOnFourthPrototype cover 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 false for that chain. The subprocess test builds the chain. Without the level == 0 condition that test fails.
  • The comments on the new code are one line each.
  • With sorted: true the first pushes still read the prototypes, so a class instance with methods and no fields printed Foo {} inside the depth and the marker past it. The check now takes an ordered flag (43fc334b75).
  • A review claimed that Object.freeze({}) gets an indexed shape and keeps the marker. It does not on this WebKit: describe() from bun:jsc shows NonArray and no butterfly for frozen, sealed, and non-extensible empty objects, on the released build and on this branch. The frozen row 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 constructor keeps the marker, and the ownKeys trap 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.log in 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