Skip to content

console.table: skip properties entries whose key is a Symbol - #46986

Open
robobun wants to merge 2 commits into
mainfrom
robobun/a962775b/console-table-property-key
Open

robobun wants to merge 2 commits into
mainfrom
robobun/a962775b/console-table-property-key

Conversation

@robobun

@robobun robobun commented Oct 26, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • console.table(data, properties) and Bun.inspect.table(data, properties) throw when a properties entry has a Symbol key. The error is TypeError: Cannot convert a symbol to a string. Node leaves that column out.
  • Cause: TablePrinter::print_table (src/jsc/ConsoleObject.rs:899) converts each entry with ToString. Node uses the entry as a property key and lists only the string keys.

Fix

  • The new Bun__ConsoleObject__tableColumnName converts an entry as obj[entry] does. A Symbol key gets no column.
  • Correct: a type test on the entry misses Object(Symbol()). Bun's JS Console#table already prints these inputs as Node does.
  • Verified: test/js/bun/console/console-table.test.ts (18 new tests, 14 fail on main). Also ran bun-inspect-table.test.ts and the console test directories.
  • Self-reviewed: 14 concerns raised, 13 addressed. Rejected: a review request to a second maintainer, which the assignee decides.

Background

  • TablePrinter is the native printer behind console.table and Bun.inspect.table. Its first loop turns properties into the column list.
  • ToPrimitive asks an object for a primitive: Symbol.toPrimitive, then toString, then valueOf. A property key keeps a Symbol result. ToString throws for it.
  • Considered the is_symbol() skip of console.table: skip Symbol entries in the properties filter #36150: Object(Symbol()) still throws. Considered Node's column model: it adds work to calls that pass no Symbol.
Notes

Repro

const S = Symbol("col");
const data = [{ a: 1, b: 2 }];
console.table(data, [S, "a"]);
console.table(data, [Object(S), "a"]);
console.table(data, [{ [Symbol.toPrimitive]: () => S }, "a"]);
console.table(data, [{ toString: () => S }, "a"]);
console.table([{ a: 1, [S]: 9 }], [S, "a"]);
Bun.inspect.table(data, [S, "a"]);
const row = { a: 1, [S]: 9 };
console.table([row], Reflect.ownKeys(row));

Each line throws on bun 1.4.2 and on main (449877f). Each line prints a table with the one column a on Node v26.3.0 (the console.table lines) and with this PR.

Measurements (linux x64, release builds of main 449877f and of this PR)

  • string-only properties: 176 instructions and 3 calls per entry (gdb stepi over print_table::<false>, 16 names, 0 rows). main 449877f: 171 and 3. The call to BunString__fromJS becomes the call to the new export.
  • no properties, 1 row x 2 keys: 10,544 instructions and 262 calls in print_table (main: 10,547 and 262), the same call sequence.
  • FFI calls per 1000 x Bun.inspect.table([{a:1,b:2}], ['a','b']) on an --lto=off release build: entry coercion 2000 (main 2000), contiguousVectorIsStillValid 2000 (main 2000), getOwn 2000 (main 2000). The counts for [42, 'a'] are the same. One call of 3 rows x 16 names: 16, 16 and 48 on both builds.
  • allocations inside print_table (mi_malloc/mi_realloc/mi_free): 1 row x 2 names 5/5/9 (main 5/5/9), 3 rows x 16 names 7/18/24 (main 7/18/24), 100 rows x 3 names 104/21/124 (main 104/21/124), 100 x 3 with no properties 304/21/324 (main 304/21/324). [42, {}] gives 4/6/9 on both. The JS heap object counts after 1000 calls and a full GC are equal on both builds.
  • release binary .text: +512 B (llvm-size, main 449877f vs PR). Bun__ConsoleObject__tableColumnName 469 B, print_table::<false> 3,650 B (was 3,629), print_table::<true> 3,730 B (was 3,709). cppbind exports +1 (114 to 115). Host functions +0. The stripped binary has the same file size.
  • size_of TablePrinter 192 B (main 192), Column 32 B (main 32).
  • coercions per properties entry over 3 rows: 1 (main 1, Node v26.3.0 13). Reads per cell: 1 (main 1).
  • unchecked-exception reports under BUN_JSC_validateExceptionChecks=1: 0. A build without the check after ToPrimitive aborts in that mode, so the mode is active.
  • Symbol-key form x entry-point cases that throw: 0 of 32 (main: 32 of 32). Non-Symbol control headers changed: 0 of 10. Existing snapshots changed: 0.
  • Tools: bloaty, valgrind and perf are not installed here. The numbers come from llvm-size, llvm-nm and gdb.

Not ported from Node

  • Node reads row[S] in each row for an entry whose key is a Symbol, and drops the result. This PR does not read it. The printed table is the same.
  • Bun converts each entry once, before it reads a row. Node converts it inside the row loop, and does not touch properties for zero rows, a Map or a Set.
  • Duplicate entries and integer-like keys stay as they are: ["b", "b", "a"] prints b, b, a. Node prints b, a.

Scope

History

Workaround for released versions

const cols = p => Array.from(p).flatMap(k => Object.keys({ [k]: 0 }));
console.table(rows, cols(properties));

Tests

  • 14 of the 18 new tests fail on bun 1.4.2 and on a debug build of main. The 4 tests under "an entry with a string key" are controls. They pass without the fix and pin what it must not change: the header for 10 kinds of non-Symbol entry, the string hint, one conversion per entry, and the error of a conversion that throws.
  • Two builds with the wrong ToPrimitive hint (PreferNumber, NoPreference) each fail 2 of the new tests.
  • Debug build: console-table.test.ts 50 pass, 19 snapshots unchanged. bun-inspect-table.test.ts 38 pass, 26 snapshots unchanged. test/js/bun/console/, test/js/web/console/ and test/js/node/console/ in one process: 131 pass.
  • In one of three runs of those directories, the existing test "cell values survive a full GC between the width and render passes" reached the 5 s timeout. It makes 64 full GCs and passes no properties. Alone it takes 3.4 s to 4.3 s on this debug build, and 4.1 s on a debug build of main.

Downsides

TablePrinter converted every entry of `properties` with ToString. ToString
throws a TypeError for a Symbol. Node uses each entry as a property key and
lists only the string keys, so an entry whose key is a Symbol names no
column.

Add Bun__ConsoleObject__tableColumnName. It applies ToPropertyKey
semantics: ToPrimitive with a string hint, then no column for a Symbol and
ToString for any other value. This covers a Symbol, a boxed Symbol, and an
object whose Symbol.toPrimitive, toString or valueOf returns a Symbol, for
console.table and Bun.inspect.table when the data is an object.
@robobun

robobun commented Oct 26, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 9:12 AM PT - Oct 26th, 2026

✅ @robobun, your commit f5fc9797f6ba063487cb4ba80b292dcfc0611496 passed in Build #133119! 🎉


🧪   To try this PR locally:

bunx bun-pr 46986

That installs a local version of the PR into your bun-46986 executable, so you can run:

bun-46986 --bun

@robobun

robobun commented Oct 26, 2026

Copy link
Copy Markdown
Collaborator Author

Reproduced on bun 1.4.2 and on main (449877f):

console.table([{ a: 1, b: 2 }], [Symbol("col"), "a"]);
// TypeError: Cannot convert a symbol to a string

Node v26.3.0 prints the table with the one column a. This PR replaces #36150.

@coderabbitai

coderabbitai Bot commented Oct 26, 2026

Copy link
Copy Markdown
Contributor

Caution

CodeRabbit couldn't post its review summary.

Error details
HttpError

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline findings, I also checked whether the new Dead out-param sentinel in Bun__ConsoleObject__tableColumnName could be confused with a failed string conversion: Bun::toStringRef returns Dead only when toWTFString yields a null string, which happens only with a pending exception, and that path is covered by the RETURN_IF_EXCEPTION immediately after it, so a non-Symbol column cannot be silently dropped. The toPrimitive call that can run user JS is also exception-checked before its result is used, and the WTFStringImpl ref leaked by toStringRef is owned by the Rust BunString pushed into Column, matching the previous to_bun_string ownership.

Extended reasoning...

The change replaces a ToString coercion of console.table properties entries in src/jsc/ConsoleObject.rs with a new C++ export that runs ToPrimitive(PreferString) and reports a Symbol result via a Dead BunString so the column is skipped, plus a new test block in test/js/bun/console/console-table.test.ts. It touches no security-sensitive surface. Two confirmed Node-compat divergences are posted inline, so a human should weigh those; this note only records what else was examined in the new FFI helper.

Comment thread src/jsc/ConsoleObject.rs Resolved
Comment thread src/jsc/ConsoleObject.rs Resolved
The control for entries with a string key listed `1n` and `-0` after
other names. Node prints integer-like keys first. With them first and
ascending, the expected header is the same under both orders.
@coderabbitai

coderabbitai Bot commented Oct 26, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 14 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

Learn how review limits work.

Review configuration:

Run configuration
Commits

Reviewing files that changed from the base of the PR and between 449877f and f5fc979.

Files selected for processing: 3
  • src/jsc/ConsoleObject.rs
  • src/jsc/bindings/ConsoleObject.cpp
  • test/js/bun/console/console-table.test.ts
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — small, self-contained fix, and the follow-up commit reworks the control test so it no longer pins a column order Node does not print.

What was reviewed:

  • src/jsc/bindings/ConsoleObject.cpp: ToPrimitive(PreferString) then the Symbol check matches ToPropertyKey; RETURN_IF_EXCEPTION follows both the ToPrimitive and the toStringRef call under one throw scope, and *name is written on every true path.
  • src/jsc/ConsoleObject.rs: name starts as BunString::DEAD, the FFI result is ?-propagated before name is read, the Dead case is skipped with nothing to release, and the +1 WTF ref from Bun::toStringRef moves into Column on the push path — no leak on the error or skip paths.
  • Non-Symbol entries keep the old ToString result (ToString(obj) is ToString(ToPrimitive(obj, string))), which the string-hint, coerced-once and still-throws controls pin.
Extended reasoning...

The change adds one false_is_throw C++ export and replaces a single to_bun_string call in TablePrinter::print_table with it, plus 18 tests in test/js/bun/console/console-table.test.ts. It touches no security-sensitive surface; the native code is a column-name coercion for console.table / Bun.inspect.table. Exception checks, out-param writes and BunString ownership across the FFI boundary were traced and are balanced on the success, skip and error paths. Since the prior review the author pushed one test-only commit addressing the column-order control I had flagged; no CHANGES_REQUESTED reviews are open, and no CODEOWNERS entry covers the changed files.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants