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 <noreply@anthropic.com>
This commit is contained in:
Malte Ubl
2026-10-23 00:37:50 -07:00
co-authored by Claude Opus 5.5
parent a9b551a091
commit fdcee191da
4 changed files with 86 additions and 1 deletions
@@ -1229,6 +1229,7 @@ export class ClassDynamicDispatch {
}
}
}
let live: Map<string, Extract<IrStmt, { kind: "fieldSet" }>[]> | 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<string, Extract<IrStmt, { kind: "fieldSet" }>[]> {
const live = new Map<string, Extract<IrStmt, { kind: "fieldSet" }>[]>();
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,
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7733-property-bag-late-prototype.js"
],
"diags": []
}
@@ -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(","));
@@ -0,0 +1 @@
{ "plain": 4400, "san": 3900 }