[code-infra] Build with rolldown: resolve imports + flatten re-exports - #1674
[code-infra] Build with rolldown: resolve imports + flatten re-exports#1674Janpot wants to merge 11 commits into
Conversation
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
Deploy previewBundle sizeTotal Size Change: 🔺+279B(+0.01%) - Total Gzip Change: 🔺+258B(+0.03%) Show details for 69 more bundles@mui/internal-docs-infra/cli parsed: 🔺+210B(+0.03%) gzip: 🔺+257B(+0.10%) PerformanceTotal duration: 16.13 ms -0.56 ms(-3.3%) | Renders: 5 (+0) | Paint: 56.44 ms -9.46 ms(-14.4%)
6 tests within noise — details Metric alarms
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.
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.
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.
`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.
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.
|
The experiment served its purpose. |
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:
./Accordionmeans./Accordion/index.mjs. Today@mui/internal-babel-plugin-resolve-importsreimplements Node/ESM resolution inside a Babel visitor purely to append extensions.export * 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.preserveModulestreeshake: trueBabel config is loaded from the project's own
babel.config.js, per file, so downstream repos' customizations andoverrideskeep applying.MUI_KEEP_ES_MODULESasks 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 inbabel-config.mjschange (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:export * fromin runtime output'use client'directives preserved__esModule+.default)binruns from build outputTests: 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 tomodule.exports = value, silently breakingrequire(...).default. Caught by comparing interop shape against Babel.envName, andbabel.config.jsnever callsapi.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 honourMUI_KEEP_ES_MODULESrather than emitting broken output.@rolldown/plugin-babelis deliberately not used — it takes only inline presets/plugins with noconfigFile, which would force pre-loading config once and baking one file'soverridesin for all files..d.mtstype pass still usesresolve-imports; that plugin is not deleted, only retired from the JS path. Declarations keep their re-export chains — only runtime JS is flattened.