Turbopack: compact re-export registration - #98617
Draft
sokra wants to merge 7 commits into
Draft
Conversation
Adds `S` to the module context, a shorter spelling of the pattern a facade or
re-export-only module emits today:
var ns = context.i(moduleId)
context.s([exportName, () => ns[importedName], ...])
becomes
context.S([moduleId, exportName, importedName])
The argument is a flat list of groups, each a module id followed by that
module's entries and terminated by the `0` sentinel, so one call can cover
several modules. Entries are `exportName, importedName` pairs, except when a
group holds a single string, which is then a comma-joined list of those pairs --
worth it when nothing needs quoting individually. The producer only picks that
spelling when no name contains a comma, since the names are recovered by
splitting on it.
Modules are instantiated in list order at the point of the call, and the
delegation to `esmExport` keeps `__esModule`, `Symbol.toStringTag`,
`namespaceObject` and getter liveness identical to `context.s`.
No code generation emits `S` yet, so the output is unchanged. The execution
fixtures cover the evaluation-order invariants any future producer has to
respect: a re-export interleaved with a plain import, a module whose every
import is a re-export source, and a re-export followed by a top-level body
statement.
Co-authored-by: Tobias Koppers <1365881+sokra@users.noreply.github.com>
Co-authored-by: Luke Sandberg <210140+lukesandberg@users.noreply.github.com>
…eads in `a.S`
Infrastructure for the compact re-export registration. **No producer emits
`a.S` yet**, and no imports are suppressed -- both are deliberate, and land
together in a follow-up.
Adds `ImportMap::export_registration_mode`, classifying a module as:
- `Normal` -- has local exports, or no re-exports: keep `a.s`.
- `Mixed` -- only re-exports, but an import *follows* one. The imports must
stay to preserve evaluation order, so `a.S` will refer to the
namespace objects they already bound.
- `Reexport`-- only re-exports, and no import follows one. `a.S` can take
module ids and subsume the imports entirely.
`references` is populated as the AST is visited, so a reference's index is its
source position and the classification is an index comparison. A
side-effect-only `import './x'` carries a reference but no imported binding, so
it is not a re-export source and correctly forces `Mixed`.
`esmReexport` now accepts both group-head forms, dispatching on `typeof`: an
already-imported namespace object is used directly, a module id is instantiated
via `context.i`. This also fixes two defects in the previous commit, which a
filtered test run had missed:
- the runtime snapshots were never regenerated after `esmReexport` was added,
so 7 snapshot tests were failing;
- `esmReexport` did not typecheck -- `EsmImport` declares an
`allowExportDefault` parameter that `esmImport` does not take. Passed
explicitly with a comment; correcting the stale declaration is a follow-up.
The snapshot churn here is exactly that regeneration: every changed fixture
inlines `[turbopack]_runtime.js`, and the additions are the new function, its
doc comment and the resulting source-map/debug-id re-encoding. No generated
user-module code changes, since nothing consumes the classifier yet.
Six fixtures cover the classification cases, all asserting real evaluation
order via a shared array rather than inspecting emitted text: the mixed
re-export/import/re-export case, all-re-exports, imports-before-re-exports,
a re-export followed by a top-level statement, a side-effect-only import, and a
snapshot capturing the mode 3 baseline the producer must change.
Co-authored-by: Fleet Dev Agent <fleet@vercel.com>
Co-authored-by: Tobias Koppers <1365881+sokra@users.noreply.github.com>
Co-authored-by: Luke Sandberg <210140+lukesandberg@users.noreply.github.com>
Uses the three-mode classification to actually emit `TURBOPACK_ESM_REEXPORT`,
replacing one arrow function per re-exported binding with a single call, and --
where evaluation order allows -- the imports it forwards from as well.
A module in `Mixed` or `Reexport` mode whose every export is a plain, immutable
forward of a named binding out of an in-graph module registers them grouped by
source module:
var ns = a.i(<id>);
a.s(["a", ()=>ns["a"], "b", ()=>ns["b"]]);
becomes, in `Reexport` mode, where nothing has to run between the imports:
a.S([<id>, "a,a,b,b"]);
and in `Mixed` mode -- where an import follows a re-export and so must stay
where it is -- the group head is the namespace variable that import already
bound, so nothing is instantiated twice.
Group order follows the import references' source positions, which is what the
runtime instantiates in when the heads are module ids. Anything that does not
fit the shape (a mutable re-export, a scope-hoisted local binding, an external,
a namespace re-export, a dynamic `export *`) falls back to the general
registration.
The imports are only subsumed when nothing else needs them where they are: the
mode already guarantees no import follows a re-export, and a re-exported module
that is *also* bound to a name the module's own code uses keeps its import.
Suppression is keyed on the namespace variable the registration makes
redundant, not on reference identity -- `esm_references` is not one-to-one with
import reference indices (free-variable references are prepended and rewritten
ones interleaved), so an index- or Vc-based match silently misses some of the
references that declare the same variable.
`esmReexport` also takes the scope-hoisting module id now, so a merged module's
exports land on the right object, exactly as `esmExport` does.
Worth knowing for anyone measuring this: a hand-written barrel module is not
emitted at all in a normal Next.js build, because `follow_reexports` binds
importers straight to the final module. The shape this optimizes therefore
shows up mainly for *synthetic* re-export modules that re-export following does
not apply to -- above all the facade the export-mangling change generates.
Co-authored-by: Fleet Dev Agent <fleet@vercel.com>
Co-authored-by: Tobias Koppers <1365881+sokra@users.noreply.github.com>
Co-authored-by: Luke Sandberg <210140+lukesandberg@users.noreply.github.com>
… the guard
Writing the fixtures the format actually needs turned up two gaps.
Nothing exercised the **pair encoding**. It is what the comma-joined spelling
falls back to when a name contains a comma, which is only reachable through a
string export name (`export { value as 'has,comma' }`). Added as a snapshot
fixture, which emits the pairs individually as intended. The equivalent
execution fixture is worth having too, but it scope-hoists into local bindings
and so exercises that fallback rather than this one.
Nothing produced a **multi-group call**, so the `0` separator between groups --
and the runtime loop that consumes it -- was never run. Added a two-source
snapshot fixture for the emitted form, and an execution fixture (with scope
hoisting off) that asserts the values arrive across the separator and that the
groups evaluate in source order.
That execution fixture then failed to use the compact form at all, which
exposed the second gap: a facade whose exports come from more than one module
has no source positions to order its groups by, and the guard bailed outright.
Bailing is stricter than necessary. Group order only matters when the
registration performs the imports itself; with namespace-object heads it
instantiates nothing and cannot reorder anything. So an unknown order now
falls back to namespace heads instead of to the general registration, which
both fixes the coverage gap and lets multi-source facades compact --
1004 registration sites across the fixtures, up from 991.
Co-authored-by: Fleet Dev Agent <fleet@vercel.com>
Co-authored-by: Tobias Koppers <1365881+sokra@users.noreply.github.com>
Co-authored-by: Luke Sandberg <210140+lukesandberg@users.noreply.github.com>
The execution fixtures were passing through the scope-hoisted LocalBinding fallback, so they did not exercise the compact re-export path their assertions were meant to cover. Disable scope hoisting in each fixture so the modules stay separate and the runtime form is actually emitted. Also applies the clippy cleanup needed by the strict-factory base branch: extract a name for the four-element CodeModuleIdsAndPaths tuple. CI denies warnings, so the inherited type_complexity warning otherwise fails every branch stacked on it. Uses Iterator::find from the back in the mode classifier to satisfy clippy, and removes a stale unknown-position guard that was accidentally carried down from the synthetic-facade layer. Ordinary modules always have their ImportMap and therefore know their source positions; the facade-specific fallback belongs in the branch above, where synthetic modules exist. Co-authored-by: Fleet Dev Agent <fleet@vercel.com> Co-authored-by: Tobias Koppers <1365881+sokra@users.noreply.github.com> Co-authored-by: Luke Sandberg <210140+lukesandberg@users.noreply.github.com>
…ntext too Grouping and suppression used the namespace variable's name alone, but two merged modules can import the same target under the same generated name in different syntax contexts -- which is exactly why the import code generation includes the context in its own dedup key. Sharing a group between them would read the wrong variable, and suppressing on the name alone would drop both declarations while only one is subsumed. Key both on `(name, ctxt)`. No emitted output changes today; this closes the gap before a scope-hoisting arrangement can reach it. Co-authored-by: Fleet Dev Agent <fleet@vercel.com> Co-authored-by: Tobias Koppers <1365881+sokra@users.noreply.github.com> Co-authored-by: Luke Sandberg <210140+lukesandberg@users.noreply.github.com>
Co-authored-by: Fleet Dev Agent <fleet@vercel.com> Co-authored-by: Tobias Koppers <1365881+sokra@users.noreply.github.com> Co-authored-by: Luke Sandberg <210140+lukesandberg@users.noreply.github.com>
sokra
force-pushed
the
fleet/turbopack-compact-reexport-registration
branch
from
September 13, 2026 19:37
af0f0e3 to
7b42ee6
Compare
Contributor
Tests PassedCommit: 7b42ee6 |
sokra
added this pull request to stack #98618
September 13, 2026 19:42
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds a compact Turbopack runtime path for modules whose exports are entirely forwarded from other modules. This replaces one generated arrow-function getter per binding with a flat re-export description, while preserving live-binding semantics.
The analyzer classifies modules into three modes:
The runtime accepts either module ids or existing namespace objects as group heads, supports multiple source modules separated by
0, and uses a comma-packed representation when no export name contains a comma. Modules with async imports or real export-cycle circuit breakers retain the existing path because compact registration cannot preserve their initialization ordering.This branch provides the reusable runtime and ordinary-module implementation. The stacked namespace-materialization PR consumes it for synthetic facade modules, where it removes the repeated import variable and per-binding getter boilerplate.
Verification
cargo test -p turbopack-ecmascript -p turbopack-tests— 565 crate, 286 execution, and 128 snapshot tests passedcargo clippy -p turbopack-ecmascript -p turbopack-core -- -D warningscargo fmt --all -- --check0group separatorbench/basic-app, scope hoisting disabled: ordinary hand-written barrel modules are eliminated by re-export following, so this lower layer produces no meaningful application-level delta by itself; the stacked facade integration is where the runtime path is exercised at scale