Skip to content

[tabs][slider] Exclude the prehydration script from client bundles#5003

Draft
michaldudak wants to merge 9 commits into
mui:masterfrom
michaldudak:tabs-prehydration-script-stub
Draft

[tabs][slider] Exclude the prehydration script from client bundles#5003
michaldudak wants to merge 9 commits into
mui:masterfrom
michaldudak:tabs-prehydration-script-stub

Conversation

@michaldudak

@michaldudak michaldudak commented Jun 10, 2026

Copy link
Copy Markdown
Member

The minified prehydration scripts for Tabs.Indicator and Slider.Thumb are 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 an imports map with a browser condition, 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) whose browser condition resolves to an empty stub and whose default condition resolves to the real script. Conditions in the imports map are honored by Vite/Rollup, webpack 5+, esbuild and Node alike — unlike the legacy browser field 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 own build/esm/package.json ({"type":"module"}), so the entry is injected there as well as at the CJS root.
  • code-infra build strips the imports field 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):

Entry master gzip this PR gzip Δ gzip
@base-ui/react/tabs 12868 12462 −406 B
@base-ui/react/slider 12941 12612 −329 B

Both bundles drop the script entirely.

Correctness

  • Hydration: the component renders the same <script> element with empty content. It already carries suppressHydrationWarning, the inline script has executed during HTML parsing by hydration time, and the element unmounts right after hydration.
  • Node SSR: resolves the default condition → the real script (verified with a Node probe in the esm scope).
  • Tabs + Slider browser tests, typecheck, lint and prettier all pass.
  • Different setups tested in Render Tabs and Slider fixture michaldudak/base-ui-bundler-tests#219

Caveats

  • Webpack 4 does not support the imports field, so it can't resolve #prehydration/*, and a project using Tabs.Indicator or Slider.Thumb won't build under it (the scripts are imported by the components themselves). The workaround is a resolve.alias mapping each specifier to its prehydrationScript.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.
  • publint warns that the nested esm/package.json imports field is ignored by Node.js. Empirically Node v26 resolved it in the SSR probe, but it is non-standard. test:package still exits 0 (warning, not error).
  • On edge SSR runtimes that resolve with browser-ish conditions, the stub would be selected server-side and renderBeforeHydration degrades to a no-op (indicator appears after hydration) — predictable, but worth knowing.
  • A 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 what renderBeforeHydration exists 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 imports map with conditions (and ideally not emitting a nested esm scope that shadows it).

🤖 Generated with Claude Code

@pkg-pr-new

pkg-pr-new Bot commented Jun 10, 2026

Copy link
Copy Markdown

commit: 849178e

@michaldudak michaldudak added the component: tabs Changes related to the tabs component. label Jun 10, 2026
@code-infra-dashboard

code-infra-dashboard Bot commented Jun 10, 2026

Copy link
Copy Markdown

Bundle size

Bundle Parsed size Gzip size
@base-ui/react ▼-2.15KB(-0.46%) ▼-735B(-0.49%)

Details of bundle changes

Performance

Total duration: 1,090.32 ms ▼-363.92 ms(-25.0%) | Renders: 50 (+0) | Paint: 1,641.43 ms ▼-542.95 ms(-24.9%)

Test Duration Renders
Tabs mount (200 instances) 205.78 ms ▼-65.66 ms(-24.2%) 4 (+0)
Checkbox mount (500 instances) 61.93 ms ▼-52.16 ms(-45.7%) 1 (+0)
Slider mount (300 instances) 146.41 ms ▼-48.60 ms(-24.9%) 3 (+0)
Select mount (200 instances) 126.02 ms ▼-48.50 ms(-27.8%) 3 (+0)
Menu mount (300 instances) 122.53 ms ▼-33.46 ms(-21.5%) 2 (+0)

…and 3 more (+4 within noise) — details


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

@netlify

netlify Bot commented Jun 10, 2026

Copy link
Copy Markdown

Deploy Preview for base-ui ready!

Built without sensitive environment variables

Name Link
🔨 Latest commit 788f7b5
🔍 Latest deploy log https://app.netlify.com/projects/base-ui/deploys/6a2bb687e8fe120008167fcd
😎 Deploy Preview https://deploy-preview-5003--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.

@michaldudak

Copy link
Copy Markdown
Member Author

@mui/infra, it seems that Vite does not respect the browser field and should rely on imports instead. Would it be a problem to update the build script to include the imports field so we don't ship unnecessary code to clients?

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>
@michaldudak michaldudak force-pushed the tabs-prehydration-script-stub branch from aa88e08 to 0e11b9d Compare June 10, 2026 12:47
@michaldudak

Copy link
Copy Markdown
Member Author

POC: making the saving real and measurable

I pushed a reworked approach to this branch. The original browser-field version produced 0 B on the size checker, and digging into why turned up a finding worth recording.

Why the browser field showed 0 B

The size checker bundles each entrypoint with Vite (Vite 8 / Rolldown in this repo). I reproduced its exact resolution against the built package: with the browser field present, the prehydration script was still in the bundle. Vite/Rolldown does not honor the object/path-mapping form of the legacy browser field. My initial −400 B figure was measured with esbuild --platform=browser, which does honor it — so the two bundlers disagreed and I trusted the wrong one.

This isn't just a metric blind spot: a large share of consumers bundle with Vite, so they wouldn't have benefited either. The browser field only helps webpack/esbuild.

What works instead: imports map + browser condition

The mechanism honored by Vite/Rollup, webpack, esbuild and Node alike is a subpath import with a browser condition. The component now imports the script via #prehydration/tabs/indicator (and #prehydration/slider/thumb), mapped in imports to { browser: stub, default: real }.

Two non-obvious details:

  1. # imports resolve against the nearest package.json scope. The ESM tree has its own build/esm/package.json ({"type":"module"}), so the entry must be injected there too, not just at the build root. This was the cause of an earlier Rolldown failed to resolve error I hit.
  2. code-infra build strips the imports field. 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.

Results (size checker's own Vite, matched utils builds)

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 has suppressHydrationWarning, the inline script has executed during HTML parsing by then, and the element unmounts right after hydration.
  • Node SSR resolves the default condition → real script (verified with a Node probe in the esm scope).
  • Tabs + Slider browser tests, typecheck, lint, prettier pass.

Caveats

  • publint warns that the nested esm/package.json imports field is ignored by Node.js. Empirically Node v26 resolved it in my SSR probe, but it's non-standard. test:package still 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 what renderBeforeHydration exists 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 = '';

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

@brijeshb42

Copy link
Copy Markdown
Contributor

Heads up, just merged flat build PR. So the paths that you are targeting will change.

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

Copy link
Copy Markdown
Member Author

Done — extracted a shared PrehydrationScript component (internals/PrehydrationScript.tsx) that encapsulates the nonce read, the isHydrating gate and the <script> element, and wired both TabsIndicator and SliderThumb to it. Nice cleanup — it removes the duplicated block from both. Pushed in f07fcb5.

One adjustment from your suggestion, though: I kept the stub as the empty script string rather than making the component itself resolve to null in the browser build.

The reason is hydration. The server emits the <script> element; the current design has the client render the same element with empty content during the hydration pass (suppressHydrationWarning bridges the content difference, and React keeps the already-executed server script — then it unmounts once isHydrating flips to false). If the client renders null there instead, it drops an element the server emitted, which is a hydration mismatch.

I tested both with renderToStringhydrateRoot on React 19:

client render recoverable errors
null (component stubbed) 1
empty <script> element (kept) 0

A recoverable error isn't fatal — React removes the orphaned node — but it fires onRecoverableError on every SSR page using these components, which the current design specifically avoids.

The other consideration: the only thing the null variant would additionally strip from the bundle is useIsHydrating/useCSPContext, but useCSPContext is used by several other components (Select, ScrollArea, …) so it stays anyway, and useIsHydrating is needed by SliderIndicator for the slider entry regardless. So the extra saving would be marginal even setting hydration aside.

So PrehydrationScript renders the empty element during hydration and the #prehydration/* browser condition keeps the script body (the ~1 KB string) out of client bundles — which is where essentially all the saving is. Net after the refactor: tabs −380 B, slider −291 B gzip vs master.

/>
)}
{/* Rendered with the last thumb to ensure all preceding thumbs are already in the DOM. */}
{inset && last && (

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why the enabled prop and not

Suggested change
{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.

@michaldudak michaldudak Jun 10, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@michaldudak michaldudak force-pushed the tabs-prehydration-script-stub branch from f07fcb5 to a90c486 Compare June 10, 2026 14:59
…-script-stub

# Conflicts:
#	packages/react/package.json
@github-actions github-actions Bot removed the PR: out-of-date The pull request has merge conflicts and can't be merged. label Jun 10, 2026
@Janpot

Janpot commented Jun 11, 2026

Copy link
Copy Markdown
Member

The reason is hydration. The server emits the <script> element; the current design has the client render the same element with empty content during the hydration pass (suppressHydrationWarning bridges the content difference, and React keeps the already-executed server script — then it unmounts once isHydrating flips to false). If the client renders null there instead, it drops an element the server emitted, which is a hydration mismatch.

Can't you move suppressHydrationWarning one element up in the tree? It could be a plus for bundle size if not only the script content is tree-shaken, but also most of the mechanics of inserting it.

@michaldudak

Copy link
Copy Markdown
Member Author

Can't you move suppressHydrationWarning one element up in the tree?

AFAIK suppressHydrationWarning is one level deep. It suppresses an element's own text content and attribute mismatches, not structural differences in its children.

@michaldudak

Copy link
Copy Markdown
Member Author

I checked how this works in different bundlers: michaldudak/base-ui-bundler-tests#219
Webpack 4 does not recognize the imports field, so it needs to have an alias specified manually.

@michaldudak michaldudak force-pushed the tabs-prehydration-script-stub branch from 84aa76b to 2de9186 Compare June 12, 2026 07:25
@michaldudak

Copy link
Copy Markdown
Member Author

@mui/infra FYI, it seems that the code-infra canary has a few breaking changes as the CI fails.

@michaldudak michaldudak changed the title [tabs] Exclude the prehydration script from client bundles [tabs][slider] Exclude the prehydration script from client bundles Jun 12, 2026
@Janpot

Janpot commented Jun 12, 2026

Copy link
Copy Markdown
Member

Yeah, looks like eslint update could have caused this. (cc @dav-is)

@michaldudak

Copy link
Copy Markdown
Member Author

There's also an issue with pkg.pr.new publishing: https://github.com/mui/base-ui/actions/runs/27401669523/job/80980946536?pr=5003

@github-actions github-actions Bot added the PR: out-of-date The pull request has merge conflicts and can't be merged. label Jun 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

component: tabs Changes related to the tabs component. PR: out-of-date The pull request has merge conflicts and can't be merged.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants