Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 19 additions & 7 deletions src/bun.js/ConsoleObject.zig
Original file line number Diff line number Diff line change
Expand Up @@ -2326,16 +2326,28 @@ pub const Formatter = struct {
);
},
.Class => {
var printable = ZigString.init(&name_buf);
try value.getClassName(this.globalThis, &printable);
this.addForNewLine(printable.len);
// Prefer the constructor's own `.name` property over
// `getClassName` / `calculatedClassName`. For DOM / WebCore
// InternalFunction constructors like `ReadableStreamBYOBReader`,
// `calculatedClassName` walks the prototype chain and hits
// `Function.prototype.constructor === Function`, returning
// "Function". The `.name` property is set to the real class
// name on the constructor itself. See #29225.
var printable = try value.getName(this.globalThis);
defer printable.deref();
this.addForNewLine(printable.length());

// Only report `extends` when the parent is itself a class
// (i.e. `class Foo extends Bar`). Built-in and DOM constructors
// have `Function.prototype` as their prototype, which would
// render as `[class X extends Function]` and is noise.
const proto = value.getPrototype(this.globalThis);
var printable_proto = ZigString.init(&name_buf);
try proto.getClassName(this.globalThis, &printable_proto);
this.addForNewLine(printable_proto.len);
const proto_is_class = !proto.isEmptyOrUndefinedOrNull() and proto.isCell() and proto.isClass(this.globalThis);
var printable_proto: bun.String = if (proto_is_class) try proto.getName(this.globalThis) else bun.String.empty;
Comment on lines +2345 to +2346
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.

🟡 The new proto_is_class check silently drops the extends clause when a class extends a regular function (not a class-keyword constructor): class Derived extends someRegularFunction {} now renders as [class Derived] instead of [class Derived extends someRegularFunction]. While the old behavior was also wrong (it showed [class Derived extends Function]), Node.js correctly shows the actual base name; a more precise fix would check proto === Function.prototype instead of proto.isClass(), which would suppress the noisy 'extends Function' case for DOM/built-ins while preserving the extends name for plain-function bases.

Extended reasoning...

What the bug is and how it manifests

In ConsoleObject.zig at lines 2345-2346, the new code computes:

const proto_is_class = \!proto.isEmptyOrUndefinedOrNull() and proto.isCell() and proto.isClass(this.globalThis);
var printable_proto: bun.String = if (proto_is_class) try proto.getName(this.globalThis) else bun.String.empty;

The isClass() C++ implementation checks isClassConstructorFunction() for JS functions. Regular JS functions (declared with the function keyword) return false from isClassConstructorFunction(), so proto_is_class is false, printable_proto is set to bun.String.empty, and the extends clause is omitted entirely.

The specific code path that triggers it

  1. User writes class Derived extends regularFunction {} where regularFunction = function() {}.
  2. proto = Derived.getPrototype(globalThis) → the regular function regularFunction.
  3. proto.isClass() → calls isClassConstructorFunction() → returns false.
  4. printable_proto is assigned bun.String.empty.
  5. Output: [class Derived] — extends clause silently dropped.
  6. Node.js output: [class Derived extends regularFunction] — correct.

Why existing code does not prevent it

The code comment explicitly documents this as an intentional tradeoff: 'Only report extends when the parent is itself a class — otherwise built-in and DOM constructors have Function.prototype as their prototype, which would render as [class X extends Function] and is noise.' There are no tests for the edge case of extending a plain function. The old behavior was wrong too (calculatedClassName walked the chain and returned "Function", not the base name), but at least it preserved the existence of inheritance.

Addressing the refutation

The refutation correctly notes that the old behavior was also wrong (showing [class Derived extends Function] instead of the actual base name) and that the new behavior is arguably less misleading. Both behaviors diverge from Node.js. However, the new behavior is a regression in a specific dimension: it loses all inheritance information rather than showing an incorrect name. A developer inspecting code where class Derived extends someFactory() would see no indication that inheritance is present at all, which is harder to debug than a wrong name.

What the impact would be

This is cosmetic only. Functional behavior (instanceof, prototype chain, subclassing) is unaffected. The edge case of extending a plain function with class syntax is uncommon in modern JS. Severity is nit.

How to fix it

Rather than proto.isClass(), check whether proto is exactly Function.prototype. This correctly suppresses the noisy 'extends Function' for DOM/built-in constructors (whose prototype chain leads to Function.prototype) while still showing the extends name for plain-function bases — matching Node.js behavior for this edge case.

Step-by-step proof

function Animal(name) { this.name = name; }
class Dog extends Animal {}
console.log(Bun.inspect(Dog));
// Current (after PR): [class Dog]
// Node.js:            [class Dog extends Animal]
// Before this PR:     [class Dog extends Function]  // wrong name but shows inheritance exists

No test in the PR suite covers this pattern; all class-extends tests use class-keyword bases (class Foo, class Bar extends Foo), which pass the isClass() check correctly.

defer printable_proto.deref();
this.addForNewLine(printable_proto.length());

if (printable.len == 0) {
if (printable.isEmpty()) {
if (printable_proto.isEmpty()) {
writer.print(comptime Output.prettyFmt("<cyan>[class (anonymous)]<r>", enable_ansi_colors), .{});
} else {
Expand Down
137 changes: 137 additions & 0 deletions test/regression/issue/29225.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
// https://github.com/oven-sh/bun/issues/29225

import { expect, test } from "bun:test";
import { bunEnv, bunExe } from "harness";
import { ReadableStreamBYOBReader } from "node:stream/web";

const streamWebClasses = [
"ByteLengthQueuingStrategy",
"CompressionStream",
"CountQueuingStrategy",
"DecompressionStream",
"ReadableByteStreamController",
"ReadableStream",
"ReadableStreamBYOBReader",
"ReadableStreamBYOBRequest",
"ReadableStreamDefaultController",
"ReadableStreamDefaultReader",
"TextDecoderStream",
"TextEncoderStream",
"TransformStream",
"TransformStreamDefaultController",
"WritableStream",
"WritableStreamDefaultController",
"WritableStreamDefaultWriter",
];

test.concurrent("node:stream/web classes inspect as [class X], not [class Function]", async () => {
const source = `
const sw = require("node:stream/web");
const names = ${JSON.stringify(streamWebClasses)};
for (const name of names) {
const klass = sw[name];
if (typeof klass !== "function") {
console.log(name + ": MISSING");
continue;
}
// Bun.inspect() uses the same formatter as console.log.
console.log(name + ": " + Bun.inspect(klass));
}
`;

await using proc = Bun.spawn({
cmd: [bunExe(), "-e", source],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});

const [stdout, _stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);

const lines = stdout.trim().split("\n");
expect(lines.length).toBe(streamWebClasses.length);

for (let i = 0; i < streamWebClasses.length; i++) {
const name = streamWebClasses[i];
// Must not be "MISSING" (sanity check for the test itself) and
// must report the real class name, not "Function".
expect(lines[i]).not.toContain("MISSING");
expect(lines[i]).toBe(`${name}: [class ${name}]`);
}
expect(exitCode).toBe(0);
});

test.concurrent("other DOM / WebCore constructors inspect as [class X]", async () => {
// Sanity: the inspect formatter should work for any `isConstructor`
// InternalFunction exposed as a global. Keep this list small — it's
// a regression guard, not an audit.
const code = `
console.log("URL: " + Bun.inspect(URL));
console.log("Request: " + Bun.inspect(Request));
console.log("Response: " + Bun.inspect(Response));
console.log("Blob: " + Bun.inspect(Blob));
console.log("Event: " + Bun.inspect(Event));
`;

await using proc = Bun.spawn({
cmd: [bunExe(), "-e", code],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});

const [stdout, _stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);

expect(stdout).toBe(
"URL: [class URL]\n" +
"Request: [class Request]\n" +
"Response: [class Response]\n" +
"Blob: [class Blob]\n" +
"Event: [class Event]\n",
);
expect(exitCode).toBe(0);
});

test.concurrent("user-defined classes and extends still render correctly", async () => {
const code = `
class Foo {}
class Bar extends Foo {}
const Anon = class {};

console.log("Foo: " + Bun.inspect(Foo));
console.log("Bar: " + Bun.inspect(Bar));
console.log("Anon: " + Bun.inspect(Anon));
`;

await using proc = Bun.spawn({
cmd: [bunExe(), "-e", code],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});

const [stdout, _stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);

// `Anon` picks up the "Anon" name from the variable binding, matching
// JSC's naming inference for `const Anon = class {};`.
expect(stdout).toBe("Foo: [class Foo]\n" + "Bar: [class Bar extends Foo]\n" + "Anon: [class Anon]\n");
expect(exitCode).toBe(0);
});

test.concurrent("instanceof and prototype identity still work", async () => {
// Functional behavior must not regress — the fix is cosmetic only.
const stream = new ReadableStream({
type: "bytes",
start(c) {
c.enqueue(new Uint8Array([1, 2, 3]));
c.close();
},
});
const reader = stream.getReader({ mode: "byob" });
expect(reader).toBeInstanceOf(ReadableStreamBYOBReader);
expect(Object.getPrototypeOf(reader)).toBe(ReadableStreamBYOBReader.prototype);
reader.releaseLock();

class Sub extends ReadableStreamBYOBReader {}
expect(Object.getPrototypeOf(Sub.prototype)).toBe(ReadableStreamBYOBReader.prototype);
});
Loading