Skip to content

[all components] Use ESM metadata constants#5248

Draft
atomiks wants to merge 3 commits into
mui:masterfrom
atomiks:codex/inline-metadata-enum-members
Draft

[all components] Use ESM metadata constants#5248
atomiks wants to merge 3 commits into
mui:masterfrom
atomiks:codex/inline-metadata-enum-members

Conversation

@atomiks

@atomiks atomiks commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Replace the DataAttributes and CssVars string enums with named ESM exports so application bundlers can inline and tree-shake the values without a custom TypeScript-checker transform.

Changes

  • Converted metadata enum members to ordinary named string exports.
  • Imported metadata modules as namespaces in source while allowing the Rolldown build to emit direct named imports for each consumer.
  • Switched the React and utils package builds to the experimental Rolldown path from @mui/internal-code-infra@988e42d.
  • Removed the checker-backed Babel transform and its tests.
  • Retained the component behavior coverage added by the original version of this PR.

Bundle size

Measured in the same realistic Vite consumer using React and ten Base UI component families. Deltas are relative to the full-enum source built through normal Babel output.

Implementation Vite default Vite + Terser
Full enums baseline baseline
Named ESM exports + Rolldown −911 B gzip −1,067 B gzip
Checker-inlined enums −1,107 B gzip −1,072 B gzip

The full built-package namespace measures 447,379 B parsed / 145,764 B gzip.

Validation

  • pnpm typescript
  • pnpm eslint
  • pnpm test:jsdom --no-watch — 6,817 passing tests
  • React and utils Rolldown builds
  • pnpm --filter @base-ui/react test:package
  • Realistic Vite builds with the default minifier and Terser

Remaining before merge

@atomiks atomiks added performance scope: code-infra Involves the code-infra product (https://www.notion.so/mui-org/5562c14178aa42af97bc1fa5114000cd). scope: all components Widespread work has an impact on almost all components. poc labels Jul 16, 2026
@pkg-pr-new

pkg-pr-new Bot commented Jul 16, 2026

Copy link
Copy Markdown

commit: 86a9f4a

@code-infra-dashboard

code-infra-dashboard Bot commented Jul 16, 2026

Copy link
Copy Markdown

Bundle size

Bundle Parsed size Gzip size
@base-ui/react ▼-2.58KB(-0.58%) ▼-214B(-0.15%)

Details of bundle changes

Performance

Total duration: 1,210.67 ms -62.93 ms(-4.9%) | Renders: 78 (+0) | Paint: 1,881.68 ms -87.95 ms(-4.5%)

Test Duration Renders
Select open (500 options) 56.85 ms 🔺+16.44 ms(+40.7%) 14 (+0)
Slider mount (300 instances) 94.39 ms ▼-30.51 ms(-24.4%) 2 (+0)
Checkbox mount (500 instances) 60.75 ms ▼-23.34 ms(-27.8%) 1 (+0)
Tooltip mount (300 contained roots) 43.35 ms ▼-14.45 ms(-25.0%) 1 (+0)

11 tests within noise — details

Metric alarms

Test Metric Change
Select open (500 options) bench:paint 🔺 +30.87 ms
Select open (500 options) bench:paint#select-open 🔺 +30.87 ms

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

@netlify

netlify Bot commented Jul 16, 2026

Copy link
Copy Markdown

Deploy Preview for base-ui ready!

Name Link
🔨 Latest commit 86a9f4a
🔍 Latest deploy log https://app.netlify.com/projects/base-ui/deploys/6a6235f1ede97d00093d8897
😎 Deploy Preview https://deploy-preview-5248--base-ui.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@atomiks
atomiks requested review from Janpot and brijeshb42 July 16, 2026 05:39
@github-actions github-actions Bot added the PR: out-of-date The pull request has merge conflicts and can't be merged. label Jul 16, 2026
@atomiks
atomiks force-pushed the codex/inline-metadata-enum-members branch from d34678f to 5ff6d87 Compare July 16, 2026 08:26
@github-actions github-actions Bot removed the PR: out-of-date The pull request has merge conflicts and can't be merged. label Jul 16, 2026
@Janpot

Janpot commented Jul 16, 2026

Copy link
Copy Markdown
Member

Checking the diff. Here's my human review:

Imo there are several downsides to this setup

  • it's considerably slower.
  • it clashes with per-file transforms, which is how babel/swc/esbuild/... think about these typescript files, pure type-stripping.
  • It adds critical logic in custom babel plugins, which makes it hard to move around between build tools, i.e. it confines us to the performance profile of babel, which we hope to get away from at some point.
  • It requires careful configuration on the authors' side, and is easily broken by renaming things. Big tech debt.
  • It conflates types with runtime, and introduces type dependent runtime behavior. e.g. an ambient declare enum FooDataAttributes { checked = 'data-checked' } has no runtime object at all, yet FooDataAttributes.checked inlines and "works" under this transform. Could be error prone.
  • End-users using these enums on their end can't benefit from the same optimization.

In short, if code infra has to adopt this complexity, I first want to make sure it's the right solution to the problem. The thing is that our users already have tools in their toolchain that can inline values like this. e.g. terser will take the following

const MyEnum = {
  f: 'foo'
}

export const y = {
 [MyEnum.f]: 'bar'
}

and happily produce

export const y={foo:"bar"};

out of it in your app bundles if you use it. The problem we have and the reason why this is not already automatically happening is that babel typescript emits those enums as

let MyEnum = function (MyEnum) {
  MyEnum["f"] = "foo";
  return MyEnum;
}({});
export const y = {
 [MyEnum.f]: 'bar'
}

and terser doesn't know how to inline this. It will produce

export const y={[function(y){return y.f="foo",y}({}).f]:"bar"};

Moreover, different compilers emit different things for these enums, and exactly because they hold runtime semantics based on types, typescript suggests to keep away from them (erasableSyntaxOnly). So I wonder, is it possible for us to express these "enums" as plain old javascript objects with as const? With the right configuration, users machines can yield the same output, at no extra tech debt. (to note: not all minifiers inline these by default, but real perf-conscious users should know how to configure.)

The only real downsides I see

  • we don't have the install-size win, but I don't see a good argument to optimize for this unless it's a really big win.
  • we currently have the enums doing double duty as a value, and as a type. If we want to preserve that behavior, but still move to a erasable syntax, we're going to need to emulate it with a slightly less ergonomic shape. (note we're using namespaces here, but in a type erasable way)

edit: Just noticed that the pattern with ... in the playground prevents inlining as well, so copying the properties may be better

@brijeshb42

Copy link
Copy Markdown
Contributor

With this, I can see scenarios were the end-users final bundle has more size than before where the code could be -

import a from './a.js';
//...
style={{[a.b]: '43px', [a.c]: '100px'}}

but is now -

style={{['--accordion-panel-height']: '43px', ['--accordion-panel-width']: '100px'}}

@Janpot

Janpot commented Jul 16, 2026

Copy link
Copy Markdown
Member

With this, I can see scenarios were the end-users final bundle has more size than before where the code could be

👍 and this supports exactly why this is best left as a concern for the minifier stage on the end-user build pipeline. The minifier can measure and decide whether it will be an overall optimization or not, based on the usage across the full end-user bundle.

@atomiks

atomiks commented Jul 16, 2026

Copy link
Copy Markdown
Contributor Author

@Janpot @brijeshb42 thanks for checking here.

I don't think leaving the consumer to optimize is realistic, most will not do it or know how to do it.

With this, I can see scenarios were the end-users final bundle has more size than before where the code could be -

I can't measure an actual huge realistic app, but I checked empirically by measuring a Vite app that includes React and uses 10 Base UI components.

All numbers below are gzip deltas (each variant was built as a published package and then bundled through Vite)

Implementation Vite default Vite + Terser
All enum refs, normal Babel output baseline baseline
Current hybrid master (needs enumSync tests, split source of truth) −968 B −943 B
All enum refs, checker-inlined −1,107 B −1,072 B
One as const object per enum −176 B −906 B
Named ESM string exports −944 B −990 B

"Named ESM string export" means defining each value as an ordinary JavaScript binding:

export const checked = 'data-checked';
export const unchecked = 'data-unchecked';

This is similar to the REASONS/reason-parts.ts file, where we could import them as a module that remains treeshakable. Would this be a decent alternative to the enums without needing to do checker-inlining?

@Janpot

Janpot commented Jul 16, 2026

Copy link
Copy Markdown
Member

I don't think leaving the consumer to optimize is realistic, most will not do it or know how to do it.

Yes, I think I'd like to frame this more as "optimizing for statically analyzable code, which underlying tooling will be able to pick up on more and more over time is a more durable long term solution". Or "unless there is a significant win, it may be more durable to leave this an end-user tooling concern and optimize for machine-readability".

Would this be a decent alternative to the enums without needing to do checker-inlining?

Yes, absolutely. Interesting differences in output for vite esbuild defaults:

Surprised to find it also perfectly inlines certain patterns. I don't see a reason why it couldn't inline the export * as ... variant the same way at some point in the future. Could be valuable submitting a fix on their end.

A few notes:

  • gzip size is a good comparison, because it can optimize inlined string constants very well.
  • but, deltas from a baseline for each method may not be fully representative, 1 baseline may already be more optimized to take further duplication than another, best to compare absolute values to each other.
  • we have been having trouble with export * as and tree-shaking before.
  • One interesting optimization we could look into for our own code is to resolve away those export * as calls and replace with import * as everywhere they're used. Maybe even just resolve all re-exports altogether. Need to make sure it's side-effect free though. It should have the same effect as your PR here under vite defaults. Opening build tooling: resolve re-exports mui-public#1673

edit: just realized vite isn't built on esbuild anymore, but ok...

edit 2: Interestingly, rolldown seems to inline both patterns, not sure why you don't see a higher delta on vite defaults with the named exports pattern. 🤔

@github-actions github-actions Bot added the PR: out-of-date The pull request has merge conflicts and can't be merged. label Jul 20, 2026
@Janpot

Janpot commented Jul 23, 2026

Copy link
Copy Markdown
Member

Prototyped a version of our build tool that resolves internal export * to their import * at the source. In case you want to try if it improves on the "Named ESM string exports".

pnpm add https://pkg.pr.new/mui/mui-public/@mui/internal-code-infra@988e42d

@atomiks atomiks changed the title [all components] Inline metadata enums at build time [all components] Use ESM metadata constants Jul 23, 2026
@atomiks

atomiks commented Jul 23, 2026

Copy link
Copy Markdown
Contributor Author

I tried with named ESM string exports & the Rolldown build resolves the namespace re-exports as intended. Compared with full enums using normal Babel output in the same realistic 10-comp Vite app:

Implementation Vite default Vite + Terser
Full enums baseline baseline
Named exports + Rolldown −911 B −1,067 B
Checker-inlined enums −1,107 B −1,072 B

So it works. But a blocker is docs generation, the current pipeline only extracts DataAttributes and CssVars from enums. Aside from that, it looks functional

@atomiks
atomiks force-pushed the codex/inline-metadata-enum-members branch from 8e063c3 to 86a9f4a Compare July 23, 2026 15:40
@github-actions github-actions Bot removed the PR: out-of-date The pull request has merge conflicts and can't be merged. label Jul 23, 2026
Janpot added a commit to mui/mui-public that referenced this pull request Jul 23, 2026
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).
Janpot added a commit to mui/mui-public that referenced this pull request Jul 24, 2026
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.
@Janpot

Janpot commented Jul 24, 2026

Copy link
Copy Markdown
Member

It's only a few bytes but still interesting how the inlined variant wins compressed. Did some benchmarking of the different solutions myself, and it seems to roughly reproduce.

                                      realistic                             full
    strategy                    raw         gz         br          raw         gz         br
                           ────────────────────────────────   ────────────────────────────────
    baseline (enums)        249,580     81,328     68,877      451,742    146,632    120,035
    esm+rolldown+inliner     −1,282 *       +7       −113       −2,742       −746 *     −768 *
    esm+rolldown             −1,102       +206        +98       −3,477 *     −335       −338
    esm+babel                −1,047       −222 *     −206       −3,137       −603       −542 
    enum+checker-inline        −598       −145       −211 *     −1,588       −534       −609

2 benchmarks, a 10 BUI component page, and a page with every component. Built with vite+rolldown-vite, gzip size and brotli size. Because anyone who cares about shaving off <1kb on a 100+ kb bundle would have already set up their env with best in class minifier and best in class compression.

  • baseline: the enums, built with babel
  • esm+rolldown+inliner: this PR, enums replaced by const exports, built with rolldown flattening export * and a inliner on top that inline constant strings exports if they match "data-*". For this I added an inliner plugin on top of the rolldown version of our build tool (js-based, no type checker). (see [code-infra] Build with rolldown: resolve imports + flatten re-exports mui-public#1674)
  • esm+rolldown: the above, without the inliner
  • esm+babel: the current PR, built with babel
  • enum+checker-inline: your initial version, enums but compiled with a typescript based inliner

We see on the small app the rolldown variants are roughly neutral. the babel-built consts and the checker inliner take small gz/br wins.
For the large app it's the new rolldown based inliner that seems to take the edge.

Conclusions:

  1. This is deep micro-optimizing territory, like fractions of a percent. While we see that simply using a different compression algorithm can improve over-the-wire size by >10%.
  2. Moving to a real bundler allows us to add optimizations that span multiple files without relying on type checking. It can stay fully within JS.
  3. erasable type syntax is the real winner IMO, it's what opens up a range of optimizations on our side, but also downstream. An inliner would be more effective if it runs on the whole end-user bundle instead of our lib only.

@Janpot

Janpot commented Jul 24, 2026

Copy link
Copy Markdown
Member

So it works. But a blocker is docs generation, the current pipeline only extracts DataAttributes and CssVars from enums. Aside from that, it looks functional

Updating in mui/mui-public#1713

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

performance poc scope: all components Widespread work has an impact on almost all components. 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.

3 participants