Skip to content

Turbopack: compact re-export registration - #98617

Draft
sokra wants to merge 7 commits into
codex/turbopack-strict-chunk-factoriesfrom
fleet/turbopack-compact-reexport-registration
Draft

Turbopack: compact re-export registration#98617
sokra wants to merge 7 commits into
codex/turbopack-strict-chunk-factoriesfrom
fleet/turbopack-compact-reexport-registration

Conversation

@sokra

@sokra sokra commented Sep 13, 2026

Copy link
Copy Markdown
Member

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:

  • normal modules keep the existing export registration;
  • mixed re-export modules keep their source-ordered imports and register through their existing namespace objects;
  • re-export-only modules register by module id and suppress the imports subsumed by that registration.

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 passed
  • cargo clippy -p turbopack-ecmascript -p turbopack-core -- -D warnings
  • cargo fmt --all -- --check
  • Added execution coverage for source ordering, module-body ordering, side-effect-only imports, multiple source groups, and both registration head forms
  • Added snapshot coverage for module-id suppression, pair encoding when export names contain commas, and the 0 group separator
  • bench/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

vercel-fleet-prod Bot and others added 7 commits September 13, 2026 19:37
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
sokra force-pushed the fleet/turbopack-compact-reexport-registration branch from af0f0e3 to 7b42ee6 Compare September 13, 2026 19:37
@github-actions

github-actions Bot commented Sep 13, 2026 •

Copy link
Copy Markdown
Contributor

Tests Passed

Commit: 7b42ee6

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.

1 participant