Skip to content

[code-infra] Build with rolldown: resolve imports + flatten re-exports - #1674

Closed
Janpot wants to merge 11 commits into
masterfrom
worktree-rolldown-build-spike
Closed

[code-infra] Build with rolldown: resolve imports + flatten re-exports#1674
Janpot wants to merge 11 commits into
masterfrom
worktree-rolldown-build-spike

Conversation

@Janpot

@Janpot Janpot commented Jul 16, 2026

Copy link
Copy Markdown
Member

Related

Let a real bundler own module discovery in code-infra build, while Babel keeps doing the code transformation. Second commit turns it on for this repo's own packages.

Why

The Babel CLI transpiles one file at a time with no module graph, so it can't do two things a bundler does for free:

  1. Resolve imports — it can't know ./Accordion means ./Accordion/index.mjs. Today @mui/internal-babel-plugin-resolve-imports reimplements Node/ESM resolution inside a Babel visitor purely to append extensions.
  2. Flatten re-exportsexport * from './X' ships verbatim, and internal imports keep pointing at barrels, so consumers traverse hops at runtime.

How

rolldown gets one entrypoint per source file plus preserveModules, so output stays 1:1 with the source tree and the existing package.json rewriting keeps working untouched.

Setting Job
entry-per-file every file is public API → nothing can be dropped → treeshaking is safe to leave on
preserveModules makes the 1:1 layout a guarantee, not an emergent property
treeshake: true does the re-export flattening

Babel config is loaded from the project's own babel.config.js, per file, so downstream repos' customizations and overrides keep applying. MUI_KEEP_ES_MODULES asks the shared config to leave module syntax intact and skip the two plugins that only exist to compensate for the missing graph.

Only 2 of the 6 bundle-driven branches in babel-config.mjs change (modules, transform-import-meta); the rest are already correct.

Results

Enabled on all three packages that use code-infra build, through the full pipeline including the type pass:

docs-infra test-utils benchmark
export * from in runtime output 70 → 0 → 0 → 0
barrel hops 94 → 7 0 0
exports map targets resolve 141/141 33/33 9/9
built entrypoints import 10/10 sampled esm+cjs 4/4
  • Layout stays 1:1 (400/400 for docs-infra, plus rolldown's runtime module)
  • Public API identical across all 62 runtime entrypoints
  • 62 'use client' directives preserved
  • test-utils CJS keeps Babel's interop shape (__esModule + .default)
  • docs-infra's bin runs from build output
  • Build time 2.67s → 2.86s
  • With the flag off, output is byte-identical to before

Tests: docs-infra 4867, code-infra 472, benchmark 63, test-utils 12 — all pass.

Notes

  • output.exports: 'named' is required. rolldown's default 'auto' collapses a default-only module to module.exports = value, silently breaking require(...).default. Caught by comparing interop shape against Babel.
  • Babel caches config per envName, and babel.config.js never calls api.cache(). The CLI path never notices because it forks a subprocess per bundle; in-process the env var must be set before the first config load. A startup guard throws if a config doesn't honour MUI_KEEP_ES_MODULES rather than emitting broken output.
  • @rolldown/plugin-babel is deliberately not used — it takes only inline presets/plugins with no configFile, which would force pre-loading config once and baking one file's overrides in for all files.
  • The .d.mts type pass still uses resolve-imports; that plugin is not deleted, only retired from the JS path. Declarations keep their re-export chains — only runtime JS is flattened.

The Babel CLI build transpiles one file at a time with no module graph, so it
cannot resolve import specifiers or flatten re-exports. The first gap is patched
by @mui/internal-babel-plugin-resolve-imports, which reimplements Node/ESM
resolution inside a Babel visitor just to append extensions; the second is not
addressed at all, leaving `export * from './X'` and barrel hops in the output.

Add `code-infra build --experimental-rolldown`, which gives rolldown one
entrypoint per source file plus `preserveModules`, so the output stays 1:1 with
the source tree while rolldown owns module discovery. Babel still transforms
each file, loaded from the project's own babel.config.js so downstream
customizations and overrides keep applying.

The two are reconciled by MUI_KEEP_ES_MODULES, which asks the shared Babel
config to leave module syntax alone and skip the resolution/import.meta plugins
that only exist to compensate for the missing graph. It defaults off, so the
Babel path is byte-for-byte unchanged.

On packages/docs-infra (400 files, esm):
- 400/400 files map 1:1, plus rolldown's runtime module
- `export * from` 70 -> 0, barrel hops 94 -> 7
- exported names identical across all 62 runtime entrypoints
- 62 'use client' directives preserved
- build time 2.67s -> 2.86s
@code-infra-dashboard

code-infra-dashboard Bot commented Jul 16, 2026

Copy link
Copy Markdown

Deploy preview

https://deploy-preview-1674--mui-internal.netlify.app/
QR code for https://deploy-preview-1674--mui-internal.netlify.app/

Bundle size

Total Size Change: 🔺+279B(+0.01%) - Total Gzip Change: 🔺+258B(+0.03%)
Files: 69 total (0 added, 0 removed, 29 changed)

Show details for 69 more bundles

@mui/internal-docs-infra/cliparsed: 🔺+210B(+0.03%) gzip: 🔺+257B(+0.10%)
BuildEngineparsed: 🔺+70B(+0.93%) gzip: 🔺+14B(+0.50%)
CodeHighlighterClientparsed: 🔺+29B(+0.05%) gzip: ▼-1B(0.00%)
@mui/internal-docs-infra/abstractCreateTypesparsed: 🔺+15B(+0.01%) gzip: 🔺+108B(+0.27%)
@mui/internal-docs-infra/abstractCreateDemoparsed: 🔺+5B(+0.08%) gzip: ▼-5B(-0.21%)
CodeHighlighterChunkparsed: 🔺+5B(+0.07%) gzip: ▼-3B(-0.12%)
@mui/internal-docs-infra/ChunkProviderparsed: 🔺+4B(+0.45%) gzip: ▼-1B(-0.21%)
@mui/internal-docs-infra/CoordinatedLazyparsed: 🔺+4B(+0.05%) gzip: ▼-9B(-0.32%)
@mui/internal-docs-infra/useDemoControllerparsed: 🔺+4B(+0.07%) gzip: 🔺+1B(+0.04%)
@mui/internal-docs-infra/useTypesparsed: 🔺+4B(+0.28%) gzip: 🔺+4B(+0.58%)
loadIsomorphicCodeVariantparsed: 🔺+3B(+0.01%) gzip: 🔺+7B(+0.08%)
@mui/internal-docs-infra/CodeHighlighterparsed: 🔺+2B(+0.01%) gzip: 🔺+21B(+0.18%)
CodeSourceLoaderparsed: 🔺+1B(+0.07%) gzip: 🔺+1B(+0.13%)
@mui/internal-docs-infra/CodeProviderparsed: ▼-55B(-0.03%) gzip: ▼-74B(-0.16%)
CodeInitialSourceLoaderparsed: ▼-14B(-0.16%) gzip: ▼-6B(-0.23%)
@mui/internal-docs-infra/withDocsInfraparsed: ▼-4B(-0.04%) gzip: ▼-1B(-0.03%)
compileCssWithPostcssparsed: ▼-3B(0.00%) gzip: 🔺+15B(+0.01%)
parseSourceparsed: ▼-1B(0.00%) gzip: 🔺+9B(+0.04%)
@mui/internal-docs-infra/abstractCreateDemoClientparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/abstractCreateStreamparsed: 0B(0.00%) gzip: ▼-7B(-0.27%)
@mui/internal-docs-infra/CodeControllerContextparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/CodeExternalsContextparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/CodeHighlighter/errorsparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/CodeHighlighter/typesparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/CoordinatedLazy/typesparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/createDemoDataparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/createDemoData/typesparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/createSitemapparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/createSitemap/typesparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/useCodeparsed: 0B(0.00%) gzip: ▼-1B(0.00%)
@mui/internal-docs-infra/useCodeWindowparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/useCoordinatedparsed: 0B(0.00%) gzip: ▼-21B(-0.40%)
@mui/internal-docs-infra/useCopierparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/useCrossTabStateparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/useDemoparsed: 0B(0.00%) gzip: ▼-86B(-0.20%)
@mui/internal-docs-infra/useErrorsparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/useLocalStorageStateparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/usePreferenceparsed: 0B(0.00%) gzip: ▼-3B(-0.38%)
@mui/internal-docs-infra/useScrollAnchorparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/useSearchparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/useSearch/typesparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/useStreamparsed: 0B(0.00%) gzip: ▼-1B(-0.06%)
@mui/internal-docs-infra/useStream/typesparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/useTypeparsed: 0B(0.00%) gzip: 🔺+3B(+0.56%)
@mui/internal-docs-infra/useUrlHashStateparsed: 0B(0.00%) gzip: 0B(0.00%)
addLineGuttersparsed: 0B(0.00%) gzip: 0B(0.00%)
createFrameparsed: 0B(0.00%) gzip: 0B(0.00%)
createParseSourceWorkerClientparsed: 0B(0.00%) gzip: 🔺+2B(+0.21%)
EditingEngineparsed: 0B(0.00%) gzip: 0B(0.00%)
embedTransformsparsed: 0B(0.00%) gzip: 🔺+15B(+0.41%)
enhanceCodeEmphasisparsed: 0B(0.00%) gzip: 0B(0.00%)
findExpandingRangesparsed: 0B(0.00%) gzip: 0B(0.00%)
getHastTextContentparsed: 0B(0.00%) gzip: 0B(0.00%)
grammarLoadersparsed: 0B(0.00%) gzip: 0B(0.00%)
grammarsparsed: 0B(0.00%) gzip: 0B(0.00%)
isFrameSpanparsed: 0B(0.00%) gzip: 0B(0.00%)
rolldown-runtimeparsed: 0B(0.00%) gzip: 0B(0.00%)
source.cssparsed: 0B(0.00%) gzip: 0B(0.00%)
source.jsparsed: 0B(0.00%) gzip: 0B(0.00%)
source.jsonparsed: 0B(0.00%) gzip: 0B(0.00%)
source.mdxparsed: 0B(0.00%) gzip: 0B(0.00%)
source.shellparsed: 0B(0.00%) gzip: 0B(0.00%)
source.tsparsed: 0B(0.00%) gzip: 0B(0.00%)
source.tsxparsed: 0B(0.00%) gzip: 0B(0.00%)
source.yamlparsed: 0B(0.00%) gzip: 0B(0.00%)
text.html.basicparsed: 0B(0.00%) gzip: 0B(0.00%)
text.mdparsed: 0B(0.00%) gzip: 0B(0.00%)
TransformEngineparsed: 0B(0.00%) gzip: 🔺+18B(+0.28%)
transpileSourceparsed: 0B(0.00%) gzip: 🔺+2B(0.00%)

Details of bundle changes

Performance

Total duration: 16.13 ms -0.56 ms(-3.3%) | Renders: 5 (+0) | Paint: 56.44 ms -9.46 ms(-14.4%)

Test Duration Renders
sub-series via labels 0.00 ms 0 (+0)

6 tests within noise — details

Metric alarms

Test Metric Change
sub-series via labels fib_phase#small 🔺 +0.01 ms

Check out the code infra dashboard for more information about this PR.

Turn the experimental path on for the three packages that use `code-infra
build`, so it gets exercised on real published artifacts rather than only in a
one-off comparison.

All three build clean through the full pipeline, including the type pass that
the initial spike skipped:

- generated exports resolve: docs-infra 141/141 targets, test-utils 33/33,
  benchmark 9/9
- built entrypoints import: docs-infra 10/10 sampled, benchmark 4/4
- test-utils CJS keeps Babel's interop shape (__esModule + .default)
- docs-infra's bin runs from build output
- `export * from` is gone from every package's runtime output; docs-infra's
  barrel hops drop 94 -> 7
- 62 'use client' directives still preserved

Declarations still go through the tsc/resolve-imports pass, so .d.mts files keep
their re-export chains. Only runtime JS is flattened.
@Janpot Janpot changed the title [code-infra] Add experimental rolldown build path [code-infra] Build with rolldown: resolve imports + flatten re-exports Jul 16, 2026
Rolldown rebuilds a module's namespace object as `var X_exports =
__exportAll({ a: () => a })` whenever a namespace escapes as a value, because its
linker normally concatenates modules and has to reconstruct what scope hoisting
destroyed. Under preserveModules that premise is false -- every module is its own
file, so the ES module system supplies the namespace for free -- and the rebuilt
object is opaque to downstream bundlers: its getters touch every export, so a
consumer using one property retains all of them.

Rewrite the emitted `import { X_exports } from './x.js'` back to `import * as
X_exports from './x.js'` and drop the synthesized object, which restores a native
namespace the consumer's bundler can see through.

Measured on a simulated 8-part component where the consumer uses one part:
8/8 parts retained (5469 B) -> 1/8 (662 B), matching hand-written native output
to within a byte. On docs-infra it also removes the runtime chunk, so the output
is now exactly 1:1 with src (400 files, no _virtual).

Upstream: rolldown/rolldown#7874, still open and not
fixed in 1.2.0. This is a workaround to delete once rolldown can preserve the
syntax itself.

Guarded four ways, because a silent no-op would ship a bundle that defeats
consumer tree-shaking with no sign anything went wrong:
- shape: no synthesized namespace may survive
- name: no chunk may still import the helper -- deliberately a different axis
  from the shape check, since that detector backs both the rewrite and its own
  guard and would go blind with it
- runtime surface: the runtime chunk may only expose known helpers, so a rename
  is reported
- version: rolldown is pinned, and a bump must be re-validated

Each guard was verified to fire by simulating the corresponding rolldown change.
Janpot added 3 commits July 23, 2026 20:54
Base UI (mui/base-ui#5248) is moving data-attribute names from TS string enums
to named ESM exports -- `export const checked = 'data-checked'` read as
`import * as FooDataAttributes from './metadata'` / `FooDataAttributes.checked`
-- so the value is authored once but referenced everywhere. Under preserveModules
rolldown keeps the cross-module import (it does not inline constants), which ties
every referencing module to the metadata module and defeats consumer
tree-shaking. Base UI previously solved this with a checker-backed Babel transform
in its own repo and removed it; this moves the equivalent into the shared build.

Add a Babel plugin, run in the rolldown transform, that replaces cross-module
`data-*` string-constant references with their literal. A pre-scan collects every
`export const NAME = 'data-...'` up front (order-independent), and the plugin
resolves each relative import against it. Running at transform time means Babel's
scope resolution decides what is the imported binding, so a shadowing
`function f(open) {}` is left alone; rolldown then drops the now-dead import, and
the metadata module falls out of any consumer that only used data attributes.

Handles `import * as ns` member access (`ns.open`, `ns['open']`) and named
imports, removing specifiers/imports that become fully consumed and keeping those
still needed for non-data members. A reference that cannot be resolved or matched
is left untouched -- a missed constant is a smaller optimization, never a
miscompile, so nothing here is fatal.

Verified on a Base-UI-shaped fixture end to end: a consumer using only a
data-attribute pulls the literal and nothing from the metadata module. Real
packages declare no such constants, so they build unchanged (0 inlined).
Master bumped vite to 8.1.5, which moves the transitive rolldown to 1.1.5, so
the direct 1.1.4 pin no longer resolved once merged and the frozen lockfile
check failed with a missing rolldown@1.1.4 entry. Track vite's copy: pin 1.1.5
and reuse the single resolved version instead of forcing a second one.

1.1.5 produces byte-identical namespace and constant output to 1.1.4, so it is
added to the validated set. The inline-constants test no longer pulls
@babel/preset-typescript (an undeclared dependency that only resolved under the
looser hoisting) -- its fixtures are plain ESM and parse without it.
Janpot added 4 commits July 24, 2026 00:36
`binding.referencePaths` includes references a value binding has inside
TypeScript type positions -- the `X` in `typeof X` / `Record<typeof X, string>`.
The inline plugin replaced those too, emitting `typeof "data-..."`, which is
invalid syntax and crashed the build on real Base UI source
(`Record<typeof FOCUSABLE_ATTRIBUTE, string>`).

Skip references whose ancestry passes through a TS type node in both the named
and namespace branches, and keep the import when a type still needs the binding
(preset-typescript then elides it once the value uses are inlined). Value uses
are still inlined as before.
Base UI declares CSS variables the same way it declares data attributes --
`export const popupWidth = '--popup-width'` in a `*CssVars` module, read through
a namespace import -- and they have the same problem: the cross-module reference
keeps every consumer tied to the constants module.

Widen the match from `data-*` to also cover `--*`, and rename the plugin to
metadata constants, the term Base UI uses for both (mui/base-ui#5248). A bare
`--` is excluded: that is the end-of-options marker, not a custom property, so
at least one character after the prefix is required.

Inlining stays safe for the same reason as before -- these are immutable
primitives, so duplicating them at call sites cannot change behaviour.
Base UI shares values between components by re-exporting them:
`export const popupOpen = CommonTriggerDataAttributes.popupOpen`, the const form
of the enum-to-enum references it uses today. The scan only recognised exports
assigned a literal, so every consumer of a forwarding module missed out on
inlining -- which is most of them, since the shared values are the common ones.

Collect forwarding exports alongside literals and resolve them transitively:
namespace-member aliases, named-import aliases, and `export { x } from './y'`.
Chains are followed across modules until a literal is reached, and a cycle
resolves to nothing rather than hanging.

The text prefilter now keys on the export forms rather than the value prefixes,
since a forwarding module need not contain `data-` or `--` at all. That is both
correct for aliases and more selective than the old check -- `--` matches
decrements and comments everywhere -- so the scan is cheaper than before:
docs-infra builds in 4.26s against 5.17s on the previous commit.
Drop the runtime-helper allowlist and the guard built on it. Its stated job --
noticing that rolldown changed -- is already done earlier and more strictly by
the version check, so at the pinned version the only thing it could do was
misfire: rolldown 1.1.5 also emits __esm, __esmMin and __toDynamicImportESM,
none of which were listed, so the first package pulling a lazy CommonJS module
would have failed claiming rolldown's output shape had changed.

Fold the two surviving namespace checks into one pass. They stay independent in
what they match -- shape, and the helper's name -- which is what makes one catch
a change that blinds the other; only the duplicate traversal goes. With a dead
re-parse of every rewritten chunk also removed, chunks are parsed twice rather
than three-plus times, and docs-infra builds in 3.41s against 4.26s.

Share the extension lists that had been copied: both build backends now take
`TO_TRANSFORM_EXTENSIONS` from babel.mjs, so they cannot enumerate different
files, and the constants scanner probes build.mjs's `JS_TS_EXTENSIONS` rather
than its own identical copy.

Also: reject imports that cannot resolve to a constants module with one lookup
instead of building 17 candidate paths, bound the type-position walk at the
enclosing statement, and use the repo's `makeTempDir` in the tests.
@github-actions github-actions Bot added the PR: out-of-date The pull request has merge conflicts and can't be merged. label Jul 27, 2026
From a high-effort code review of the branch:

- Inlining a named import that is also bare re-exported (`import { open };
  export { open }`) crashed the Babel transform, because the `open` inside the
  ExportSpecifier is a reference the plugin rewrote to a string literal, which is
  not a valid ExportSpecifier local. Skip references in an export specifier (and,
  as before, in a type position); the binding is kept for them.

- The synthesized-namespace producer stripped a renamed re-export
  (`export { X_exports as Public }`) that the consumer rewrite, keyed on the
  imported name, never matched -- dangling the consumer import. Rolldown keeps
  the namespace in its own module so this is not known to occur; it is now fatal
  rather than a silent break.

- The namespace-import rewrite folded a co-located default specifier into
  `import { ... }`, turning a default import into a named one. Re-emit each
  specifier with its kind preserved.

- assertKeepsEsModules validated only the first source file; Babel matches
  `overrides` per filename, so a config converting a different file to commonjs
  slipped through. Check per file in the transform hook, which Babel's config
  cache makes free.

- Local asset imports (`./x.css`, `./x.json`) were pulled into the graph and
  parsed as JS; the Babel CLI left them as runtime imports. Externalize relative
  imports whose extension is not JS/TS.

- `export * from` was admitted by the scanner's text guard but never followed, so
  constants re-exported through a barrel were not inlined. Follow bare star
  re-exports transitively (memoized, cycle-safe).

- The metadata scan ran once per bundle over identical source; cache it so the
  concurrent esm and cjs builds share one scan.

- Tighten the runtime-chunk liveness check to match the `_rolldown/runtime` path
  tail rather than the bare basename.
@oliviertassinari
oliviertassinari temporarily deployed to worktree-rolldown-build-spike - code-infra-dashboard PR #1674 August 1, 2026 18:07 — with Render Destroyed
@oliviertassinari
oliviertassinari temporarily deployed to worktree-rolldown-build-spike - mui-tools-public PR #1674 August 1, 2026 18:07 — with Render Destroyed
@Janpot

Janpot commented Aug 27, 2026

Copy link
Copy Markdown
Member Author

The experiment served its purpose.

@Janpot Janpot closed this Aug 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

PR: out-of-date The pull request has merge conflicts and can't be merged. scope: code-infra Involves the code-infra product (https://www.notion.so/mui-org/5562c14178aa42af97bc1fa5114000cd).

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants