From fdcee191da193bbaed9f78d36aad1b70d29bb96a Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Thu, 22 Oct 2026 22:40:27 -0700 Subject: [PATCH] Give rebuilt property-bag stores their prototype Worklist passes that transform every function (the class-construction dispatch rewriting dyn.classBasePrototype) rebuild statement objects, so a recorded lazy bag store could be a detached copy. When the class gained prototype data later, only the copy was upgraded and the live store kept creating a bare {} bag: inherited methods read as undefined through the property bag (hono's SmartRouter.match once zod was in the program). Upgrades now also reach the stores as they stand in the generated bodies. Co-Authored-By: Claude Opus 5.5 --- .../lowering/class-dynamic-dispatch.ts | 28 +++++++++- .../7733-property-bag-late-prototype.js.json | 6 +++ .../7733-property-bag-late-prototype.js | 52 +++++++++++++++++++ .../7733-property-bag-late-prototype.js.json | 1 + 4 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7733-property-bag-late-prototype.js.json create mode 100644 tests/corpus/7733-property-bag-late-prototype.js create mode 100644 tests/harness/corpus-costs/7733-property-bag-late-prototype.js.json diff --git a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts index e394718d1..6548d33d2 100644 --- a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts +++ b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts @@ -1229,6 +1229,7 @@ export class ClassDynamicDispatch { } } } + let live: Map[]> | null = null; for (const plan of this.propertyReceivers(lowerer, false, false)) { const info = plan.info; const className = info.def.name; @@ -1309,7 +1310,11 @@ export class ClassDynamicDispatch { ); const prototypeFor = (current: ClassInfo, receiver: IrExpr, loc: SrcLoc): IrExpr | null => this.bagPrototype(lowerer, current, receiver, loc); - for (const existingInit of this.bagInitializers.get(className) ?? []) { + live ??= this.liveBagInitializers(); + for (const existingInit of [ + ...(this.bagInitializers.get(className) ?? []), + ...(live.get(className) ?? []), + ]) { if (existingInit.value.kind === "dynObjLit" && needsPrototype(info)) { const prototype = prototypeFor(info, existingInit.obj, existingInit.loc); if (prototype) { @@ -4440,6 +4445,27 @@ export class ClassDynamicDispatch { * A class with constructible subclasses that have prototype data selects * it through its `%class.bagPrototype` helper; any other class reads its * own prototype data. */ + /** The lazy bag stores as they stand in the generated bodies, by class. + * Worklist passes that transform every function rebuild statement + * objects, so a recorded initializer can be a detached copy; a store that + * gains its prototype must be updated where it runs. */ + private liveBagInitializers(): Map[]> { + const live = new Map[]>(); + const add = (stmt: IrStmt | undefined): void => { + if (stmt?.kind !== "if") return; + const store = stmt.then[0]; + if (store?.kind !== "fieldSet" || store.field !== PROPERTY_BAG) return; + const list = live.get(store.className); + if (list) list.push(store); + else live.set(store.className, [store]); + }; + // The complete dispatch guards each class's lazy store by its receiver. + for (const stmt of this.propertyBag?.body ?? []) if (stmt.kind === "if") add(stmt.then[0]); + for (const helper of this.typedPropertyBags.values()) add(helper.body[0]); + for (const helper of this.exactPropertyBags.values()) add(helper.body[0]); + return live; + } + private bagPrototype( lowerer: Lowerer, current: ClassInfo, diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7733-property-bag-late-prototype.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7733-property-bag-late-prototype.js.json new file mode 100644 index 000000000..2b9778666 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7733-property-bag-late-prototype.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7733-property-bag-late-prototype.js" + ], + "diags": [] +} diff --git a/tests/corpus/7733-property-bag-late-prototype.js b/tests/corpus/7733-property-bag-late-prototype.js new file mode 100644 index 000000000..f88212b62 --- /dev/null +++ b/tests/corpus/7733-property-bag-late-prototype.js @@ -0,0 +1,52 @@ +// A class whose property bag is first created without prototype data, while a local +// class with an untyped base rewrites the shared bag dispatch: the store gains the +// prototype later, so inherited methods stay readable (hono's SmartRouter with zod). +function $constructor(name, initializer, params) { + const Parent = params?.Parent ?? Object; + class Definition extends Parent {} + Object.defineProperty(Definition, "name", { value: name }); + function _(def) { + const inst = params?.Parent ? new Definition() : this; + inst.def = def; + initializer(inst, def); + return inst; + } + Object.defineProperty(_, "name", { value: name }); + return _; +} +class Smart { + name = "S"; + #inner = []; + constructor(init) { this.#inner = init.routers; } + add(x) { this.#inner.push(x); } + match(m) { + const r = this.#inner[0]; + this.match = r.match.bind(r); + this.name = `S + ${r.name}`; + return "first " + m; + } +} +class Inner { name = "Inner"; match(m) { return "inner " + m; } } +class App { + router; + constructor(options = {}) { + const { strict, ...rest } = options; + Object.assign(this, rest); + this.router = options.router ?? new Smart({ routers: [new Inner()] }); + } + dispatch(m) { return this.router.match(m); } +} +const app = new App(); +console.log(Object.keys(app.router).join(",")); +const $Err = $constructor("$Err", (inst, def) => { inst.issues = def.issues; }, { Parent: Error }); +const e = new $Err({ issues: [1] }); +console.log(Object.keys(e).join(","), e.issues.length); +const run = [(a) => a.dispatch("a"), (a) => a.dispatch("b")]; +for (const f of run) console.log(f(app), app.router.name); +const holder = JSON.parse("{}"); +holder.r = new Smart({ routers: [new Inner()] }); +console.log(typeof holder.r.match, "match" in holder.r, Object.keys(holder.r).join(",")); +console.log(holder.r.match("c"), holder.r.match("d"), holder.r.name); +const scratch = JSON.parse("{\"name\":\"x\",\"other\":1}"); +delete scratch.name; +console.log(Object.keys(scratch).join(",")); diff --git a/tests/harness/corpus-costs/7733-property-bag-late-prototype.js.json b/tests/harness/corpus-costs/7733-property-bag-late-prototype.js.json new file mode 100644 index 000000000..706d1a2a5 --- /dev/null +++ b/tests/harness/corpus-costs/7733-property-bag-late-prototype.js.json @@ -0,0 +1 @@ +{ "plain": 4400, "san": 3900 }