diff --git a/docs/public/_redirects b/docs/public/_redirects
index 3766b34f249..28077448c71 100644
--- a/docs/public/_redirects
+++ b/docs/public/_redirects
@@ -5,6 +5,7 @@
# For links that we can't edit later on, for example hosted in the code published on npm
/r/discord https://discord.com/invite/g6C3hUtuxz 302
+/r/invalid-render-prop /react/handbook/composition 302
# Legacy redirection
# Added in chronological order (the last line is the most recent one)
diff --git a/packages/react/src/utils/useRenderElement.test.tsx b/packages/react/src/utils/useRenderElement.test.tsx
index 9600ae45bef..74e5b6698ef 100644
--- a/packages/react/src/utils/useRenderElement.test.tsx
+++ b/packages/react/src/utils/useRenderElement.test.tsx
@@ -1,7 +1,9 @@
/* eslint-disable testing-library/render-result-naming-convention */
import * as React from 'react';
import { expect } from 'chai';
+import { vi } from 'vitest';
import { createRenderer } from '#test-utils';
+import { reactMajor } from '@mui/internal-test-utils';
import type { BaseUIComponentProps } from '../utils/types';
import { useRenderElement } from './useRenderElement';
@@ -70,4 +72,187 @@ describe('useRenderElement', () => {
expect(element?.getAttribute('style')).to.equal('padding: 10px;');
});
+
+ describe('render prop', () => {
+ it('accepts render as a function that receives props and state', async () => {
+ const renderFn = vi.fn((props, state) => {
+ return ;
+ });
+
+ const { container } = await render(
+ ,
+ );
+
+ const element = container.firstElementChild;
+
+ expect(renderFn.mock.calls.length).to.be.greaterThan(0);
+ const [firstCallProps, firstCallState] = renderFn.mock.calls[0];
+ expect(firstCallProps).to.include({
+ className: 'test-component',
+ 'data-testid': 'custom',
+ });
+ expect(firstCallProps.style).to.deep.equal({ padding: '10px' });
+ expect(firstCallState).to.deep.equal({ active: true });
+ expect(element?.tagName).to.equal('SPAN');
+ expect(element).to.have.attribute('data-testid', 'custom');
+ expect(element).to.have.attribute('data-active', 'true');
+ });
+
+ it('accepts render as a React element and clones it with merged props', async () => {
+ const CustomElement = React.forwardRef>(
+ function CustomElement(props, ref) {
+ return ;
+ },
+ );
+
+ const { container } = await render(
+ } data-testid="custom" />,
+ );
+
+ const element = container.firstElementChild;
+
+ expect(element?.tagName).to.equal('SPAN');
+ expect(element).to.have.attribute('data-testid', 'custom');
+ expect(element).to.have.attribute('data-active', 'true');
+ });
+
+ it('forwards ref to render element', async () => {
+ const CustomElement = React.forwardRef>(
+ function CustomElement(props, ref) {
+ return ;
+ },
+ );
+
+ const ref = React.createRef();
+ const { container } = await render(} />);
+ const element = container.firstElementChild;
+ expect(ref.current).to.equal(element);
+ });
+
+ it('merges className from render element and component props', async () => {
+ const { container } = await render(
+ }
+ />,
+ );
+
+ const element = container.firstElementChild;
+
+ expect(element?.className).to.contain('component-class');
+ expect(element?.className).to.contain('render-class');
+ expect(element?.className).to.contain('test-component');
+ });
+
+ it('merges className function with render element', async () => {
+ const { container } = await render(
+ (state.active ? 'active-class' : '')}
+ render={}
+ />,
+ );
+
+ const element = container.firstElementChild;
+
+ expect(element?.className).to.contain('active-class');
+ expect(element?.className).to.contain('render-class');
+ expect(element?.className).to.contain('test-component');
+ });
+
+ it('merges style from render element and component props', async () => {
+ const { container } = await render(
+ }
+ />,
+ );
+
+ const element = container.firstElementChild as HTMLElement;
+ expect(element.style.padding).to.equal('10px');
+ expect(element.style.color).to.equal('rgb(255, 0, 0)');
+ expect(element.style.fontSize).to.equal('16px');
+ });
+
+ it('merges style function with render element', async () => {
+ const { container } = await render(
+ ({ color: state.active ? 'rgb(255, 0, 0)' : 'rgb(0, 0, 0)' })}
+ render={}
+ />,
+ );
+
+ const element = container.firstElementChild as HTMLElement;
+ expect(element.style.padding).to.equal('10px');
+ expect(element.style.color).to.equal('rgb(255, 0, 0)');
+ expect(element.style.fontSize).to.equal('16px');
+ });
+
+ it('handles lazy elements', async () => {
+ const LazyComponent = React.lazy(() =>
+ Promise.resolve({
+ default: React.forwardRef>(
+ function LazyDiv(props, ref) {
+ return ;
+ },
+ ),
+ }),
+ );
+
+ const { container } = await render(
+ Loading…}>
+ } />
+ ,
+ );
+
+ const element = container.firstElementChild;
+ expect(element).to.not.equal(null);
+ expect(element?.getAttribute('data-testid')).to.equal('lazy');
+ expect(element?.getAttribute('data-lazy')).to.equal('true');
+ expect(element?.className).to.contain('test-component');
+ });
+
+ // React 18 also log console error, React 19 fixed that. Ignoring this test for React 18.
+ it.skipIf(reactMajor < 19)(
+ 'throws error for invalid render element in development',
+ async () => {
+ const originalEnv = process.env.NODE_ENV;
+
+ let error: Error | null = null;
+ try {
+ process.env.NODE_ENV = 'development';
+ await render();
+ } catch (err) {
+ error = err as Error;
+ } finally {
+ process.env.NODE_ENV = originalEnv;
+ }
+
+ expect(error).to.not.equal(null);
+ expect(error?.message).to.match(
+ /Base UI: The `render` prop was provided an invalid React element/,
+ );
+ },
+ );
+
+ it('handles render element with existing ref', async () => {
+ const CustomElement = React.forwardRef>(
+ function CustomElement(props, ref) {
+ return ;
+ },
+ );
+
+ const renderRef = React.createRef();
+ const componentRef = React.createRef();
+
+ await render(} />);
+
+ expect(renderRef.current).to.be.instanceOf(HTMLDivElement);
+ expect(componentRef.current).to.be.instanceOf(HTMLDivElement);
+ expect(renderRef.current).to.equal(componentRef.current);
+ });
+ });
});
diff --git a/packages/react/src/utils/useRenderElement.tsx b/packages/react/src/utils/useRenderElement.tsx
index fba40178c9d..db7aa09993e 100644
--- a/packages/react/src/utils/useRenderElement.tsx
+++ b/packages/react/src/utils/useRenderElement.tsx
@@ -104,6 +104,12 @@ function useRenderElementProps<
return outProps;
}
+// The symbol React uses internally for lazy components
+// https://github.com/facebook/react/blob/a0566250b210499b4c5677f5ac2eedbd71d51a1b/packages/shared/ReactSymbols.js#L31
+//
+// TODO delete once https://github.com/facebook/react/issues/32392 is fixed
+const REACT_LAZY_TYPE = Symbol.for('react.lazy');
+
function evaluateRenderProp(
element: IntrinsicTagName | undefined,
render: BaseUIComponentProps['render'],
@@ -116,7 +122,36 @@ function evaluateRenderProp(
}
const mergedProps = mergeProps(props, render.props);
mergedProps.ref = props.ref;
- return React.cloneElement(render, mergedProps);
+
+ let newElement = render;
+
+ // Workaround for https://github.com/facebook/react/issues/32392
+ // This works because the toArray() logic unwrap lazy element type in
+ // https://github.com/facebook/react/blob/a0566250b210499b4c5677f5ac2eedbd71d51a1b/packages/react/src/ReactChildren.js#L186
+ if (newElement?.$$typeof === REACT_LAZY_TYPE) {
+ const children = React.Children.toArray(render);
+ newElement = children[0] as BaseUIComponentProps['render'];
+ }
+
+ // There is a high number of indirections, the error message thrown by React.cloneElement() is
+ // hard to use for developers, this logic provides a better context.
+ //
+ // Our general guideline is to never change the control flow depending on the environment.
+ // However, React.cloneElement() throws if React.isValidElement() is false,
+ // so we can throw before with custom message.
+ if (process.env.NODE_ENV !== 'production') {
+ if (!React.isValidElement(newElement)) {
+ throw /* minify-error-disabled */ new Error(
+ [
+ 'Base UI: The `render` prop was provided an invalid React element as `React.isValidElement(render)` is `false`.',
+ 'A valid React element must be provided to the `render` prop because it is cloned with props to replace the default element.',
+ 'https://base-ui.com/r/invalid-render-prop',
+ ].join('\n'),
+ );
+ }
+ }
+
+ return React.cloneElement(newElement, mergedProps);
}
if (element) {
if (typeof element === 'string') {