Compare commits

...

2 Commits

Author SHA1 Message Date
Michael H
40ec833462 Merge branch 'main' into claude/fix-decorator-circular-deps 2025-11-12 00:39:29 +11:00
Claude Bot
507c83e9f7 Fix decorator metadata TDZ errors in circular imports (#17056)
This fixes ReferenceError: Cannot access uninitialized variable errors
that occur when using TypeScript decorators with emitDecoratorMetadata
in files with circular dependencies.

## Problem

When two modules have a circular dependency and both use decorators with
emitDecoratorMetadata, the decorator metadata would try to reference an
imported class before it was initialized, causing a TDZ error:

```typescript
// entity-a.ts
import { EntityB } from "./entity-b";
@Entity()
export class EntityA {
  @Field
  value: string;
}

// entity-b.ts
import { EntityA } from "./entity-a";
@Entity()
export class EntityB {
  @Field
  reference: EntityA | null;  // ReferenceError here!
}
```

## Solution

Wrap imported type references in arrow functions when emitting decorator
metadata. This defers the type evaluation until the function is called,
avoiding TDZ errors. The metadata now contains `() => typeof X === "undefined" ? Object : X`
instead of direct references to imported types.

This is similar to how TypeORM and other ORMs handle circular dependencies
by accepting type functions: `@ManyToOne(() => User)`.

## Changes

- Modified `serializeMetadata()` in src/ast/P.zig to wrap imported
  identifiers in arrow functions
- Added helper `wrapMetadataInArrow()` to generate the wrapper functions
- Updated test to handle the new function-wrapped metadata format
- Added regression tests for circular dependency scenarios

## Trade-offs

This is a pragmatic fix that changes the metadata format for imported types.
Code that reads decorator metadata for imported types will need to check if
the value is a function and call it if so. This is already the pattern used
by popular frameworks like TypeORM.

Fixes #17056

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-11 12:59:17 +00:00
3 changed files with 193 additions and 7 deletions

View File

@@ -5138,6 +5138,25 @@ pub fn NewParser_(
}
}
fn wrapMetadataInArrow(noalias p: *P, inner_expr: Expr) Expr {
// Wraps an expression in an arrow function: () => expr
// This defers evaluation to avoid TDZ issues with circular imports.
// The function can be called later when needed, after modules are fully initialized.
return p.newExpr(
E.Arrow{
.args = &.{},
.body = .{
.loc = logger.Loc.Empty,
.stmts = p.allocator.dupe(Stmt, &.{
p.s(S.Return{ .value = inner_expr }, logger.Loc.Empty),
}) catch |err| bun.handleOom(err),
},
.prefer_expr = true,
},
logger.Loc.Empty,
);
}
fn serializeMetadata(noalias p: *P, ts_metadata: TypeScript.Metadata) !Expr {
return switch (ts_metadata) {
.m_none,
@@ -5219,12 +5238,15 @@ pub fn NewParser_(
.m_identifier => |ref| {
p.recordUsage(ref);
if (p.is_import_item.contains(ref)) {
return p.maybeDefinedHelper(p.newExpr(
// For imported types, wrap in an IIFE that uses typeof to check if defined
// This avoids TDZ errors in circular dependencies
const import_ref = p.newExpr(
E.ImportIdentifier{
.ref = ref,
},
logger.Loc.Empty,
));
);
return p.wrapMetadataInArrow(try p.maybeDefinedHelper(import_ref));
}
return p.maybeDefinedHelper(p.newExpr(
@@ -5325,6 +5347,11 @@ pub fn NewParser_(
logger.Loc.Empty,
);
// Wrap in arrow function if it starts with an import to avoid TDZ
if (p.is_import_item.contains(refs.items[0])) {
return p.wrapMetadataInArrow(root);
}
return root;
},
};

View File

@@ -1232,17 +1232,21 @@ describe("bundler", () => {
files: {
"/entry.ts": /* ts */ `
${reflectMetadata}
import { Foo } from "./foo.js";
function d1() {}
@d1
class Bar {
constructor(foo: Foo) {}
}
console.log(Reflect.getMetadata("design:paramtypes", Bar)[0] === Foo);
// Imported types in metadata are wrapped in functions to avoid TDZ in circular imports
// So we need to call the function to get the actual type
const paramType = Reflect.getMetadata("design:paramtypes", Bar)[0];
const actualType = typeof paramType === "function" ? paramType() : paramType;
console.log(actualType === Foo);
`,
"/foo.js": /* js */ `
const f = () => "Foo";

View File

@@ -0,0 +1,155 @@
// https://github.com/oven-sh/bun/issues/17056
// Circular Dependency Causes Uninitialized Error When Using Decorators
import { expect, test } from "bun:test";
import { bunEnv, bunExe, tempDir } from "harness";
test("decorator metadata with circular imports should not cause TDZ error", async () => {
using dir = tempDir("issue17056", {
"tsconfig.json": JSON.stringify({
compilerOptions: {
experimentalDecorators: true,
emitDecoratorMetadata: true,
target: "ES2022",
module: "ESNext",
},
}),
"entity-a.ts": `
import { EntityB } from "./entity-b";
function Entity() {
return function (target: any) {};
}
function Field(target: any, propertyKey: string) {}
@Entity()
export class EntityA {
@Field
value: string = "a";
}
`,
"entity-b.ts": `
import { EntityA } from "./entity-a";
function Entity() {
return function (target: any) {};
}
function Field(target: any, propertyKey: string) {}
@Entity()
export class EntityB {
@Field
reference: EntityA | null = null;
}
`,
"index.ts": `
import { EntityA } from "./entity-a";
import { EntityB } from "./entity-b";
const a = new EntityA();
const b = new EntityB();
console.log("EntityA:", a.value);
console.log("EntityB reference:", b.reference);
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "index.ts"],
env: bunEnv,
cwd: String(dir),
stderr: "pipe",
stdout: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).not.toContain("ReferenceError");
expect(stderr).not.toContain("Cannot access");
expect(stderr).not.toContain("before initialization");
expect(stdout).toContain("EntityA: a");
expect(stdout).toContain("EntityB reference: null");
expect(exitCode).toBe(0);
});
test("decorator metadata with circular imports in separate files", async () => {
using dir = tempDir("issue17056-cross-file", {
"tsconfig.json": JSON.stringify({
compilerOptions: {
experimentalDecorators: true,
emitDecoratorMetadata: true,
target: "ES2022",
module: "ESNext",
},
}),
"base-entity.ts": `
export abstract class BaseEntity {
id: number = 0;
}
`,
"user.ts": `
import { BaseEntity } from "./base-entity";
import { Post } from "./post";
function Entity() {
return function (target: any) {};
}
function OneToMany(props: any) {
return function (target: any, propertyKey: string) {};
}
@Entity()
export class User extends BaseEntity {
@OneToMany({ target: () => Post })
posts: Post[] = [];
}
`,
"post.ts": `
import { BaseEntity } from "./base-entity";
import { User } from "./user";
function Entity() {
return function (target: any) {};
}
function ManyToOne(props: any) {
return function (target: any, propertyKey: string) {};
}
@Entity()
export class Post extends BaseEntity {
@ManyToOne({ target: () => User })
author: User | null = null;
}
`,
"index.ts": `
import { User } from "./user";
import { Post } from "./post";
const user = new User();
const post = new Post();
console.log("User created with", user.posts.length, "posts");
console.log("Post author:", post.author);
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "index.ts"],
env: bunEnv,
cwd: String(dir),
stderr: "pipe",
stdout: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).not.toContain("ReferenceError");
expect(stderr).not.toContain("Cannot access");
expect(stderr).not.toContain("before initialization");
expect(stdout).toContain("User created with 0 posts");
expect(stdout).toContain("Post author: null");
expect(exitCode).toBe(0);
});