Skip to content

Compile lazy barrels, star re-exports and npm aliases in bundled packages - #1427

Merged
ctate merged 8 commits into
opt/npm-esm-wrappersfrom
opt/npm-lazy-loaders
Oct 20, 2026
Merged

ctate merged 8 commits into
opt/npm-esm-wrappersfrom
opt/npm-lazy-loaders

Conversation

@cramforce

@cramforce cramforce commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #1422 (which is stacked on #1411). Review those first; this branch is based on opt/npm-esm-wrappers.

Summary

Memoized lazy loaders of bundled namespaces

A bundle plugin can turn a barrel's re-exports into lazy loaders over esbuild's lazily initialized modules:

var import_x, load_import_x = () => import_x ??= __toESM((init_x(), __toCommonJS(x_exports)));
// re-export:  X: () => load_import_x().X
  • Loader reads. Every load_import_x().name becomes the module's initializer call followed by the export's live read. The first access still initializes the module, once, and later accesses read the binding. A loader whose result escapes as a value stays refused.
  • Accessor entries. The canonical export table now keeps a getter it cannot chase as a live accessor. Before, it used the any fallback, so a lazy re-export still initializes its module at the first read. require() reads these accessors live, as Node does.
  • ES-module importers. Node's loader reads every lexer-visible export of a CommonJS module once, when an ES module first links it, and the import bindings keep those values. The importer's link point now runs each accessor once, in the lexer's order, and its import bindings read the stored values.
  • Lexer answer. Node's lexer stops reading an object literal at the first accessor. Whenever the canonical table holds accessors, the rewrite therefore keeps the original file's lexer answer (esbuild's dead annotation).

Star re-exports of lazily initialized modules

__reExport(main, (init_x(), __toCommonJS(x_exports)), module.exports) comes from export * from in a bundle entry. The module now initializes at that statement, and its names join the export table after the explicit ones, without default, which is what __reExport copies.

npm aliases

"x-updated": "npm:x@6.3.0" installs package x under another directory name. When x is selected, an import through the alias now resolves to that copy's JavaScript in both resolvers. Before, scriptc's resolver decided on the specifier's name and answered the copy's .d.ts.

Lowering fixes found in bundled third-party code

  • "use strict" and arguments. Code under "use strict" may read arguments in functions with named parameters. Strict code never aliases them; before, only ES modules were trusted.
  • Comma expressions ending in null. return log(), null no longer leaves a bare unit literal; the literal takes the result's representation. This was an internal compiler error.
  • Void conditional returns. return c ? f() : g() over void calls lowers as control flow. This was an internal compiler error.
  • Detached local-class methods. A receiverless copy of a local class's method no longer keeps an empty class-capture list. This was an internal compiler error on main; see the local-class-detached-method.ts corpus program.

Evidence

New npm corpus cases. All are real esbuild output, using a generic lazy-barrel plugin where needed:

  • 2580-lazy-barrel: an ES-module import of the barrel, plain and whitespace/syntax-minified. Node initializes every module at link and the imports are snapshots.
  • 2581-lazy-barrel-require: require() reads each getter at its read.
  • 2582-npm-alias-two-releases: two releases of one package in a pnpm store layout, the newer one through an alias.

New corpus programs:

  • local-class-detached-method.ts (an internal compiler error on main);
  • comma-null-union-return.cjs (an internal compiler error on main);
  • strict-arguments-parameters.cjs (a runtime fence on main).

Tests.

  • All new cases and programs match Node byte for byte in both the plain and sanitized lanes.
  • All 115 tests in npm-static.test.ts, the compiler unit tests and order-parity pass.
  • Three tests also failed, and they fail independently of this change:
    • npm.test.ts > node24-url-authority and library-mode K10 under ASan also fail on main in the sandbox;
    • one toolchain cache test timed out under load and passes on its own.

Checks.

  • Zero-cost gate against origin/main: PASS. All 2464 programs (bench, self, corpus) are byte-identical; bench time geomean −2.8% and self +1.5%, within tolerance.
  • pnpm lint, pnpm format:check and test:ts7 --baselines-only pass; baselines are recorded for the new entries.
  • The self-hosted compiler builds its IR cleanly, and self-hosting-native-driver.test.ts passes.

🤖 Generated with Claude Code

cramforce and others added 4 commits October 19, 2026 15:55
A loader `L = () => M ??= NS` over a lazily initialized module's
namespace (optionally through __toESM) is respelled at every `L().name`
as the module's initializer call followed by the export's live read;
loaders whose result escapes as a value stay refused. The canonical
export table keeps a getter it cannot chase as a live accessor, so a
lazy re-export still initializes its module at the first read. An
ES-module importer copies those accessors once when it links the
CommonJS module, in the lexer's order, exactly like Node's loader;
require() keeps reading them live.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…wering fixes

- A star re-export of a lazily initialized module into the bundle's
  exports (__reExport(main, (init_x(), __toCommonJS(x_exports))))
  initializes the module at the statement and adds its names to the
  export table after the explicit ones, without default.
- The rewrite keeps the original's lexer answer whenever its export
  table holds accessors (Node's lexer stops at the first one).
- An npm alias directory of a selected package ("x-updated":
  "npm:x@6.3.0") resolves to that package's JavaScript in both resolvers.
- "use strict" code may read arguments in functions with parameters.
- A comma expression ending in null/undefined coerces its result.
- A void conditional return lowers as control flow.
- A detached method of a local class drops its empty capture list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 8:08am UTC

Comment thread packages/compiler/src/frontend/npm-static-bundled-esm.ts Outdated
The numbers collided with other open changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Renumbers this branch's npm cases 2564-2566 to 2580-2582: #1411 now uses
2564-2566.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread packages/compiler/src/frontend/npm-static-bundled-esm.ts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Lazy loaders that share a var statement each removed their declarators
on their own, and declarators of one statement removed separately leave
overlapping edits, of which applyEdits keeps the first. A later loader
in the statement (one using __toESM, say) stayed behind and the interop
pass refused the package. Every removed declarator of the pass (the
interop requires, the loaders and the unused export tables) now goes
into one set, removed once.

A call through a loader (`load().fn()`) respelled the callee as the
export's live read, which drops the namespace receiver. Loader calls now
take the rule namespace calls already use: the callee must be a
top-level function that never reads `this`; other calls keep the package
refused.
@ctate
ctate merged commit 9d10ae1 into opt/npm-esm-wrappers Oct 20, 2026
48 of 64 checks passed
@ctate
ctate deleted the opt/npm-lazy-loaders branch October 20, 2026 08:12

This branch was successfully deployed

1 active deployment
Preview — 66d20103 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