Answer constructor reads on instances without a property bag

The constructor read asked dyn.hasOwn on the instance's property bag, which
is undefined until the instance has one, and on the bag's prototype, so it
threw "Cannot convert undefined or null to object" for an instance without
own properties (for example `new ParseFailure('p').constructor` for a class
that extends SyntaxError). It now goes through a lifted helper that answers
false before the instance has a bag and when the bag has no prototype, as
the dyn.hasKey read did.
This commit is contained in:
Chris Tate
2026-10-23 21:57:25 -05:00
parent 03807c0c35
commit 2a98dbfa32
2 changed files with 134 additions and 1 deletions
@@ -3966,6 +3966,94 @@ export class ClassDynamicDispatch {
return { kind: "call", callee: name, args: [bag, key], type: BOOL, loc };
}
/** `%dyn.class.bagHasOwn[OrPrototype]`: whether an instance's property bag
* holds `key` as its own property, or, with `prototype`, whether the bag's
* prototype object does. False before the instance has a bag. */
private readBagHasOwn(
lowerer: Lowerer,
bag: IrExpr,
key: IrExpr,
prototype: boolean,
loc: SrcLoc,
): IrExpr {
const name = prototype ? "%dyn.class.bagHasOwnOrPrototype" : "%dyn.class.bagHasOwn";
let helper = lowerer.liftedFnNamed(name);
if (!helper) {
const value = varRef("p.0", DYN, loc);
const keyRef = varRef("p.1", STRING, loc);
const proto = varRef("proto", DYN, loc);
const hasOwn = (target: IrExpr): IrExpr => ({
kind: "libCall",
fn: "dyn.hasOwn",
args: [target, keyRef],
type: BOOL,
loc,
});
const returnFalse: IrStmt = {
kind: "return",
value: { kind: "boolLit", value: false, type: BOOL, loc },
loc,
};
helper = {
name,
params: [
{ localId: "p.0", name: "bag", type: DYN },
{ localId: "p.1", name: "key", type: STRING },
],
returnType: BOOL,
locals: [
{ id: "p.0", name: "bag", type: DYN, mutable: false },
{ id: "p.1", name: "key", type: STRING, mutable: false },
...(prototype ? [{ id: "proto", name: "proto", type: DYN, mutable: false }] : []),
],
body: [
{
kind: "if",
cond: { kind: "dynTest", test: "undefined", value, type: BOOL, loc },
then: [returnFalse],
else_: null,
loc,
},
...(prototype
? ([
{
kind: "if",
cond: hasOwn(value),
then: [
{
kind: "return",
value: { kind: "boolLit", value: true, type: BOOL, loc },
loc,
},
],
else_: null,
loc,
},
{
kind: "varDecl",
localId: "proto",
init: { kind: "libCall", fn: "dyn.getPrototype", args: [value], type: DYN, loc },
loc,
},
{
kind: "if",
cond: { kind: "dynTest", test: "nullish", value: proto, type: BOOL, loc },
then: [returnFalse],
else_: null,
loc,
},
{ kind: "return", value: hasOwn(proto), loc },
] satisfies IrStmt[])
: [{ kind: "return", value: hasOwn(value), loc } satisfies IrStmt]),
],
loc,
};
lowerer.liftedFns.push(helper);
}
this.generated.add(helper);
return { kind: "call", callee: name, args: [bag, key], type: BOOL, loc };
}
/** The hints for which some boxed class declares a conversion method. A
* string conversion calls toString first, so without a declared toString
* or Symbol.toPrimitive it answers Object.prototype.toString's
@@ -5633,12 +5721,18 @@ export class ClassDynamicDispatch {
const cls = lowerer.coerceToExpected(classValueRef(lowerer, info, info.decl), DYN);
const bag = this.branchPropertyBag(lowerer, info, receiver, false, loc);
const key: IrExpr = { kind: "strLit", value: name, type: STRING, loc };
// `constructor` is an own property of every class prototype, so an
// ancestor's prototype never answers for an instance of this class:
// the instance's own property answers, then one this class's
// prototype object defines (a reflected prototype, or a write to
// `C.prototype.constructor`), and otherwise the class itself.
const answered = this.readBagHasOwn(lowerer, bag, key, hasClassPrototypeData(info), loc);
return [
{
kind: "return",
value: {
kind: "ternary",
cond: this.readBagHas(lowerer, "dyn.hasKey", bag, key, loc),
cond: answered,
then: {
kind: "libCall",
fn: "dyn.bagGet",
@@ -128,3 +128,42 @@ report("square both", () => Square.prototype.both.call(foreign));
report("tiny describe", () => Tiny.prototype.describe.call(foreign));
report("tiny tag", () => Tiny.prototype.tag.call(foreign));
report("square describe on tiny", () => Square.prototype.describe.call(tiny));
// A base method that builds `new this.constructor()` on a subclass instance
// builds the subclass, also once the base prototype is complete (a super call
// above reads it) and `copy` is read through its prototype slot.
class Node3 {
constructor() {
this.a = 0;
}
copy(source) {
this.a = source.a;
return this;
}
clone() {
return new this.constructor().copy(this);
}
}
class Mesh3 extends Node3 {
constructor(geometry) {
super();
this.geometry = geometry;
}
copy(source) {
super.copy(source);
this.geometry = source.geometry;
return this;
}
}
const copyValue = Node3.prototype.copy;
const mesh = new Mesh3({ id: 1 });
mesh.a = 5;
const meshClone = mesh.clone();
console.log(
meshClone instanceof Mesh3,
meshClone.constructor === Mesh3,
mesh.constructor === Mesh3,
meshClone.a,
JSON.stringify(meshClone.geometry),
typeof copyValue,
);