Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/src/app/(docs)/react/components/slider/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,8 @@ import { TypesSlider } from './types';

<TypesSlider.Thumb />

**Using webpack 4?** `Slider.Thumb` needs a small bundler alias to build—see [Bundler support](/react/overview/about#bundler-support). No configuration is required with any other bundler.

export const metadata = {
keywords: [
'React Slider',
Expand Down
2 changes: 2 additions & 0 deletions docs/src/app/(docs)/react/components/tabs/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ import { TypesTabs } from './types';

<TypesTabs.Indicator />

**Using webpack 4?** `Tabs.Indicator` needs a small bundler alias to build—see [Bundler support](/react/overview/about#bundler-support). No configuration is required with any other bundler.

### Panel

<TypesTabs.Panel />
Expand Down
22 changes: 22 additions & 0 deletions docs/src/app/(docs)/react/overview/about/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,28 @@ For the full list of supported browsers, refer to our [.browserslistrc](https://

Base UI supports React 17 and newer versions.

## Bundler support

Base UI uses the [`imports` field](https://nodejs.org/api/packages.html#subpath-imports) in its `package.json` to keep internal helpers out of client bundles—most notably the prehydration scripts used by [`Tabs.Indicator`](/react/components/tabs) and [`Slider.Thumb`](/react/components/slider).
This is supported by Node.js and every actively maintained bundler, including Vite, Rspack, esbuild, Parcel, Rollup, and webpack 5 and newer.

Webpack 4 does not recognize the `imports` field and fails to resolve specifiers such as `#prehydration/tabs/indicator`.
Because these scripts are imported by the components themselves, a project that uses `Tabs.Indicator` or `Slider.Thumb` won't build under webpack 4 without help.
If your toolchain still relies on webpack 4 (for example, Create React App 4), alias each Base UI prehydration import to its browser stub:

```js title="webpack.config.js"
module.exports = {
resolve: {
alias: {
'#prehydration/tabs/indicator': '@base-ui/react/tabs/indicator/prehydrationScript.stub.js',
'#prehydration/slider/thumb': '@base-ui/react/slider/thumb/prehydrationScript.stub.js',
},
},
};
```

The stub files are empty, so the prehydration scripts are excluded from the bundle—the same outcome modern bundlers get from the `browser` condition.

## Community

Visit the [Community](/react/overview/community) page to learn more about ecosystem projects, support channels, and how to stay up to date and contribute.
Expand Down
1 change: 1 addition & 0 deletions docs/src/app/(docs)/react/overview/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ An open-source React component library for building accessible user interfaces.
- Team
- Browser support
- React versions
- Bundler support
- Community

</details>
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@
"@arethetypeswrong/cli": "0.18.2",
"@babel/plugin-transform-react-constant-elements": "7.27.1",
"@base-ui/monorepo-tests": "workspace:*",
"@mui/internal-code-infra": "0.0.4-canary.42",
"@mui/internal-code-infra": "https://pkg.pr.new/mui/mui-public/@mui/internal-code-infra@133b135",
"@mui/internal-netlify-cache": "0.0.3-canary.5",
"@mui/internal-test-utils": "2.0.18-canary.22",
"@next/eslint-plugin-next": "16.1.6",
Expand Down
10 changes: 9 additions & 1 deletion packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,15 @@
},
"imports": {
"#test-utils": "./test/index.ts",
"#formatErrorMessage": "@base-ui/utils/formatErrorMessage"
"#formatErrorMessage": "@base-ui/utils/formatErrorMessage",
"#prehydration/tabs/indicator": {
"browser": "./src/tabs/indicator/prehydrationScript.stub.ts",
"default": "./src/tabs/indicator/prehydrationScript.min.ts"
},
"#prehydration/slider/thumb": {
"browser": "./src/slider/thumb/prehydrationScript.stub.ts",
"default": "./src/slider/thumb/prehydrationScript.min.ts"
}
},
"scripts": {
"prebuild": "rimraf --glob build build-tests published-docs \"*.tsbuildinfo\" && node ./scripts/stagePublishedDocs.mjs",
Expand Down
54 changes: 54 additions & 0 deletions packages/react/src/internals/PrehydrationScript.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
'use client';
import * as React from 'react';
import { useIsHydrating } from '../utils/useIsHydrating';
import { useCSPContext } from './csp-context/CSPContext';

/**
* Renders an inline script that runs before React hydrates, used by components that need
* to position server-rendered content ahead of hydration (e.g. `Tabs.Indicator`,
* `Slider.Thumb`).
*
* The `script` source is imported by the caller through the package's `#prehydration/*`
* subpath import, whose `browser` condition resolves to an empty string — so the script
* body is excluded from client bundles. It only ever executes from server-rendered HTML.
*
* Render this only when the script should be emitted (i.e. gate `renderBeforeHydration`
* and any structural conditions at the call site). The element is still rendered (with
* empty content) on the client during the hydration pass so the React tree matches the
* server markup; `suppressHydrationWarning` bridges the content difference and React keeps
* the already-executed server script. Once `isHydrating` flips to `false` the element
* unmounts.
*
* The component must stay in client bundles: returning `null` on the client (e.g. by
* stubbing the whole component) would drop an element the server emitted and trigger a
* recoverable hydration error (React #418) in consumers' apps. Only the script body is
* excluded from client bundles, via the `#prehydration/*` `browser` condition.
*/
export function PrehydrationScript(props: PrehydrationScript.Props) {
const { script } = props;
const { nonce } = useCSPContext();
const isHydrating = useIsHydrating();

if (!isHydrating) {
return null;
}

return (
<script
nonce={nonce}
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: script }}
suppressHydrationWarning
/>
);
}

export namespace PrehydrationScript {
export interface Props {
/**
* The script source, imported through the `#prehydration/*` subpath import.
* Empty in client bundles.
*/
script: string;
}
}
22 changes: 6 additions & 16 deletions packages/react/src/slider/thumb/SliderThumb.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect';
import { useMergedRefs } from '@base-ui/utils/useMergedRefs';
import { visuallyHidden } from '@base-ui/utils/visuallyHidden';
import { ownerWindow } from '@base-ui/utils/owner';
import { script as prehydrationScript } from '#prehydration/slider/thumb';
import { BaseUIComponentProps } from '../../internals/types';
import { clamp } from '../../internals/clamp';
import { formatNumber } from '../../utils/formatNumber';
Expand All @@ -26,7 +27,7 @@ import {
} from '../../internals/composite/composite';
import { useCompositeListItem } from '../../internals/composite/list/useCompositeListItem';
import { useDirection } from '../../internals/direction-context/DirectionContext';
import { useCSPContext } from '../../internals/csp-context/CSPContext';
import { PrehydrationScript } from '../../internals/PrehydrationScript';
import { useFieldRootContext } from '../../internals/field-root-context/FieldRootContext';
import { matchesFocusVisible } from '../../floating-ui-react/utils/element';
import { type LabelableContext } from '../../internals/labelable-provider/LabelableContext';
Expand All @@ -38,7 +39,6 @@ import type { SliderRootState } from '../root/SliderRoot';
import { useSliderRootContext } from '../root/SliderRootContext';
import { sliderStateAttributesMapping } from '../root/stateAttributesMapping';
import { SliderThumbDataAttributes } from './SliderThumbDataAttributes';
import { script as prehydrationScript } from './prehydrationScript.min';

const ALL_KEYS = new Set([...COMPOSITE_KEYS, PAGE_UP, PAGE_DOWN]);

Expand Down Expand Up @@ -115,7 +115,6 @@ export const SliderThumb = React.forwardRef(function SliderThumb(
...elementProps
} = componentProps;

const { nonce } = useCSPContext();
const id = useBaseUiId(idProp);

const {
Expand Down Expand Up @@ -475,19 +474,10 @@ export const SliderThumb = React.forwardRef(function SliderThumb(
<React.Fragment>
{childrenProp}
<input ref={mergedInputRef} {...inputProps} suppressHydrationWarning />
{inset &&
isHydrating &&
renderBeforeHydration &&
// this must be rendered with the last thumb to ensure all
// preceding thumbs are already rendered in the DOM
last && (
<script
nonce={nonce}
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: prehydrationScript }}
suppressHydrationWarning
/>
)}
{/* Rendered with the last thumb to ensure all preceding thumbs are already in the DOM. */}
{inset && last && renderBeforeHydration && (
<PrehydrationScript script={prehydrationScript} />
)}
</React.Fragment>
),
id,
Expand Down
6 changes: 6 additions & 0 deletions packages/react/src/slider/thumb/prehydrationScript.stub.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// Browser-bundle replacement for `prehydrationScript.min.ts`, selected via the `browser`
// condition of the `#prehydration/*` entry in package.json `imports`. The real script
// 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)

18 changes: 3 additions & 15 deletions packages/react/src/tabs/indicator/TabsIndicator.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,17 @@
'use client';
import * as React from 'react';
import { useForcedRerendering } from '@base-ui/utils/useForcedRerendering';
import { script as prehydrationScript } from '#prehydration/tabs/indicator';
import { useRenderElement } from '../../internals/useRenderElement';
import { getCssDimensions } from '../../utils/getCssDimensions';
import { useIsHydrating } from '../../utils/useIsHydrating';
import { PrehydrationScript } from '../../internals/PrehydrationScript';
import type { BaseUIComponentProps } from '../../internals/types';
import type { TabsRoot, TabsRootState } from '../root/TabsRoot';
import { useTabsRootContext } from '../root/TabsRootContext';
import { tabsStateAttributesMapping } from '../root/stateAttributesMapping';
import { useTabsListContext } from '../list/TabsListContext';
import type { TabsTab } from '../tab/TabsTab';
import { script as prehydrationScript } from './prehydrationScript.min';
import { TabsIndicatorCssVars } from './TabsIndicatorCssVars';
import { useCSPContext } from '../../internals/csp-context/CSPContext';

const stateAttributesMapping = {
...tabsStateAttributesMapping,
Expand All @@ -38,15 +37,11 @@ export const TabsIndicator = React.forwardRef(function TabsIndicator(
...elementProps
} = componentProps;

const { nonce } = useCSPContext();

const { getTabElementBySelectedValue, orientation, tabActivationDirection, value } =
useTabsRootContext();

const { tabsListElement, registerIndicatorUpdateListener } = useTabsListContext();

const isHydrating = useIsHydrating();

const rerender = useForcedRerendering();

React.useEffect(() => {
Expand Down Expand Up @@ -143,14 +138,7 @@ export const TabsIndicator = React.forwardRef(function TabsIndicator(
return (
<React.Fragment>
{element}
{isHydrating && renderBeforeHydration && (
<script
nonce={nonce}
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: prehydrationScript }}
suppressHydrationWarning
/>
)}
{renderBeforeHydration && <PrehydrationScript script={prehydrationScript} />}
</React.Fragment>
);
});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// Browser-bundle replacement for `prehydrationScript.min.ts`, selected via the `browser`
// condition of the `#prehydration/*` entry in package.json `imports`. The real script
// only ever executes from server-rendered HTML, so client bundles don't need its contents —
// during hydration the indicator renders the same `<script>` element with empty content,
// which React adopts without patching.
export const script = '';
Loading
Loading