Skip to content

Hoist lazily initialized ES module classes; follow on-demand workspace imports - #1487

Merged
ctate merged 6 commits into
opt/npm-polyfill-shimsfrom
opt/npm-esm-class-hoist
Oct 20, 2026
Merged

ctate merged 6 commits into
opt/npm-polyfill-shimsfrom
opt/npm-esm-class-hoist

Conversation

@cramforce

Copy link
Copy Markdown
Contributor

Stacked on #1458 (opt/npm-polyfill-shims), which is stacked on #1434. Review those first.

Summary

Classes defined in a lazy ES module initializer. esbuild scope-hoists an ES module that some code require()s and runs its body from an __esm initializer, so its classes become assignments to module bindings:

var Multi;
var init_multi = __esm({ "src/multi.ts"() {
  import_stream = require("stream");
  Multi = class extends import_stream.Readable { … };
} });

A class whose base is runtime-provided (a stream, an EventEmitter) has no stored class value, so this was refused. The lazy-initializer rewrite now turns such an assignment into a class declaration in the bundle's scope, at the same text position. The initializer body splits around the class into run-once pieces that call each other in order, so the class text stays where it was and only its binding changes. Line structure is preserved.

The move applies only where defining the class earlier can't be observed:

  • the definition runs no code: no static fields, static blocks, computed member names or decorators;
  • the heritage is absent, a builtin module member, or a class hoisted before it;
  • the binding is declared once and assigned only there;
  • the binding is read only inside functions and after the assignment (the class's own methods may name it). esbuild's code reaches those reads only after the initializer ran, the same assumption the interop respelling already makes;
  • the initializer body declares nothing, so its statements can move between functions.

Everything else stays an assignment and keeps its existing treatment.

On-demand workspace packages. npm package selection scanned the startup module order only. A workspace package that the program loads through a literal import() (and that package's own static graph) was never scanned, so its npm dependencies were refused with "its implementation is not part of the compiled module graph". Selection now follows literal import() edges from program modules, including their static imports. Package files stay with the package machinery.

new through a respelled interop binding. new import_x.C() became new require("x").C(), which parses as constructing the require call. The respelling now produces new (require("x")).C().

util.inherits onto a runtime-provided class. util.inherits(Ctor, stream.Readable) is the from2 and multistream shape. It used to pass the class's identity token to the runtime, where Node's argument validation threw ERR_INVALID_ARG_TYPE ("superCtor.prototype must be of type object"). It now fences where it is written, with the extends hint, like the program-class case.

Evidence

  • npm-static case 2588-bundled-esm-classes: real esbuild 0.27 output of a CommonJS entry that requires an ES module whose ES-module importer uses two classes, one over stream.Readable and one over that class. Before this change the package was refused.
  • npm case workspace-dynamic-import: a workspace package loaded through import() whose npm dependency is installed only in the workspace package. Before this change it failed with SC2013.
  • Unit tests, each run against the original bundle under Node:
    • hoisting, including comma-chained assignments and a named class expression that references itself;
    • the shapes that must stay assignments: a static field, an unhoistable base, a read before the assignment, a top-level read;
    • new through an interop binding.
  • Diagnostics snapshot util-inherits-runtime-class.ts.
  • The npm-static suite (121), the npm workspace cases, the diagnostics corpus (200), the compiler unit tests, the corpus selection, pnpm lint, the formatting of the touched files and test:ts7 --baselines-only all pass.
  • Zero-cost gate against Compile polyfill packages that are Node builtins as the builtins #1458: PASS. All 2502 programs (bench, self, corpus) are byte-identical; bench geomean is −0.0% and self −0.5%. The self-hosted compiler builds its IR cleanly, and self-hosting-native-driver.test.ts passes on the re-merged stack top.

🤖 Generated with Claude Code

cramforce and others added 3 commits October 19, 2026 20:33
…pace imports

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 <noreply@anthropic.com>
…sting

The self-hosted compiler needs the same record type on both sides of the
call.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Renumbers this branch's npm case 2572 to 2588, after the renumbered
polyfill cases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vercel

vercel Bot commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
scriptc Ready Ready Preview, v0 Oct 20, 2026 6:52am UTC

@socket-security

socket-security Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addednpm/​gtshout@​1.0.0N/AN/AN/AN/AN/A

View full report

Comment thread packages/compiler/src/frontend/pipeline.ts Outdated
cramforce and others added 2 commits October 19, 2026 22:44
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

This branch was successfully deployed

1 active deployment
Preview — 78dbc31e Deployed Oct 20, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants