From 3d8c7259a05274abd416ace9c9d750cd6d7bc3d1 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Mon, 19 Oct 2026 20:33:19 -0700 Subject: [PATCH 1/4] Hoist lazily initialized ES module classes and follow on-demand workspace imports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A class a bundled ES module's __esm initializer stores into a module binding (`Multi = class extends import_stream.Readable { … }`) becomes a class declaration at the same text position: the initializer's body splits around it into run-once pieces, so a class over a runtime-provided base no longer has to exist as a stored class value. Only definitions whose evaluation runs no code (no static fields, static blocks or computed names; heritage absent, a builtin module member, or a hoisted class), declared once, assigned only there and read only inside functions after the assignment move. npm selection now also scans the program modules a literal import() reaches, with their static graphs: a workspace package loaded on demand had its npm dependencies refused as "not part of the compiled module graph". `new import_x.C()` respelled `new require("x").C()`, which constructs the require call; the interop binding now becomes `(require("x"))` there. util.inherits onto a runtime-provided class (stream.Readable, the from2 and multistream shape) fences where it is written instead of failing in Node's argument validation at run time. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 1 + .../src/frontend/lowering/lower-inspect.ts | 14 ++ .../frontend/npm-static-bundled-esm.test.ts | 74 +++++++ .../src/frontend/npm-static-bundled-esm.ts | 196 +++++++++++++++++- packages/compiler/src/frontend/pipeline.ts | 49 +++++ .../util-inherits-runtime-class.ts.json | 6 + .../2572-bundled-esm-classes/main.ts.json | 6 + .../workspace-dynamic-import/main.ts.json | 6 + .../util-inherits-runtime-class.ts | 10 + .../cases/2572-bundled-esm-classes/main.ts | 8 + .../cases/workspace-dynamic-import/main.ts | 10 + .../node_modules/wsdynamic | 1 + .../npm/node_modules/gtesmclass/dist/index.js | 78 +++++++ .../npm/node_modules/gtesmclass/index.d.ts | 1 + .../npm/node_modules/gtesmclass/package.json | 7 + .../fixtures/npm/workspace/wsdynamic/index.ts | 5 + .../wsdynamic/node_modules/gtshout/index.d.ts | 1 + .../wsdynamic/node_modules/gtshout/index.js | 3 + .../node_modules/gtshout/package.json | 7 + .../npm/workspace/wsdynamic/package.json | 10 + .../util-inherits-runtime-class.ts.txt | 8 + tests/harness/npm-cases.ts | 4 +- tests/harness/npm-static.test.ts | 10 +- 23 files changed, 509 insertions(+), 6 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/util-inherits-runtime-class.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/2572-bundled-esm-classes/main.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/workspace-dynamic-import/main.ts.json create mode 100644 tests/diagnostics/util-inherits-runtime-class.ts create mode 100644 tests/fixtures/npm/cases/2572-bundled-esm-classes/main.ts create mode 100644 tests/fixtures/npm/cases/workspace-dynamic-import/main.ts create mode 120000 tests/fixtures/npm/cases/workspace-dynamic-import/node_modules/wsdynamic create mode 100644 tests/fixtures/npm/node_modules/gtesmclass/dist/index.js create mode 100644 tests/fixtures/npm/node_modules/gtesmclass/index.d.ts create mode 100644 tests/fixtures/npm/node_modules/gtesmclass/package.json create mode 100644 tests/fixtures/npm/workspace/wsdynamic/index.ts create mode 100644 tests/fixtures/npm/workspace/wsdynamic/node_modules/gtshout/index.d.ts create mode 100644 tests/fixtures/npm/workspace/wsdynamic/node_modules/gtshout/index.js create mode 100644 tests/fixtures/npm/workspace/wsdynamic/node_modules/gtshout/package.json create mode 100644 tests/fixtures/npm/workspace/wsdynamic/package.json create mode 100644 tests/harness/__snapshots__/util-inherits-runtime-class.ts.txt diff --git a/.gitignore b/.gitignore index 2ef724d989..6bb51dcda8 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ node_modules/ !tests/fixtures/npm-static/node_modules/*/node_modules/ !tests/fixtures/npm/store/*/node_modules/ !tests/fixtures/npm/node_modules/*/node_modules/ +!tests/fixtures/npm/workspace/*/node_modules/ !tests/fixtures/npm/cases/*/node_modules/ !tests/library-mode/*/node_modules/ !tests/fixtures/provenance/node_modules/ diff --git a/packages/compiler/src/frontend/lowering/lower-inspect.ts b/packages/compiler/src/frontend/lowering/lower-inspect.ts index 245d1b6761..1f8a673750 100644 --- a/packages/compiler/src/frontend/lowering/lower-inspect.ts +++ b/packages/compiler/src/frontend/lowering/lower-inspect.ts @@ -34,6 +34,7 @@ import type { ClassInfo } from "./lower-classes.js"; import { isSafeToDiscard, isSafeToRepeat } from "./expressions/evaluation-safety.js"; import { symbolFieldDisplayName } from "./symbol-fields.js"; import { eventTargetRootOf } from "./lower-event-target.js"; +import { builtinClassRefOf } from "./builtins/class-values.js"; import { lowerSystemErrorMap } from "./lower-system-errors.js"; import { isPerfEntryType, perfEntryJson } from "./builtins/performance.js"; import { recordMayKeepSource, recordSourceTest } from "./record-sources.js"; @@ -2545,6 +2546,19 @@ function lowerUtilCallableCall( // Program classes keep static prototypes: there is no function object // whose prototype chain the runtime could relink or wrap. const classArgs = member === "inherits" ? 2 : member === "deprecate" ? 1 : 0; + // A runtime-provided parent (`util.inherits(Ctor, stream.Readable)`, the + // from2 and multistream shape) has no prototype object the runtime could + // link a constructor function's prototype to. + const parent = member === "inherits" ? expr.arguments.nodes[1] : undefined; + if (parent !== undefined && !ts.isSpreadElement(parent)) { + const ref = builtinClassRefOf(lowerer, parent); + if (ref !== null) + lowerer.noLowering( + `util.inherits onto the runtime-provided class '${ref.name}'`, + parent, + "declare the subclass with extends", + ); + } for (const arg of expr.arguments.nodes.slice(0, classArgs)) { if (lowerer.mapTypeOf(lowerer.typeOf(arg))?.kind === "classval") lowerer.noLowering( diff --git a/packages/compiler/src/frontend/npm-static-bundled-esm.test.ts b/packages/compiler/src/frontend/npm-static-bundled-esm.test.ts index 0a144036c2..70869089cc 100644 --- a/packages/compiler/src/frontend/npm-static-bundled-esm.test.ts +++ b/packages/compiler/src/frontend/npm-static-bundled-esm.test.ts @@ -146,3 +146,77 @@ test("files without __esm initializers and ES-module bundles are left alone", () lower(`${helpers}var init_a = __esm({ "a.ts"() {} });\ninit_a();\nexport {};\n`), ).toBeNull(); }); + +test("a class an initializer defines into a module binding becomes a class declaration", () => { + const lowered = expectSameBehavior(`"use strict"; +${helpers}var import_stream, Shout, Loud, Plain; +var init_shout = __esm({ + "src/shout.ts"() { + "use strict"; + import_stream = require("stream"); + console.log("shout init"); + Shout = class extends import_stream.Transform { + _transform(chunk, encoding, callback) { + callback(null, String(chunk).toUpperCase()); + } + }; + Loud = class Loud extends Shout { + static of() { return new Loud(); } + }, Plain = class { + label() { return "plain"; } + }; + console.log("after", typeof Shout, Loud.name, new Plain().label()); + } +}); +function main() { + init_shout(); + const s = Loud.of(); + s.on("data", (c) => console.log("data", String(c))); + s.end("hi"); +} +console.log("start"); +main(); +`); + expect(lowered).toContain('class Shout extends require("stream").Transform'); + expect(lowered).toContain("class Loud extends Shout"); + expect(lowered).toContain("class Plain "); + // The bindings' declarators are gone with the interop binding's. + expect(lowered).not.toMatch(/var [^;=]*\b(?:import_stream|Shout|Loud|Plain)\b/); +}); + +test("classes whose definition or binding is observable stay assignments", () => { + const lowered = expectSameBehavior(`${helpers}var A, B, C, D; +var init_m = __esm({ + "src/m.ts"() { + A = class { static made = console.log("static field runs at definition") }; + B = class extends Array {}; + console.log(typeof D); + D = class {}; + C = class {}; + } +}); +console.log(typeof C); +init_m(); +console.log(typeof A, typeof B, typeof C, typeof D); +`); + expect(lowered).toContain("A = class"); + expect(lowered).toContain("B = class"); + expect(lowered).toContain("C = class"); + expect(lowered).toContain("D = class"); +}); + +test("an interop binding a new expression constructs through stays the callee", () => { + const lowered = expectSameBehavior(`${helpers}function make() { + return new import_events.EventEmitter(); +} +var import_events; +var init_m = __esm({ + "src/m.ts"() { + import_events = require("events"); + } +}); +init_m(); +console.log(typeof make().on); +`); + expect(lowered).toContain('new (require("events")).EventEmitter()'); +}); diff --git a/packages/compiler/src/frontend/npm-static-bundled-esm.ts b/packages/compiler/src/frontend/npm-static-bundled-esm.ts index 10177ccb9a..0c9fc38e07 100644 --- a/packages/compiler/src/frontend/npm-static-bundled-esm.ts +++ b/packages/compiler/src/frontend/npm-static-bundled-esm.ts @@ -29,6 +29,7 @@ import * as ts from "./ts7/syntax.js"; import { bareRequireSpecOf } from "./cjs-syntax.js"; import { helperTokens } from "./helper-tokens.js"; +import { canonicalBuiltinModule } from "./builtin-modules.js"; import { applyEdits, countDeclarations, @@ -37,6 +38,7 @@ import { helperBase, isWritten, references, + strictPrologue, unparen, type Edit, } from "./npm-static-bundled-chunks.js"; @@ -133,6 +135,17 @@ function removeDeclarators(sf: ts.SourceFile, dead: ReadonlySet(); + const interopSpecs = new Map(); for (const init of inits.values()) { for (const stmt of init.body.statements.nodes) { if (!ts.isExpressionStatement(stmt)) continue; @@ -403,14 +417,27 @@ export function lowerBundledEsmInits( } if (defaultIsModule && access.name.text === "default") uses.push({ start: access.getStart(sf), end: access.getEnd(), text: `(${req})` }); - else uses.push({ start: r.getStart(sf), end: r.getEnd(), text: req }); + // `new import_x.C()` must stay `new (require("x")).C()`: a bare + // `new require("x").C()` constructs the require call itself. + else + uses.push({ + start: r.getStart(sf), + end: r.getEnd(), + text: inNewCallee(r) ? `(${req})` : req, + }); } if (!ok) continue; edits.push({ start: el.getStart(sf), end: el.getEnd(), text: req }, ...uses); deadDeclarators.add(decl); + interopSpecs.set(name, spec); } } } + const hoisted = hoistInitClasses(sf, inits, top, duplicate, writes, refsByName, interopSpecs); + for (const h of hoisted.values()) { + edits.push(...h.edits); + for (const d of h.declarators) deadDeclarators.add(d); + } edits.push(...removeDeclarators(sf, deadDeclarators)); /* Namespaces of lazily initialized modules read through members. */ @@ -752,14 +779,177 @@ export function lowerBundledEsmInits( { start: call.getStart(sf), end: body.getStart(sf) + 1, - text: "(() => { let __scriptc_done = false; function __scriptc_init() {", + text: `(() => { let __scriptc_done = false; function __scriptc_init() {`, }, { start: body.getEnd() - 1, end: call.getEnd(), - text: "} return function () { if (__scriptc_done) return; __scriptc_done = true; __scriptc_init(); }; })()", + // A body split around hoisted classes ends in its last tail function. + text: hoisted.has(init.name) ? "}" : `} ${RUN_ONCE_TAIL}`, }, ); } return applyEdits(source, 0, source.length, edits); } + +/** The run-once closure's closing text after the initializer function. */ +const RUN_ONCE_TAIL = + "return function () { if (__scriptc_done) return; __scriptc_done = true; __scriptc_init(); }; })()"; + +interface InitBody { + name: string; + call: ts.CallExpression; + body: ts.Block; +} + +/* Classes an initializer defines into a module binding, + * + * var Multi; + * var init_m = __esm({ "src/m.ts"() { Multi = class extends import_stream.Readable { … }; } }); + * + * become class declarations in the bundle's scope, at the same text + * position: the initializer's body splits around each class into run-once + * pieces that call one another in order (`… __scriptc_rest_init_m_1(); } + * return …; })(); class Multi extends … { … } function + * __scriptc_rest_init_m_1() { … }`), so the class's own text stays where + * it was and only its binding changes. A stored class value with a + * runtime-provided base has no static representation, while a declared + * class does. + * + * Exact where defining the class earlier is unobservable: its heritage is + * absent, a builtin module member, or a class hoisted before it; it has no + * static field initializers, static blocks or computed member names, so + * evaluating the definition runs no code; the binding is declared once, + * assigned only there, and read only inside functions (which esbuild's + * code reaches only after the initializer ran, the assumption the interop + * respelling makes too); and the body declares nothing, so its statements + * can move between functions. */ +function hoistInitClasses( + sf: ts.SourceFile, + inits: ReadonlyMap, + top: ReadonlyMap, + duplicate: ReadonlySet, + writes: ReadonlyMap, + refsByName: ReadonlyMap, + interopSpecs: ReadonlyMap, +): Map { + const out = new Map(); + const hoistedNames = new Set(); + const insideFunction = (n: ts.Node): boolean => { + for (let p = n.parent; p !== undefined && !ts.isSourceFile(p); p = p.parent) + if (ts.isFunctionLike(p)) return true; + return false; + }; + const heritageOk = (cls: ts.ClassExpression): boolean => { + const clause = cls.heritageClauses?.nodes.find((c) => c.token === ts.SyntaxKind.ExtendsKeyword); + if (clause === undefined) return true; + const type = clause.types.nodes[0]; + if (type === undefined || clause.types.nodes.length !== 1 || type.typeArguments !== undefined) + return false; + let root: ts.Expression = type.expression; + while (ts.isPropertyAccessExpression(root) && !root.questionDotToken) root = root.expression; + if (ts.isIdentifier(root)) { + if (root === type.expression) return hoistedNames.has(root.text); + const spec = interopSpecs.get(root.text); + return spec !== undefined && canonicalBuiltinModule(spec) !== null; + } + const spec = bareRequireSpecOf(root); + return spec !== null && canonicalBuiltinModule(spec) !== null && root !== type.expression; + }; + const definitionInert = (cls: ts.ClassExpression): boolean => + (cls.modifiers?.nodes.length ?? 0) === 0 && + cls.members.nodes.every((m) => { + if (ts.isClassStaticBlockDeclaration(m)) return false; + if (m.name !== undefined && ts.isComputedPropertyName(m.name)) return false; + if (m.modifiers?.nodes.some((x) => x.kind === ts.SyntaxKind.Decorator)) return false; + const isStatic = m.modifiers?.nodes.some((x) => x.kind === ts.SyntaxKind.StaticKeyword); + return !(isStatic && ts.isPropertyDeclaration(m)); + }); + + for (const init of inits.values()) { + const stmts = init.body.statements.nodes; + if (stmts.some((st) => !ts.isExpressionStatement(st) && !ts.isEmptyStatement(st))) continue; + // The initializer's declaration is a statement of its own. + const declarator = init.call.parent; + const list = declarator?.parent; + if ( + declarator === undefined || + !ts.isVariableDeclaration(declarator) || + list === undefined || + !ts.isVariableDeclarationList(list) || + list.declarations.nodes.length !== 1 || + unparen(declarator.initializer!) !== init.call + ) + continue; + const strict = strictPrologue(stmts) ? '"use strict"; ' : ""; + const edits: Edit[] = []; + const declarators: ts.VariableDeclaration[] = []; + let piece = 0; + for (const stmt of stmts) { + if (!ts.isExpressionStatement(stmt)) continue; + const els = commaElements(stmt.expression); + const hoistedEls = new Set(); + for (let i = 0; i < els.length; i++) { + const el = els[i]!; + if ( + !ts.isBinaryExpression(el) || + el.operatorToken.kind !== ts.SyntaxKind.EqualsToken || + !ts.isIdentifier(el.left) || + !ts.isClassExpression(el.right) + ) + continue; + const name = el.left.text; + const cls = el.right; + const target = el.left; + const decl = top.get(name)?.node; + if ( + decl === undefined || + !ts.isVariableDeclaration(decl) || + decl.initializer !== undefined || + duplicate.has(name) || + writes.get(name) !== 1 || + (cls.name !== undefined && cls.name.text !== name) || + // The binding's declarator (and the class's own name, if spelled). + countDeclarations(sf, name) !== (cls.name === undefined ? 1 : 2) || + !definitionInert(cls) || + !heritageOk(cls) || + !(refsByName.get(name) ?? []).every( + (r) => + r === target || + // The class's own methods name the class itself. + (r.getStart(sf) >= cls.getStart(sf) && r.getEnd() <= cls.getEnd()) || + (insideFunction(r) && + // The initializer's own earlier code reads the unassigned binding. + !(r.getStart(sf) >= init.body.getStart(sf) && r.getStart(sf) < el.getEnd())), + ) + ) + continue; + piece++; + const rest = `__scriptc_rest_${init.name}_${piece}`; + const close = piece === 1 ? `} ${RUN_ONCE_TAIL};` : "}"; + // `a, X = class …, b`: the elements around the class end and begin + // the neighboring pieces. + // After a hoisted neighbor the separating comma is already gone. + const fresh = i === 0 || hoistedEls.has(els[i - 1]!); + const from = i === 0 ? stmt.getStart(sf) : fresh ? el.getStart(sf) : els[i - 1]!.getEnd(); + const sep = fresh ? "" : ";"; + hoistedEls.add(el); + edits.push({ start: from, end: cls.getStart(sf), text: `${sep} ${rest}(); ${close} ` }); + const next = els[i + 1]; + edits.push({ + start: cls.getEnd(), + end: next === undefined ? cls.getEnd() : next.getStart(sf), + text: ` function ${rest}() { ${strict}`, + }); + if (cls.name === undefined) { + const keyword = cls.getStart(sf); + edits.push({ start: keyword, end: keyword + "class".length, text: `class ${name}` }); + } + declarators.push(decl); + hoistedNames.add(name); + } + } + if (piece > 0) out.set(init.name, { edits, declarators }); + } + return out; +} diff --git a/packages/compiler/src/frontend/pipeline.ts b/packages/compiler/src/frontend/pipeline.ts index 29d40ccfaf..725ff48cd7 100644 --- a/packages/compiler/src/frontend/pipeline.ts +++ b/packages/compiler/src/frontend/pipeline.ts @@ -12,6 +12,7 @@ import { locOf, npmStaticLoadableFiles, requiresOf, + resolveImport, resolveNpmImport, type LoadResult, } from "./program.js"; @@ -36,6 +37,7 @@ import { isStringLiteral, isStringLiteralLike, SyntaxKind, + type SourceFile, } from "./ts7/adapter.js"; import { moduleSourceCandidates } from "./module-source-candidates.js"; import { CONTRACT_TYPES_MODULE } from "./contract-types.js"; @@ -113,6 +115,52 @@ export type FrontendFactory = ( libraryNpmStatic?: readonly string[], ) => Frontend; +/** Adds the program modules a literal `import()` reaches to `scanned`, + * with their own static import graphs: they load at the call, outside the + * startup module order, and their npm imports are dependencies all the + * same (a workspace package loaded on demand, the vercel.ts config parser + * shape). Package files are the package machinery's business. */ +function addDynamicImportGraph(load: LoadResult, scanned: Set): void { + const queue = [...scanned]; + const added = new Set(); + while (queue.length > 0) { + const sf = queue.pop()!; + if (sf.fileName.includes("/node_modules/")) continue; + const specs: string[] = []; + const calls = /\bimport\s*\(/.test(sf.text) + ? moduleSourceCandidates(load.program, sf).calls + : []; + for (const call of calls) { + const arg = call.arguments.nodes[0]; + if ( + call.expression.kind === SyntaxKind.ImportKeyword && + arg !== undefined && + isStringLiteralLike(arg) + ) + specs.push(arg.text); + } + // The startup graph's static edges are already in the module order. + if (added.has(sf)) { + for (const stmt of sf.statements.nodes) { + if ( + (isImportDeclaration(stmt) || isExportDeclaration(stmt)) && + stmt.moduleSpecifier && + isStringLiteral(stmt.moduleSpecifier) && + !(isImportDeclaration(stmt) ? erasedTypeOnlyImport(stmt) : stmt.isTypeOnly) + ) + specs.push(stmt.moduleSpecifier.text); + } + } + for (const spec of specs) { + const dep = resolveImport(load.program, sf, spec); + if (dep === null || scanned.has(dep) || dep.fileName.includes("/node_modules/")) continue; + scanned.add(dep); + added.add(dep); + queue.push(dep); + } + } +} + function detectAutoPackages( load: LoadResult, statuses: NpmStaticStatus[], @@ -135,6 +183,7 @@ function detectAutoPackages( // nested require() calls the graph walk does not model) still name its // dependencies. const scanned = new Set([...load.moduleOrder, load.entry]); + addDynamicImportGraph(load, scanned); const transitiveFiles = transitive.size === 0 ? [] diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/util-inherits-runtime-class.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/util-inherits-runtime-class.ts.json new file mode 100644 index 0000000000..5dfbdc3246 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/util-inherits-runtime-class.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/diagnostics/util-inherits-runtime-class.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/2572-bundled-esm-classes/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/2572-bundled-esm-classes/main.ts.json new file mode 100644 index 0000000000..62dd9116dd --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/2572-bundled-esm-classes/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/fixtures/npm/cases/2572-bundled-esm-classes/main.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/workspace-dynamic-import/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/workspace-dynamic-import/main.ts.json new file mode 100644 index 0000000000..0f6c04a583 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/workspace-dynamic-import/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/fixtures/npm/cases/workspace-dynamic-import/main.ts" + ], + "diags": [] +} diff --git a/tests/diagnostics/util-inherits-runtime-class.ts b/tests/diagnostics/util-inherits-runtime-class.ts new file mode 100644 index 0000000000..f79b379b92 --- /dev/null +++ b/tests/diagnostics/util-inherits-runtime-class.ts @@ -0,0 +1,10 @@ +// util.inherits onto a runtime-provided class (stream.Readable, the from2 +// and multistream shape in JavaScript) has no prototype object to link a +// constructor function's prototype to: the call fences where it is written +// instead of throwing Node's ERR_INVALID_ARG_TYPE at run time. +import { inherits } from "node:util"; +import { Readable } from "node:stream"; + +function Source(): void {} +inherits(Source, Readable); +console.log(typeof Source); diff --git a/tests/fixtures/npm/cases/2572-bundled-esm-classes/main.ts b/tests/fixtures/npm/cases/2572-bundled-esm-classes/main.ts new file mode 100644 index 0000000000..e61e4b6548 --- /dev/null +++ b/tests/fixtures/npm/cases/2572-bundled-esm-classes/main.ts @@ -0,0 +1,8 @@ +// Classes a bundled ES module defines inside its lazy __esm initializer, +// one extending stream.Readable and one extending that class, compile as +// class declarations: the stored class values have no static +// representation for a runtime-provided base. +import { wrap } from "gtesmclass"; + +console.log("start"); +wrap("body", (result) => console.log(result)); diff --git a/tests/fixtures/npm/cases/workspace-dynamic-import/main.ts b/tests/fixtures/npm/cases/workspace-dynamic-import/main.ts new file mode 100644 index 0000000000..59a7b88a9d --- /dev/null +++ b/tests/fixtures/npm/cases/workspace-dynamic-import/main.ts @@ -0,0 +1,10 @@ +// A workspace package the program loads on demand through import(): its +// own npm dependency (installed only in the workspace package's +// node_modules) compiles with it, although neither is part of the startup +// module graph. +async function main(): Promise { + console.log("before"); + const m = await import("wsdynamic"); + console.log(m.greet("world")); +} +main(); diff --git a/tests/fixtures/npm/cases/workspace-dynamic-import/node_modules/wsdynamic b/tests/fixtures/npm/cases/workspace-dynamic-import/node_modules/wsdynamic new file mode 120000 index 0000000000..049bcee20a --- /dev/null +++ b/tests/fixtures/npm/cases/workspace-dynamic-import/node_modules/wsdynamic @@ -0,0 +1 @@ +../../../workspace/wsdynamic \ No newline at end of file diff --git a/tests/fixtures/npm/node_modules/gtesmclass/dist/index.js b/tests/fixtures/npm/node_modules/gtesmclass/dist/index.js new file mode 100644 index 0000000000..3f00469a2f --- /dev/null +++ b/tests/fixtures/npm/node_modules/gtesmclass/dist/index.js @@ -0,0 +1,78 @@ +"use strict"; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __esm = (fn, res) => function __init() { + return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res; +}; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); + +// src/multi.ts +var import_stream, Wrapped, Shouting; +var init_multi = __esm({ + "src/multi.ts"() { + import_stream = require("stream"); + Wrapped = class extends import_stream.Readable { + constructor(source, prefix, suffix) { + super(); + source.on("end", () => { + this.push(suffix); + this.push(null); + }); + this.push(prefix); + source.on("data", (chunk) => { + this.push(chunk); + }); + } + _read() { + } + }; + Shouting = class extends Wrapped { + static label() { + return "shouting"; + } + }; + } +}); + +// src/api.ts +var api_exports = {}; +__export(api_exports, { + wrap: () => wrap +}); +function wrap(text, done) { + const source = new import_stream2.PassThrough(); + const out = new Wrapped(source, "<", ">"); + let seen = ""; + out.on("data", (chunk) => { + seen += String(chunk); + }); + out.on("end", () => done(Shouting.label() + " " + seen)); + source.end(text); +} +var import_stream2; +var init_api = __esm({ + "src/api.ts"() { + import_stream2 = require("stream"); + init_multi(); + } +}); + +// src/index.js +exports.wrap = function(text, done) { + const api = (init_api(), __toCommonJS(api_exports)); + api.wrap(text, done); +}; diff --git a/tests/fixtures/npm/node_modules/gtesmclass/index.d.ts b/tests/fixtures/npm/node_modules/gtesmclass/index.d.ts new file mode 100644 index 0000000000..b2ed7a6dd5 --- /dev/null +++ b/tests/fixtures/npm/node_modules/gtesmclass/index.d.ts @@ -0,0 +1 @@ +export declare function wrap(text: string, done: (result: string) => void): void; diff --git a/tests/fixtures/npm/node_modules/gtesmclass/package.json b/tests/fixtures/npm/node_modules/gtesmclass/package.json new file mode 100644 index 0000000000..0875ee26d8 --- /dev/null +++ b/tests/fixtures/npm/node_modules/gtesmclass/package.json @@ -0,0 +1,7 @@ +{ + "name": "gtesmclass", + "version": "1.0.0", + "description": "real esbuild 0.27 output (--bundle --platform=node --format=cjs) of a CommonJS entry that require()s an ES module defining classes over stream.Readable, so they are defined inside the lazy __esm initializer", + "main": "dist/index.js", + "types": "index.d.ts" +} diff --git a/tests/fixtures/npm/workspace/wsdynamic/index.ts b/tests/fixtures/npm/workspace/wsdynamic/index.ts new file mode 100644 index 0000000000..c3ac8db88f --- /dev/null +++ b/tests/fixtures/npm/workspace/wsdynamic/index.ts @@ -0,0 +1,5 @@ +import { shout } from "gtshout"; + +export function greet(name: string): string { + return `hello, ${shout(name)}`; +} diff --git a/tests/fixtures/npm/workspace/wsdynamic/node_modules/gtshout/index.d.ts b/tests/fixtures/npm/workspace/wsdynamic/node_modules/gtshout/index.d.ts new file mode 100644 index 0000000000..ccebdab51f --- /dev/null +++ b/tests/fixtures/npm/workspace/wsdynamic/node_modules/gtshout/index.d.ts @@ -0,0 +1 @@ +export declare function shout(s: string): string; diff --git a/tests/fixtures/npm/workspace/wsdynamic/node_modules/gtshout/index.js b/tests/fixtures/npm/workspace/wsdynamic/node_modules/gtshout/index.js new file mode 100644 index 0000000000..5050d06072 --- /dev/null +++ b/tests/fixtures/npm/workspace/wsdynamic/node_modules/gtshout/index.js @@ -0,0 +1,3 @@ +exports.shout = function (s) { + return s.toUpperCase() + "!"; +}; diff --git a/tests/fixtures/npm/workspace/wsdynamic/node_modules/gtshout/package.json b/tests/fixtures/npm/workspace/wsdynamic/node_modules/gtshout/package.json new file mode 100644 index 0000000000..5f1401c326 --- /dev/null +++ b/tests/fixtures/npm/workspace/wsdynamic/node_modules/gtshout/package.json @@ -0,0 +1,7 @@ +{ + "name": "gtshout", + "version": "1.0.0", + "description": "an npm dependency only a workspace package loaded through import() uses", + "main": "index.js", + "types": "index.d.ts" +} diff --git a/tests/fixtures/npm/workspace/wsdynamic/package.json b/tests/fixtures/npm/workspace/wsdynamic/package.json new file mode 100644 index 0000000000..7c6533bbd2 --- /dev/null +++ b/tests/fixtures/npm/workspace/wsdynamic/package.json @@ -0,0 +1,10 @@ +{ + "name": "wsdynamic", + "version": "1.0.0", + "type": "module", + "main": "./index.ts", + "types": "./index.ts", + "dependencies": { + "gtshout": "1.0.0" + } +} diff --git a/tests/harness/__snapshots__/util-inherits-runtime-class.ts.txt b/tests/harness/__snapshots__/util-inherits-runtime-class.ts.txt new file mode 100644 index 0000000000..98c7806460 --- /dev/null +++ b/tests/harness/__snapshots__/util-inherits-runtime-class.ts.txt @@ -0,0 +1,8 @@ +util-inherits-runtime-class.ts:9:18 - error SC2020: 'util.inherits onto the runtime-provided class 'Readable'' is part of the standard library types but has no scriptc lowering yet + + 8 | function Source(): void {} + 9 | inherits(Source, Readable); + | ^~~~~~~~ + 10 | console.log(typeof Source); + + hint: declare the subclass with extends \ No newline at end of file diff --git a/tests/harness/npm-cases.ts b/tests/harness/npm-cases.ts index e9aae9add6..aebf1f2d6b 100644 --- a/tests/harness/npm-cases.ts +++ b/tests/harness/npm-cases.ts @@ -19,12 +19,12 @@ export function npmCases(fixturesRoot: string): NpmCase[] { return [ ...globSync(join(fixturesRoot, "npm/cases/*/main.ts")) .sort() - // 2465-2469 and 2556-2571 are the --npm-static bundler-emitted-CJS + // 2465-2469 and 2556-2572 are the --npm-static bundler-emitted-CJS // cases (npm-static.test.ts drives them with the opt-in): their // consumers exercise the static rewrite's surface (lexer-visible // names the shipped .d.ts never declares, the __toESM interop // family), so they stay out of the flagless island lane by design. - .filter((entry) => !/\/(246[5-9]|255[6-9]|256[0-9]|257[01])-[^/]+\/main\.ts$/.test(entry)) + .filter((entry) => !/\/(246[5-9]|255[6-9]|256[0-9]|257[0-2])-[^/]+\/main\.ts$/.test(entry)) .map((entry) => ({ name: entry.split("/").at(-2)!, entry, diff --git a/tests/harness/npm-static.test.ts b/tests/harness/npm-static.test.ts index 8656f84d53..4336d1891f 100644 --- a/tests/harness/npm-static.test.ts +++ b/tests/harness/npm-static.test.ts @@ -76,7 +76,7 @@ async function buildStatic(entry: string, npmStatic: string[] | "auto"): Promise ...globSync( join(fixturesRoot, "npm/node_modules/cryptozoo/**/*.{js,mjs,cjs,json,d.ts}"), ).sort(), - // the bundler-emitted-CJS mini packages (cases 2465-2469, 2556-2571) + // the bundler-emitted-CJS mini packages (cases 2465-2469, 2556-2572) ...globSync(join(fixturesRoot, "npm/node_modules/gt*/**/*.{js,cjs,json}")).sort(), ...globSync(join(fixturesRoot, "npm/store/gt*/**/*.{js,cjs,json}")).sort(), ]; @@ -1587,6 +1587,14 @@ describe(`npm-static pilots${sanitize ? " (sanitized)" : ""}`, () => { await expectAliasParity(entry, ["gtpolybundle"], disabled); }, 180_000); + // 2572: classes a bundled ES module defines inside its lazy initializer, + // over stream.Readable and over each other, compile as declarations. + test("bundled ES module classes 2572-bundled-esm-classes compile statically and byte-match Node", async () => { + await expectNodeParity(join(fixturesRoot, "npm/cases/2572-bundled-esm-classes/main.ts"), [ + "gtesmclass", + ]); + }, 180_000); + // 2562: a file with unverifiable interop that no compiled code can load // (the program imports a leaf data module and only a type from the // main entry) leaves its package static. From 6f2a7ce814155f0bcba5d2f85e0a14d2fd6e244a Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Mon, 19 Oct 2026 20:52:10 -0700 Subject: [PATCH 2/4] Share the initializer and top-level binding shapes with the class hoisting The self-hosted compiler needs the same record type on both sides of the call. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/npm-static-bundled-esm.ts | 31 +++++++++---------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/packages/compiler/src/frontend/npm-static-bundled-esm.ts b/packages/compiler/src/frontend/npm-static-bundled-esm.ts index 0c9fc38e07..b63960292a 100644 --- a/packages/compiler/src/frontend/npm-static-bundled-esm.ts +++ b/packages/compiler/src/frontend/npm-static-bundled-esm.ts @@ -47,6 +47,19 @@ export interface EsmInitHost { requireTargetEsModuleStamped(fromFile: string, specifier: string): boolean; } +/** A top-level binding of the bundle: its statement and declaration. */ +interface Top { + stmt: ts.Statement; + node: ts.VariableDeclaration | ts.FunctionDeclaration; +} + +/** A lazy initializer: its declaration's `__esm(...)` call and body. */ +interface InitBody { + name: string; + call: ts.CallExpression; + body: ts.Block; +} + const ESM_SHAPE_DEGRADE = "its shipped JS initializes bundled ES modules through the __esm helper in a shape the " + "recognizer cannot verify"; @@ -236,10 +249,6 @@ export function lowerBundledEsmInits( if (!source.includes("__esm")) return null; if (!filePath.endsWith(".cjs") && ts.isExternalModule(sf)) return null; - interface Top { - stmt: ts.Statement; - node: ts.VariableDeclaration | ts.FunctionDeclaration; - } const top = new Map(); const duplicate = new Set(); for (const stmt of sf.statements.nodes) { @@ -283,12 +292,7 @@ export function lowerBundledEsmInits( } // The initializers: `NAME = HELPER(factory)` declarators. - interface Init { - name: string; - call: ts.CallExpression; - body: ts.Block; - } - const inits = new Map(); + const inits = new Map(); for (const [name, t] of top) { if (!ts.isVariableDeclaration(t.node) || t.node.initializer === undefined) continue; const call = unparen(t.node.initializer); @@ -796,11 +800,6 @@ export function lowerBundledEsmInits( const RUN_ONCE_TAIL = "return function () { if (__scriptc_done) return; __scriptc_done = true; __scriptc_init(); }; })()"; -interface InitBody { - name: string; - call: ts.CallExpression; - body: ts.Block; -} /* Classes an initializer defines into a module binding, * @@ -827,7 +826,7 @@ interface InitBody { function hoistInitClasses( sf: ts.SourceFile, inits: ReadonlyMap, - top: ReadonlyMap, + top: ReadonlyMap, duplicate: ReadonlySet, writes: ReadonlyMap, refsByName: ReadonlyMap, From 3ddf9640e90f7d37335e0f562731371e9a561b2f Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Mon, 19 Oct 2026 22:44:15 -0700 Subject: [PATCH 3/4] Format the npm case filter Co-Authored-By: Claude Opus 5.5 --- packages/compiler/src/frontend/npm-static-bundled-esm.ts | 1 - tests/harness/npm-cases.ts | 4 +++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/compiler/src/frontend/npm-static-bundled-esm.ts b/packages/compiler/src/frontend/npm-static-bundled-esm.ts index d6e59a3a03..e4beb240c5 100644 --- a/packages/compiler/src/frontend/npm-static-bundled-esm.ts +++ b/packages/compiler/src/frontend/npm-static-bundled-esm.ts @@ -850,7 +850,6 @@ export function lowerBundledEsmInits( const RUN_ONCE_TAIL = "return function () { if (__scriptc_done) return; __scriptc_done = true; __scriptc_init(); }; })()"; - /* Classes an initializer defines into a module binding, * * var Multi; diff --git a/tests/harness/npm-cases.ts b/tests/harness/npm-cases.ts index 4293c0f982..241ce3c1fd 100644 --- a/tests/harness/npm-cases.ts +++ b/tests/harness/npm-cases.ts @@ -24,7 +24,9 @@ export function npmCases(fixturesRoot: string): NpmCase[] { // consumers exercise the static rewrite's surface (lexer-visible // names the shipped .d.ts never declares, the __toESM interop // family), so they stay out of the flagless island lane by design. - .filter((entry) => !/\/(246[5-9]|255[6-9]|256[0-7]|2571|258[0-8])-[^/]+\/main\.ts$/.test(entry)) + .filter( + (entry) => !/\/(246[5-9]|255[6-9]|256[0-7]|2571|258[0-8])-[^/]+\/main\.ts$/.test(entry), + ) .map((entry) => ({ name: entry.split("/").at(-2)!, entry, From 78dbc31e43cf55ad9c330fa0faeb83fd4b3e41c3 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 20 Oct 2026 01:51:57 -0500 Subject: [PATCH 4/4] Find dynamic imports with a comment before their parenthesis The on-demand import scan tested each file's text for `import(` before reading its calls, so `import /* note */ ("pkg")` was never followed: the workspace package it loads and that package's npm dependencies were left out of the module graph, and the dependency was refused with SC2013. A file is now skipped only when it does not contain the keyword, and its literal import() calls are read from the syntax. The workspace-dynamic-import case loads its package with such a comment. --- packages/compiler/src/frontend/pipeline.ts | 7 ++++--- tests/fixtures/npm/cases/workspace-dynamic-import/main.ts | 6 +++--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/compiler/src/frontend/pipeline.ts b/packages/compiler/src/frontend/pipeline.ts index 725ff48cd7..1bb34bc3bc 100644 --- a/packages/compiler/src/frontend/pipeline.ts +++ b/packages/compiler/src/frontend/pipeline.ts @@ -127,9 +127,10 @@ function addDynamicImportGraph(load: LoadResult, scanned: Set): void const sf = queue.pop()!; if (sf.fileName.includes("/node_modules/")) continue; const specs: string[] = []; - const calls = /\bimport\s*\(/.test(sf.text) - ? moduleSourceCandidates(load.program, sf).calls - : []; + // Every literal import() in the file, read from the syntax: a comment + // can stand between the keyword and its parenthesis. A file without + // the keyword has none. + const calls = sf.text.includes("import") ? moduleSourceCandidates(load.program, sf).calls : []; for (const call of calls) { const arg = call.arguments.nodes[0]; if ( diff --git a/tests/fixtures/npm/cases/workspace-dynamic-import/main.ts b/tests/fixtures/npm/cases/workspace-dynamic-import/main.ts index 59a7b88a9d..2068f3c4be 100644 --- a/tests/fixtures/npm/cases/workspace-dynamic-import/main.ts +++ b/tests/fixtures/npm/cases/workspace-dynamic-import/main.ts @@ -1,10 +1,10 @@ -// A workspace package the program loads on demand through import(): its +// A workspace package the program loads on demand with a dynamic import: its // own npm dependency (installed only in the workspace package's // node_modules) compiles with it, although neither is part of the startup -// module graph. +// module graph. A comment can stand between `import` and its parenthesis. async function main(): Promise { console.log("before"); - const m = await import("wsdynamic"); + const m = await import /* loaded on demand */ ("wsdynamic"); console.log(m.greet("world")); } main();