Call observed class methods natively while their slot is unchanged

Any computed write with a key TypeScript cannot know (zod's
`curr[el] = ...` in ZodError.js, for one) puts every class method name
in the method census. Every typed call of such a method then went
through the observable prototype slot: a dynamic call with boxed
arguments, followed by a checked conversion of the result (for a
`Product[]` result, a deep walk of every element). Bundling untyped
JavaScript made unrelated typed code several times slower per call.

The prototype data now keeps each observed method's original value in a
global. A typed, non-virtual call of a TypeScript method still reads the
slot first, as before, and calls the method natively when the slot holds
that original value. A replacement, on the prototype or as an own
property, still takes the dynamic call. Virtual calls and methods
declared in unchecked JavaScript keep the dynamic call.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Malte Ubl
2026-10-22 21:52:15 -07:00
co-authored by Claude Opus 5.5
parent 5159d2e966
commit 390ac6f326
9 changed files with 229 additions and 1 deletions
@@ -164,6 +164,7 @@ export function classCallbackCall(
fallback: IrExpr,
loc: SrcLoc,
calleeName = name,
original?: { originalId: () => string | undefined; directCall: () => IrExpr },
): IrExpr {
const bag = lowerer.declareHiddenLocal("%callbackBag", DYN);
const present = lowerer.declareHiddenLocal("%callbackPresent", BOOL);
@@ -235,6 +236,44 @@ export function classCallbackCall(
answer =
fallback.type.kind === "void" ? result : lowerer.coerceToExpected(result, fallback.type);
}
// Read after observedSlot built the prototype data and its originals.
const originalId = observed ? original?.originalId() : undefined;
if (original && originalId !== undefined) {
// The slot still holds the method's original value: calling it is
// calling the method, so the call stays native (no boxed arguments,
// dynamic call or checked result). A replacement takes the dynamic call.
const unchanged: IrExpr = {
kind: "libCall",
fn: "dyn.sameValue",
args: [varRef(callback.id, DYN, loc), varRef(originalId, DYN, loc)],
type: BOOL,
loc,
};
answer =
fallback.type.kind === "void"
? {
kind: "ternary",
cond: unchanged,
then: {
kind: "seqExpr",
stmts: [{ kind: "exprStmt", expr: original.directCall(), loc }],
result: dynUndefinedExpr(loc),
type: DYN,
loc,
},
else_: call,
type: DYN,
loc,
}
: {
kind: "ternary",
cond: unchanged,
then: original.directCall(),
else_: answer,
type: answer.type,
loc,
};
}
return {
kind: "seqExpr",
stmts: [
@@ -207,12 +207,21 @@ export function classPrototypeData(
// promise-returning signature) gets no data slot.
const boxedValue = lowerer.coerceToExpected(compiled, DYN);
if (boxedValue.type.kind !== "dyn") continue;
const originalId = `%g.${info.def.prototypeDataHelper}.original.${method}`;
lowerer.globalsList.push({
id: originalId,
name: `${info.def.jsName ?? info.def.name}.prototype.${method}.original`,
type: DYN,
mutable: true,
});
info.prototypeOriginals ??= new Map();
info.prototypeOriginals.set(method, originalId);
const descriptor: IrExpr = {
kind: "dynObjLit",
fields: [
{
key: { kind: "strLit", value: "value", type: STRING, loc },
value: boxedValue,
value: varRef(originalId, DYN, loc),
},
{
key: { kind: "strLit", value: "writable", type: STRING, loc },
@@ -227,6 +236,8 @@ export function classPrototypeData(
loc,
};
const init = helper.body[0]!;
if (init.kind === "if")
init.then.push({ kind: "assign", localId: originalId, value: boxedValue, loc });
if (init.kind === "if")
init.then.push({
kind: "exprStmt",
@@ -10195,6 +10195,20 @@ export function lowerObjectMethodCall(
type: found.sig.ret,
loc,
};
// The declaring class's original value, built with the observed slot.
// An override below (a virtual call) keeps the dynamic call: a slot
// reassigned to an ancestor's method must not reach the override. So do
// methods declared in unchecked JavaScript, whose results are inferred.
const declarerFile = found.declarer.decl ? ts.getSourceFileOfNode(found.declarer.decl) : null;
const original =
virtual ||
!declarerFile ||
(isJsSourceFile(declarerFile) && !lowerer.checkedJsFile(declarerFile))
? undefined
: {
originalId: (): string | undefined => found.declarer.prototypeOriginals?.get(method),
directCall: (): IrExpr => fallback,
};
const result = classCallbackCall(
lowerer,
value,
@@ -10203,6 +10217,7 @@ export function lowerObjectMethodCall(
fallback,
loc,
ts.getTextOfNode(access),
original,
);
return {
kind: "seqExpr",
@@ -478,6 +478,10 @@ export interface ClassInfo {
prototypeMethodValues?: Map<string, IrExpr>;
prototypeReflectionReady?: true;
prototypeObservedMethodsReady?: true;
/** The globals holding each observed method's original prototype value
* (set when the prototype data is built), by method name. A typed call
* whose slot still holds that value calls the method natively. */
prototypeOriginals?: Map<string, string>;
prototypeReflectionRefusal?: string;
localClass?: {
context: FnCtx | null;
@@ -0,0 +1,77 @@
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, expect, test } from "vitest";
import { compile, deserializeModule, validateModule } from "../src/index.js";
const dirs: string[] = [];
afterEach(async () => {
await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
});
type IrRecord = Record<string, unknown>;
function recordsOf(value: unknown, out: IrRecord[] = []): IrRecord[] {
if (value === null || typeof value !== "object") return out;
if (Array.isArray(value)) {
for (const item of value) recordsOf(item, out);
return out;
}
const record = value as IrRecord;
out.push(record);
for (const child of Object.values(record)) recordsOf(child, out);
return out;
}
// An untyped computed write puts every method name in the census, so typed
// calls read the observable slot. While the slot holds the declared method,
// the call is the native method call, not a dynamic call of the slot.
test("typed calls of an observed method call it natively while its slot is unchanged", async () => {
const dir = await mkdtemp(join(tmpdir(), "scriptc-observed-guard-"));
dirs.push(dir);
const entry = join(dir, "main.ts");
const outDir = join(dir, ".scriptc");
const outPath = join(outDir, "main.ir.json");
await writeFile(
join(dir, "untyped.js"),
"export function put(target, key, value) { target[key] = value; }\n",
);
await writeFile(
entry,
[
'import { put } from "./untyped.js";',
"class Repo {",
" items: number[] = [];",
" total(): number { let s = 0; for (const n of this.items) s += n; return s; }",
"}",
"const repo = new Repo();",
"repo.items.push(1, 2);",
"put({}, String(Math.random()), 1);",
"export function sum(r: Repo): number { return r.total(); }",
"console.log(sum(repo));",
"",
].join("\n"),
);
const result = await compile(entry, { outDir, outPath, outputKind: "ir" });
if (!result.ok)
throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"));
const module = deserializeModule(await readFile(outPath, "utf8"));
expect(validateModule(module)).toEqual([]);
const sum = module.functions.find((fn) => fn.name.endsWith("sum"));
expect(sum).toBeDefined();
const records = recordsOf(sum);
const guards = records.filter(
(record) =>
record.kind === "ternary" &&
(record.cond as IrRecord | undefined)?.kind === "libCall" &&
(record.cond as IrRecord).fn === "dyn.sameValue",
);
expect(guards).toHaveLength(1);
const then = guards[0]!.then as IrRecord;
expect(then.kind).toBe("call");
expect(String(then.callee)).toMatch(/Repo\.total$/);
expect(module.globals?.some((global) => global.name === "Repo.prototype.total.original")).toBe(
true,
);
});
@@ -0,0 +1,7 @@
{
"order": [
"<repo>/tests/corpus/7365-class-methods-untyped-computed-writes/untyped.js",
"<repo>/tests/corpus/7365-class-methods-untyped-computed-writes/main.ts"
],
"diags": []
}
@@ -0,0 +1,59 @@
// Typed classes next to untyped computed writes. Calls through a typed
// receiver keep the method while its prototype slot holds the declared
// method, and observe a replacement made by untyped code (on the prototype
// or as an own property of one instance) exactly as Node does.
import { patch, tally, unpatch } from "./untyped.js";
class Repository {
private items = new Map<string, number>();
add(name: string, cents: number): number {
this.items.set(name, cents);
return this.items.size;
}
list(): string[] {
return [...this.items.keys()];
}
total(): number {
let sum = 0;
for (const cents of this.items.values()) sum += cents;
return sum;
}
}
const words: string[] = ["list", "add", "list", "total"];
console.log(JSON.stringify(tally(words)));
const repo = new Repository();
repo.add("keyboard", 12900);
repo.add("mouse", 4900);
let sum = 0;
for (let i = 0; i < 1000; i++) sum += repo.total();
console.log(repo.list().join(","), sum);
// untyped code replaces the method on the prototype
const proto: unknown = Object.getPrototypeOf(repo);
patch(proto, "total", function (this: Repository): number {
return -1;
});
console.log(repo.total(), new Repository().total());
unpatch(proto, "total");
try {
repo.total();
} catch (error) {
console.log("after delete:", error instanceof TypeError);
}
// an own replacement on one instance shadows the prototype for that instance only
const other = new Repository();
other.add("cable", 900);
patch(other, "list", () => ["shadowed"]);
console.log(other.list().join(","), repo.list().join(","));
unpatch(other, "list");
console.log(other.list().join(","));
// restoring the original value through the prototype brings back the method
const saved: unknown = (proto as Record<string, unknown>)["list"];
patch(proto, "list", () => ["patched"]);
console.log(repo.list().join(","));
patch(proto, "list", saved);
console.log(repo.list().join(","));
@@ -0,0 +1,15 @@
// Untyped helpers with computed writes whose keys TypeScript cannot know.
// They put every class method name in play for the method census.
export function tally(words) {
const counts = {};
for (const word of words) counts[word] = (counts[word] || 0) + 1;
return counts;
}
export function patch(target, name, impl) {
target[name] = impl;
}
export function unpatch(target, name) {
delete target[name];
}
@@ -0,0 +1 @@
{ "plain": 3000, "san": 3400 }