mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-10-29 07:04:09 +08:00
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:
co-authored by
Claude Opus 5.5
parent
5159d2e966
commit
390ac6f326
@@ -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,
|
||||
);
|
||||
});
|
||||
+7
@@ -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 }
|
||||
Reference in New Issue
Block a user