[tabs][slider] Exclude the prehydration script from client bundles#5003
[tabs][slider] Exclude the prehydration script from client bundles#5003michaldudak wants to merge 9 commits into
Conversation
commit: |
Bundle size
PerformanceTotal duration: 1,090.32 ms ▼-363.92 ms(-25.0%) | Renders: 50 (+0) | Paint: 1,641.43 ms ▼-542.95 ms(-24.9%)
…and 3 more (+4 within noise) — details Check out the code infra dashboard for more information about this PR. |
✅ Deploy Preview for base-ui ready!Built without sensitive environment variables
To edit notification comments on pull requests, go to your Netlify project configuration. |
|
@mui/infra, it seems that Vite does not respect the |
Each prehydration script is a string constant imported unconditionally by its component, so it ships in the JS bundle of every consumer — yet it only ever executes from server-rendered HTML. Route the import through a `#prehydration/*` subpath import whose `browser` condition resolves to an empty stub. Conditions in the `imports` map are honored by Vite/Rollup, webpack, esbuild and Node alike — unlike the legacy `browser` field object map, which Vite ignores (and which the size checker, being Vite-based, could not even measure). `code-infra build` strips the `imports` field, so a post-build step re-injects `#prehydration/*` into both the CJS root and the ESM tree's package.json scopes (`#` imports resolve against the nearest scope). This is a stopgap fork of the build; the clean fix is first-class support in code-infra. Measured with the size checker's own Vite on the built package: - @base-ui/react/tabs: 35361 -> 34287 parsed, 12868 -> 12462 gzip (-406 B) - @base-ui/react/slider: stub selected, script dropped from the bundle During hydration the component renders the same `<script>` element with empty content; it already has `suppressHydrationWarning`, the inline script has run during HTML parsing by then, and the element unmounts right after hydration. Node SSR resolves the `default` condition and emits the real script. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
aa88e08 to
0e11b9d
Compare
POC: making the saving real and measurableI pushed a reworked approach to this branch. The original Why the
|
| Entry | master gzip | POC gzip | Δ gzip |
|---|---|---|---|
@base-ui/react/tabs |
12868 | 12462 | −406 B |
@base-ui/react/slider |
12941 | 12612 | −329 B |
Both bundles drop the script entirely. The CI size check on this PR should now reflect it (unlike the browser-field version). Covers both prehydration scripts via one wildcard entry, so it scales to future ones without touching the fork.
Correctness
- Hydration: the component renders the same
<script>with empty content; it already hassuppressHydrationWarning, the inline script has executed during HTML parsing by then, and the element unmounts right after hydration. - Node SSR resolves the
defaultcondition → real script (verified with a Node probe in theesmscope). - Tabs + Slider browser tests, typecheck, lint, prettier pass.
Caveats
- publint warns that the nested
esm/package.jsonimportsfield is ignored by Node.js. Empirically Node v26 resolved it in my SSR probe, but it's non-standard.test:packagestill exits 0 (warning, not error). - The rejected alternative — a conditional dynamic
import()on the server — defeats itself:import()is async while the server render needs the string synchronously, so the first render per process emits an empty script (reproduced in both a Node test and the docs app's first SSR request). Cold-start renders are exactly whatrenderBeforeHydrationexists for.
Recommendation
The gain is real and worth it, but the build fork is a demonstration, not a shippable mechanism. The proper path is code-infra preserving an imports map with conditions (and ideally not emitting a nested esm scope that shadows it). This PoC is the argument for that investment.
| // only ever executes from server-rendered HTML, so client bundles don't need its contents — | ||
| // during hydration the thumb renders the same `<script>` element with empty content, | ||
| // which React adopts without patching. | ||
| export const script = ''; |
There was a problem hiding this comment.
I'll look into adding package.json import conditions support.
As a small optimization. Have you considered a PrehydrationScript component that encapsulates reading the nonce from context as well as the isHydrating logic? Then you can render it statically in prehydrationScript.min.ts and just a null in this stub.
There was a problem hiding this comment.
It seems that it doesn't support extra conditions like browser in the imports map ("Unsupported import. Only a string or mui-src object supported")
There was a problem hiding this comment.
Looking into it. globs are tricky, we're expanding them for codesandbox (I believe, right @brijeshb42 ?).
@michaldudak Did you check whether codesandbox supports conditional imports?
There was a problem hiding this comment.
globs are tricky
I suppose I could just list all the scripts manually, so it's not a blocker we they're not supported.
Did you check whether codesandbox supports conditional imports?
It seems it doesn't :/
https://codesandbox.io/p/sandbox/4mjx6h?file=%2Fsrc%2FApp.tsx%3A23%2C4
There was a problem hiding this comment.
It seems it doesn't :/
Yeah, I'm beginning to think that we should just build and ship our own sandbox environment, which is fast as csb, but not so buggy (or at least debuggable). We don't necessarily need the full Node.js environment that stackblitz supports (for 90% of our use-cases at least)
|
Heads up, just merged flat build PR. So the paths that you are targeting will change. |
|
Done — extracted a shared One adjustment from your suggestion, though: I kept the stub as the empty script string rather than making the component itself resolve to The reason is hydration. The server emits the I tested both with
A recoverable error isn't fatal — React removes the orphaned node — but it fires The other consideration: the only thing the So |
| /> | ||
| )} | ||
| {/* Rendered with the last thumb to ensure all preceding thumbs are already in the DOM. */} | ||
| {inset && last && ( |
There was a problem hiding this comment.
Why the enabled prop and not
| {inset && last && ( | |
| {inset && last && renderBeforeHydration && ( |
? Then you can make <PrehydrationScript script={prehydrationScript} /> static and keep the whole thing out of client side bundles, including the PrehydrationScript component itself.
There was a problem hiding this comment.
Claude decided to commit, push and engage in a conversation with you while I was away, I see...
Your suggestion does make sense. I'll drop the enabled prop.
Extract the nonce + isHydrating + <script> element into a shared PrehydrationScript component, removing the duplicated logic from TabsIndicator and SliderThumb. Callers gate it structurally (renderBeforeHydration, plus inset/last for the slider); the component only owns the hydration mechanics. The script string is still imported per-component through the #prehydration/* subpath import (stubbed in client bundles). The component keeps rendering the <script> element with empty content on the client during the hydration pass, rather than returning null: the server emits the element, so dropping it on the client triggers a recoverable hydration error (React mui#418, verified in a production build). suppressHydrationWarning bridges the content difference and the element unmounts once isHydrating flips to false. Only the script body is excluded from client bundles, not the component. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
f07fcb5 to
a90c486
Compare
…-script-stub # Conflicts: # packages/react/package.json
Can't you move |
AFAIK |
|
I checked how this works in different bundlers: michaldudak/base-ui-bundler-tests#219 |
84aa76b to
2de9186
Compare
|
@mui/infra FYI, it seems that the code-infra canary has a few breaking changes as the CI fails. |
|
There's also an issue with pkg.pr.new publishing: https://github.com/mui/base-ui/actions/runs/27401669523/job/80980946536?pr=5003 |
The minified prehydration scripts for
Tabs.IndicatorandSlider.Thumbare string constants imported unconditionally by their components, so each ships in the JS bundle of every consumer — yet they only ever execute from server-rendered HTML. This keeps them out of client bundles.Note
This started as a
browser-field approach but that turned out to be invisible to Vite (and the size checker). It now uses animportsmap with abrowsercondition, which required a stopgap fork of the build. See the POC writeup comment for the full investigation; this description reflects the final approach.Mechanism
Each component imports its script through a
#prehydration/*subpath import (#prehydration/tabs/indicator,#prehydration/slider/thumb) whosebrowsercondition resolves to an empty stub and whosedefaultcondition resolves to the real script. Conditions in theimportsmap are honored by Vite/Rollup, webpack 5+, esbuild and Node alike — unlike the legacybrowserfield object map, which Vite ignores (and which the Vite-based size checker could not even measure).Two non-obvious details:
#subpath imports resolve against the nearest package.json scope. The ESM tree carries its ownbuild/esm/package.json({"type":"module"}), so the entry is injected there as well as at the CJS root.code-infra buildstrips theimportsfield from the published package.json. A post-build step (scripts/injectPrehydrationImports.mjs) re-injects#prehydration/*into both scopes. This is a stopgap fork of the build — the clean fix is first-class support in code-infra.A single wildcard entry covers both scripts, so future prehydration scripts need only a stub file plus the import specifier — no build-fork change.
Results
Measured with the size checker's own Vite against the built package (matched utils builds):
@base-ui/react/tabs@base-ui/react/sliderBoth bundles drop the script entirely.
Correctness
<script>element with empty content. It already carriessuppressHydrationWarning, the inline script has executed during HTML parsing by hydration time, and the element unmounts right after hydration.defaultcondition → the real script (verified with a Node probe in theesmscope).Caveats
importsfield, so it can't resolve#prehydration/*, and a project usingTabs.IndicatororSlider.Thumbwon't build under it (the scripts are imported by the components themselves). The workaround is aresolve.aliasmapping each specifier to itsprehydrationScript.stub.js. This is documented in the new Bundler support section on the About page, with cross-links from the Tabs and Slider component pages. Modern bundlers (Vite, Rspack, esbuild, Parcel, Rollup, webpack 5+) and Node are unaffected.esm/package.jsonimportsfield is ignored by Node.js. Empirically Node v26 resolved it in the SSR probe, but it is non-standard.test:packagestill exits 0 (warning, not error).renderBeforeHydrationdegrades to a no-op (indicator appears after hydration) — predictable, but worth knowing.import()on the server — defeats itself:import()is async while the server render needs the string synchronously, so the first render per process emits an empty script (reproduced in both a Node test and the docs app's first SSR request). Cold-start renders are exactly whatrenderBeforeHydrationexists for.Status
This is a proof of concept demonstrating that the saving is real and measurable. The build fork is a demonstration, not a shippable mechanism — the proper path is code-infra preserving an
importsmap with conditions (and ideally not emitting a nestedesmscope that shadows it).🤖 Generated with Claude Code