From 03807c0c3574d395c53905aa4b8983ef8ec11d09 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 23 Oct 2026 04:11:54 -0500 Subject: [PATCH 1/3] Run private members and super calls over foreign receivers like Node A JavaScript class's prototype method called with a receiver that is not an instance of its class (`C.prototype.m.call(other)`, or an object that inherits the method) now behaves as in Node when it uses private members or calls super methods, instead of throwing "expected C" for the receiver. - Private fields keep their brand check: reading or writing `this.#x` throws "Cannot read private member #x from an object whose class did not declare it" (or the write message), and `#x in this` answers false. - `this.#m()` brand-checks the receiver through a new `call` use of the private brand helpers and throws "Receiver must be an instance of class C" (V8's wording: the class's own identifier, "anonymous" without one, and "Cannot read properties of undefined (reading 'C')" for a nullish receiver). It also applies to private method calls on other checked-dynamic values. - `super.m(...)` looks the method up on the base class's complete prototype object and calls it with the receiver, as `super` reads [[HomeObject]].[[Prototype]]. Methods that use `super` in other positions, static methods with private names, and classes whose base chain includes a function-local, generic, mixin, Date or runtime-provided class keep the native receiver check. The receiver-independent method lowering and the prototype reflection now mark their frames as `this` boundaries, so lowering one class's members on top of another method's frames (a super call reflecting the base) never reaches the caller's `this`. --- .../frontend/lowering/class-method-values.ts | 50 ++++++- .../src/frontend/lowering/class-prototypes.ts | 3 + .../src/frontend/lowering/lower-calls.ts | 44 ++++++ .../src/frontend/lowering/lower-classes.ts | 14 +- .../src/frontend/lowering/private-brands.ts | 50 ++++++- ...1-class-methods-foreign-receivers.mjs.json | 6 + .../7621-class-methods-foreign-receivers.mjs | 130 ++++++++++++++++++ ...1-class-methods-foreign-receivers.mjs.json | 1 + 8 files changed, 292 insertions(+), 6 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7621-class-methods-foreign-receivers.mjs.json create mode 100644 tests/corpus/7621-class-methods-foreign-receivers.mjs create mode 100644 tests/harness/corpus-costs/7621-class-methods-foreign-receivers.mjs.json diff --git a/packages/compiler/src/frontend/lowering/class-method-values.ts b/packages/compiler/src/frontend/lowering/class-method-values.ts index 6e8256513..af2ef51d5 100644 --- a/packages/compiler/src/frontend/lowering/class-method-values.ts +++ b/packages/compiler/src/frontend/lowering/class-method-values.ts @@ -378,9 +378,17 @@ function reflectedMethodValue( !isJsSourceFile(ts.getSourceFileOfNode(declaration)) || info.localClass !== undefined || lowerer.errorHierarchyClassOf(info.def.name); + // `super.m(...)` in a prototype method reads the base class's prototype, + // which lowerSuperMethodCall answers over the call-site receiver when the + // base has a prototype object. Other uses of `super` keep the native + // receiver. + const superCalls = !staticMethod && info.base !== null && prototypeChainViewable(info.base); ts.walkPreorder(member, (node) => { - if (ts.isPrivateIdentifier(node) || node.kind === ts.SyntaxKind.SuperKeyword) + if (node.kind === ts.SyntaxKind.SuperKeyword && !(superCalls && isSuperMethodCallee(node))) needsNativeReceiver = true; + // Private members on a static method's receiver (the class value) keep + // the native receiver; prototype methods brand-check theirs. + if (staticMethod && ts.isPrivateIdentifier(node)) needsNativeReceiver = true; }); if (needsNativeReceiver) return null; // The method reads itself as a value inside its own body: it is the @@ -403,7 +411,11 @@ function reflectedMethodValue( loc: locOf(member), }; } + // The method may be lowered on top of an unrelated caller's frames (a + // `super` call in another class's method reflects this class): its `this` + // is its own receiver, never an enclosing function's. const context = newFnCtx(false, null, null, DYN); + context.thisBoundary = true; const previousClass = lowerer.currentClass; lowerer.currentClass = info; lowerer.fnStack.push(context); @@ -441,6 +453,42 @@ function reflectedMethodValue( } } +/** Whether `base` and every class above it have a complete prototype object + * (reflectClassPrototype): module-level program classes that neither extend + * Date nor a runtime-provided class. */ +function prototypeChainViewable(base: ClassInfo): boolean { + for (let c: ClassInfo | null = base; c; c = c.base) + if ( + c.decl === null || + c.localClass !== undefined || + c.generic !== undefined || + c.genericInstance !== undefined || + c.mixinInstance !== undefined || + c.callableBase !== undefined || + c.extendsDate !== undefined || + c.def.runtime || + c.builtinEmitter || + c.builtinStream || + c.builtinError || + c.builtinEventTarget + ) + return false; + return true; +} + +/** `super` as the receiver of a called member: `super.m(...)`. */ +function isSuperMethodCallee(node: ts.Node): boolean { + const access = node.parent; + return ( + access !== undefined && + ts.isPropertyAccessExpression(access) && + access.expression === node && + access.parent !== undefined && + ts.isCallExpression(access.parent) && + access.parent.expression === access + ); +} + /** Complete adapters after all native class bodies and instantiations exist. */ export function finalizeClassMethodValues(lowerer: Lowerer, functions: IrFunction[]): void { const byName = new Map(functions.map((fn) => [fn.name, fn])); diff --git a/packages/compiler/src/frontend/lowering/class-prototypes.ts b/packages/compiler/src/frontend/lowering/class-prototypes.ts index cb2ab2280..116150465 100644 --- a/packages/compiler/src/frontend/lowering/class-prototypes.ts +++ b/packages/compiler/src/frontend/lowering/class-prototypes.ts @@ -400,7 +400,10 @@ export function reflectClassPrototype( writable, ]), ); + // The members lower on top of whatever frames requested the view (a + // `super` call in a subclass's method); their `this` is their own receiver. const context = newFnCtx(false, null, null, DYN); + context.thisBoundary = true; lowerer.fnStack.push(context); try { for (const member of info.decl.members.nodes) { diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index 82f5a27c1..d80f342c2 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -1,3 +1,4 @@ +import { checkedPrivateMethodReceiver, privateNameClass } from "./private-brands.js"; import { keyedHelperName } from "./stable-names.js"; import { perKeyOrder } from "./key-order-witness.js"; import { isForwardedTupleSpread, lowerForwardingApply } from "./typed-arguments.js"; @@ -547,6 +548,17 @@ export function lowerCall(lowerer: Lowerer, expr: ts.CallExpression): IrExpr { ) { return lowerer.lowerSuperMethodCall(expr, expr.expression); } + // `value.#m(...)` on a checked-dynamic value (`this` in a JavaScript + // class's receiver-independent method): the private method of the + // brand-checked receiver. + if ( + ts.isPropertyAccessExpression(expr.expression) && + ts.isPrivateIdentifier(expr.expression.name) && + !expr.expression.questionDotToken + ) { + const privateCall = lowerCheckedPrivateMethodCall(lowerer, expr, expr.expression); + if (privateCall) return privateCall; + } const consoleMember = lowerer.consoleCallMember(expr); const consoleMethod = consoleMember === null ? lowerer.consoleMethodMember(expr) : null; @@ -11286,3 +11298,35 @@ function lowerShimIntrinsicCall( ); return { kind: "libCall", fn: intrinsic.fn, args, type: intrinsic.ret, loc }; } + +/** `value.#m(args)` where `value` lowers to a checked-dynamic value and the + * declaration-level class whose body declares `#m` declares it as a method: + * the method called on the brand-checked receiver + * (checkedPrivateMethodReceiver). Null for any other private member call. */ +function lowerCheckedPrivateMethodCall( + lowerer: Lowerer, + call: ts.CallExpression, + access: ts.PropertyAccessExpression, +): IrExpr | null { + if (!ts.isPrivateIdentifier(access.name)) return null; + const name = access.name.text; + const owner = privateNameClass(lowerer, access.name); + const found = owner ? lowerer.findMethodOn(owner, name) : null; + if (!owner || !found || found.declarer !== owner || found.sig.abstract === true) return null; + const probed = tryLowerExpression(lowerer, access.expression); + if (!probed || !checkedDynamicOperand(probed)) return null; + const value = checkedDynamicOperand(lowerer.lowerExpr(access.expression)); + if (!value) return null; + const loc = locOf(call); + const callee = `%${owner.def.name}.${name}`; + lowerer.noteEdge(callee); + const receiver = checkedPrivateMethodReceiver(lowerer, owner, name, value, loc); + const args = lowerer.completeArgs(call.arguments.nodes, found.sig.params, loc, call); + return { kind: "call", callee, args: [receiver, ...args], type: found.sig.ret, loc }; +} + +/** A checked-dynamic value, or the dynamic value under a checked extraction. */ +function checkedDynamicOperand(expr: IrExpr): IrExpr | null { + if (expr.type.kind === "dyn") return expr; + return expr.kind === "dynCheck" && expr.value.type.kind === "dyn" ? expr.value : null; +} diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index 29afbb4fe..db8f35f95 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -181,7 +181,7 @@ import { construction, constructionPrefix } from "./js-class-fields.js"; import { lowerObjectFactoryNew } from "./object-factory-new.js"; import { tryLowerExpression } from "./expressions/try-lower-expression.js"; import { lowerInstanceConstructorNew } from "./class-instance-constructor.js"; -import { classPrototypeData } from "./class-prototypes.js"; +import { classPrototypeData, reflectClassPrototype } from "./class-prototypes.js"; import { constructionEscape } from "./construction-escapes.js"; import { checkedClassConstruction, checkedClassConstructionPacked } from "./class-construction.js"; import { initializeRuntimeStatics, hasRuntimeStatics } from "./class-runtime-statics.js"; @@ -9384,8 +9384,16 @@ export function lowerSuperMethodCall( if (!thisLocal) lowerer.unsupported("SC1080", access); const loc = locOf(call); const thisRef: IrExpr = { kind: "varRef", localId: thisLocal.id, type: thisLocal.type, loc }; - if (lowerer.prototypeMethodAccesses.has(access.name.text)) { - const prototype = classPrototypeData(lowerer, cls.base, loc); + // A method whose `this` is the call-site receiver (a JavaScript class's + // receiver-independent method value, reflectedClassMethodValue) looks the + // method up on the base class's complete prototype, as `super` reads + // [[HomeObject]].[[Prototype]], and calls it with that receiver. + const dynamicThis = thisRef.type.kind === "dyn"; + if (dynamicThis || lowerer.prototypeMethodAccesses.has(access.name.text)) { + const prototype = + dynamicThis && cls.base.decl + ? reflectClassPrototype(lowerer, cls.base, loc, classValueRef(lowerer, cls.base, access)) + : classPrototypeData(lowerer, cls.base, loc); if (prototype) { const callee: IrExpr = { kind: "dynKeyGet", diff --git a/packages/compiler/src/frontend/lowering/private-brands.ts b/packages/compiler/src/frontend/lowering/private-brands.ts index c7881bce1..92bee4fdd 100644 --- a/packages/compiler/src/frontend/lowering/private-brands.ts +++ b/packages/compiler/src/frontend/lowering/private-brands.ts @@ -17,8 +17,9 @@ import type { Lowerer } from "./lowerer.js"; import type { ClassInfo } from "./lower-classes.js"; /** How a brand helper uses its receiver: `in` answers `#x in v`, `read` and - * `write` return the receiver as the declaring class for a field access. */ -type BrandUse = "in" | "read" | "write"; + * `write` return the receiver as the declaring class for a field access, and + * `call` for a private method call. */ +type BrandUse = "in" | "read" | "write" | "call"; const PREFIX = "%private."; @@ -214,6 +215,35 @@ export function checkedPrivateValue( return { kind: "call", callee: helper, args: [receiver], type: resultType, loc }; } +/** `value.#m(...)` on a checked-dynamic `value` in a declaration-level class + * `info` that declares the private method `#m`: the receiver as an instance + * of `info` when the value is one, else Node's TypeError ("Receiver must be + * an instance of class C"). */ +export function checkedPrivateMethodReceiver( + lowerer: Lowerer, + info: ClassInfo, + name: string, + receiver: IrExpr, + loc: SrcLoc, +): IrExpr { + const className = info.def.name; + const resultType: IrType = { kind: "object", className }; + const helper = `${PREFIX}call:${className}:${name}`; + if (!lowerer.valueHelpers.has(helper)) { + lowerer.liftedFns.push({ + name: helper, + params: [{ localId: "value.0", name: "value", type: DYN }], + returnType: resultType, + locals: [{ id: "value.0", name: "value", type: DYN, mutable: false }], + // finalizePrivateBrands fills the body. + body: [], + loc: helperLoc(loc), + }); + lowerer.valueHelpers.set(helper, helper); + } + return { kind: "call", callee: helper, args: [receiver], type: resultType, loc }; +} + /** A plain assignment, a destructuring target, or a for-in/of target writes * the field. Compound assignments and updates read it first, and Node * reports the failed read. */ @@ -358,6 +388,22 @@ export function finalizePrivateBrands(lowerer: Lowerer, functions: IrFunction[]) }, { kind: "return", value: { kind: "boolLit", value: false, type: BOOL, loc }, loc }, ); + } else if (use === "call") { + // V8 names the class by its own identifier (not a name inferred from + // a binding), "anonymous" without one, also for a nullish receiver. + const owner = lowerer.classes.get(className); + const identifier = owner?.decl?.name; + const shown = identifier !== undefined ? identifier.text : "anonymous"; + for (const unit of ["undefined", "null"] as const) { + body.push({ + kind: "if", + cond: { kind: "dynTest", test: unit, value, type: BOOL, loc }, + then: [typeError(strLit(`Cannot read properties of ${unit} (reading '${shown}')`, loc))], + else_: null, + loc, + }); + } + body.push(typeError(strLit(`Receiver must be an instance of class ${shown}`, loc))); } else { const verb = use === "read" ? "reading" : "setting"; const action = use === "read" ? "Cannot read properties" : "Cannot set properties"; diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7621-class-methods-foreign-receivers.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7621-class-methods-foreign-receivers.mjs.json new file mode 100644 index 000000000..8f50b09a3 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7621-class-methods-foreign-receivers.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7621-class-methods-foreign-receivers.mjs" + ], + "diags": [] +} diff --git a/tests/corpus/7621-class-methods-foreign-receivers.mjs b/tests/corpus/7621-class-methods-foreign-receivers.mjs new file mode 100644 index 000000000..beb046ea4 --- /dev/null +++ b/tests/corpus/7621-class-methods-foreign-receivers.mjs @@ -0,0 +1,130 @@ +// Prototype methods called with a receiver that is not an instance of +// their class (`C.prototype.m.call(other)`): `#private` members throw +// Node's TypeErrors for such a receiver, and `super.m()` looks the method up +// on the base class's prototype and calls it with the receiver. +function report(label, run) { + try { + console.log(label, run()); + } catch (error) { + console.log(label, error.constructor.name, error.message); + } +} + +class Counter { + #count = 0; + #step = 1; + constructor(name) { + this.name = name; + } + read() { + return this.#count; + } + bump() { + this.#count += this.#step; + return this.#count; + } + reset(value) { + this.#count = value; + return this.name; + } + #label() { + return `${this.name}#${this.#count}`; + } + label() { + return this.#label(); + } + owns(value) { + return #count in value; + } + ownsSelf() { + return #count in this; + } +} + +const counter = new Counter("c"); +console.log(counter.bump(), counter.bump(), counter.read(), counter.label()); +console.log(counter.owns(counter), counter.owns({}), counter.ownsSelf(), counter.reset(7), counter.read()); + +const plain = { name: "plain" }; +report("read", () => Counter.prototype.read.call(plain)); +report("bump", () => Counter.prototype.bump.call(plain)); +report("reset", () => Counter.prototype.reset.call(plain, 3)); +report("label", () => Counter.prototype.label.call(plain)); +report("ownsSelf", () => Counter.prototype.ownsSelf.call(plain)); +report("owns primitive", () => Counter.prototype.owns.call(counter, 5)); +report("label undefined", () => Counter.prototype.label.call(undefined)); +report("label null", () => Counter.prototype.label.call(null)); +report("label number", () => Counter.prototype.label.call(4)); +report("read other instance", () => Counter.prototype.read.call(new Counter("d"))); +report("label other instance", () => Counter.prototype.label.call(new Counter("e"))); + +const Anonymous = class { + #m() { + return 1; + } + call() { + return this.#m(); + } +}; +const Named = class Inner { + #m() { + return 2; + } + call() { + return this.#m(); + } +}; +report("anonymous", () => Anonymous.prototype.call.call({})); +report("named", () => Named.prototype.call.call({})); +console.log(new Anonymous().call(), new Named().call()); + +class Shape { + constructor(name) { + this.name = name; + } + describe() { + return `shape ${this.name}`; + } + area() { + return 0; + } +} +class Square extends Shape { + describe() { + return `square: ${super.describe()} area ${this.area()}`; + } + area() { + return this.size * this.size; + } + both() { + return [super.describe(), super.area()].join("|"); + } +} +class Tiny extends Square { + #tag = "t"; + describe() { + return `tiny<${super.describe()}>`; + } + tag() { + return `${this.#tag}:${super.both()}`; + } +} + +const square = new Square("s"); +square.size = 3; +console.log(square.describe(), square.both()); +const tiny = new Tiny("t"); +tiny.size = 1; +console.log(tiny.describe(), tiny.tag()); +const foreign = { + name: "f", + size: 2, + area() { + return 40; + }, +}; +report("square describe", () => Square.prototype.describe.call(foreign)); +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)); diff --git a/tests/harness/corpus-costs/7621-class-methods-foreign-receivers.mjs.json b/tests/harness/corpus-costs/7621-class-methods-foreign-receivers.mjs.json new file mode 100644 index 000000000..bb2ade1c2 --- /dev/null +++ b/tests/harness/corpus-costs/7621-class-methods-foreign-receivers.mjs.json @@ -0,0 +1 @@ +{ "plain": 6415, "san": 10107 } From 2a98dbfa329c8b9e176179257429edb8d2f6a5e6 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 23 Oct 2026 21:57:25 -0500 Subject: [PATCH 2/3] 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. --- .../lowering/class-dynamic-dispatch.ts | 96 ++++++++++++++++++- .../7621-class-methods-foreign-receivers.mjs | 39 ++++++++ 2 files changed, 134 insertions(+), 1 deletion(-) diff --git a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts index a1bc41782..5132e487f 100644 --- a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts +++ b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts @@ -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", diff --git a/tests/corpus/7621-class-methods-foreign-receivers.mjs b/tests/corpus/7621-class-methods-foreign-receivers.mjs index beb046ea4..8e4feedbd 100644 --- a/tests/corpus/7621-class-methods-foreign-receivers.mjs +++ b/tests/corpus/7621-class-methods-foreign-receivers.mjs @@ -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, +); From bffd887145a81541b05cd6a1361c074c88f0f57c Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sat, 24 Oct 2026 16:26:20 -0500 Subject: [PATCH 3/3] Keep the native receiver for private accessors and private method values A JavaScript prototype method value runs over the call-site receiver when its private members are fields (brand-checked reads and writes) or private methods it calls. A private accessor, or a private method read as a value, would read a public property of the same name, so such a method keeps its native receiver, as before. A super method call with spread arguments over the call-site receiver now spreads them into the dynamic call instead of rejecting them. --- .../frontend/lowering/class-method-values.ts | 34 ++++++++++++- .../src/frontend/lowering/lower-classes.ts | 11 ++++- .../7621-class-methods-foreign-receivers.mjs | 49 +++++++++++++++++++ 3 files changed, 91 insertions(+), 3 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/class-method-values.ts b/packages/compiler/src/frontend/lowering/class-method-values.ts index af2ef51d5..ab9101cf7 100644 --- a/packages/compiler/src/frontend/lowering/class-method-values.ts +++ b/packages/compiler/src/frontend/lowering/class-method-values.ts @@ -383,12 +383,29 @@ function reflectedMethodValue( // base has a prototype object. Other uses of `super` keep the native // receiver. const superCalls = !staticMethod && info.base !== null && prototypeChainViewable(info.base); + // Over a dynamic receiver, private fields are brand-checked and read, and private methods + // brand-checked and called; a private accessor, or a private method read as a value, would become a + // lookup of its name. + const privateAccessors = new Set(); + const privateMethods = new Set(); + for (const other of declaration.members.nodes) + if (other.name !== undefined && ts.isPrivateIdentifier(other.name)) { + if (ts.isAccessor(other)) privateAccessors.add(other.name.text); + else if (ts.isMethodDeclaration(other)) privateMethods.add(other.name.text); + } ts.walkPreorder(member, (node) => { if (node.kind === ts.SyntaxKind.SuperKeyword && !(superCalls && isSuperMethodCallee(node))) needsNativeReceiver = true; + if (!ts.isPrivateIdentifier(node)) return; // Private members on a static method's receiver (the class value) keep - // the native receiver; prototype methods brand-check theirs. - if (staticMethod && ts.isPrivateIdentifier(node)) needsNativeReceiver = true; + // the native receiver; prototype methods brand-check theirs, except + // private accessors and private methods used other than by a call. + if ( + staticMethod || + privateAccessors.has(node.text) || + (privateMethods.has(node.text) && !isPrivateMethodCallee(node)) + ) + needsNativeReceiver = true; }); if (needsNativeReceiver) return null; // The method reads itself as a value inside its own body: it is the @@ -477,6 +494,19 @@ function prototypeChainViewable(base: ClassInfo): boolean { } /** `super` as the receiver of a called member: `super.m(...)`. */ +/** Whether `node`, a private name, is the method a call calls (`x.#m(...)`). */ +function isPrivateMethodCallee(node: ts.Node): boolean { + const access = node.parent; + return ( + access !== undefined && + ts.isPropertyAccessExpression(access) && + access.name === node && + access.parent !== undefined && + ts.isCallExpression(access.parent) && + access.parent.expression === access + ); +} + function isSuperMethodCallee(node: ts.Node): boolean { const access = node.parent; return ( diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index db8f35f95..2abbe81ca 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -9402,12 +9402,21 @@ export function lowerSuperMethodCall( type: DYN, loc, }; + // Spread arguments are spread into the dynamic call, as in a dynamic method call. + const spreads: { arg: number; what: string }[] = []; + const args = call.arguments.nodes.map((arg, index) => { + if (!ts.isSpreadElement(arg)) return lowerer.lowerExprExpecting(arg, DYN); + const spelling = ts.getTextOfNode(arg.expression); + spreads.push({ arg: index, what: spelling }); + return checkedIterableSpread(lowerer, lowerer.lowerExpr(arg.expression), spelling, loc); + }); const value: IrExpr = { kind: "dynCall", callee, receiver: lowerer.coerceToExpected(thisRef, DYN), calleeName: ts.getTextOfNode(access), - args: call.arguments.nodes.map((arg) => lowerer.lowerExprExpecting(arg, DYN)), + args, + ...(spreads.length > 0 ? { spreads } : {}), type: DYN, loc, }; diff --git a/tests/corpus/7621-class-methods-foreign-receivers.mjs b/tests/corpus/7621-class-methods-foreign-receivers.mjs index 8e4feedbd..70826a192 100644 --- a/tests/corpus/7621-class-methods-foreign-receivers.mjs +++ b/tests/corpus/7621-class-methods-foreign-receivers.mjs @@ -167,3 +167,52 @@ console.log( JSON.stringify(meshClone.geometry), typeof copyValue, ); + +// Methods that use a private accessor or return a private method keep their own receiver, as values too. +class Vault { + #v = 1; + get #value() { + return this.#v * 10; + } + set #value(x) { + this.#v = x; + } + #hidden() { + return "hidden " + this.#v; + } + read() { + return this.#value; + } + bump() { + this.#value = this.#v + 1; + return this.#v; + } + hiddenValue() { + return this.#hidden; + } +} +const vault = new Vault(); +const readVault = Vault.prototype.read; +const bumpVault = Vault.prototype.bump; +const hiddenOf = Vault.prototype.hiddenValue; +console.log(readVault.call(vault), bumpVault.call(vault), readVault.call(vault), typeof hiddenOf.call(vault)); +console.log(hiddenOf.call(vault).call(vault)); +try { + readVault.call({}); +} catch (error) { + console.log(error.constructor.name); +} + +// A method value whose body calls super with spread arguments. +class Summer { + add(...xs) { + return xs.reduce((sum, x) => sum + x, 0); + } +} +class Doubler extends Summer { + add(...xs) { + return super.add(...xs) * 2; + } +} +const addValue = Doubler.prototype.add; +console.log(addValue.call(new Doubler(), 1, 2, 3), addValue.apply(new Doubler(), [4, 5]));