Skip to content

[all components] Fix render prop leaking the default children fallback - #5716

Open
3o14 wants to merge 4 commits into
mui:masterfrom
3o14:fix/render-prop-default-children
Open

3o14 wants to merge 4 commits into
mui:masterfrom
3o14:fix/render-prop-default-children

Conversation

@3o14

@3o14 3o14 commented Sep 13, 2026

Copy link
Copy Markdown

Fixes #4752

Problem

Several components render a default glyph (e.g. the ▼ arrow, ✔️ checkmark, "x" clear icon) by baking a children key into their internal default props object:

// SelectIcon.tsx (before)
props: [{ 'aria-hidden': true, children: '▼' }, elementProps],

This works correctly when a caller overrides the icon via children, since elementProps.children overwrites the default during the props-array merge.

It does not work when a caller uses render with a childless custom element:

<Select.Icon render={<span className="arrow-drop-down" />} />
// renders: <span class="arrow-drop-down" aria-hidden="true">▼</span>

render's own props ({ className: 'arrow-drop-down' }) have no children key at all, so the later prop merge in evaluateRenderProp (mergeProps(props, render.props)) never overwrites the default — it only overwrites keys that actually exist on render.props. The default ends up cloned into the caller's element.

I found the same pattern in 6 other components (a search keyed only on "aria-hidden" + "children:" initially missed two of them, since not every instance pairs the default with aria-hidden or formats it the same way):

  • Select.ItemIndicator (✔️)
  • Select.ScrollArrow (/)
  • Combobox.Icon ()
  • Combobox.ItemIndicator (✔️)
  • Combobox.Clear ("x")
  • NavigationMenu.Icon ()

Fix

Rather than patch each call site with a render == null && ... guard (easy to forget on the next component that needs default content), this adds a defaultChildren option to useRenderElement itself:

if (enabled && defaultChildren !== undefined && outProps.children === undefined && renderProp == null) {
  outProps.children = defaultChildren;
}

It's applied only on the default-element path, before evaluateRenderProp ever runs — so a render element (with or without its own children) can never receive it. Every component funnels through useRenderElement to render anything, so this is enforced structurally rather than by convention.

Migrated all 7 known instances (SelectIcon, SelectItemIndicator, SelectScrollArrow, ComboboxIcon, ComboboxItemIndicator, ComboboxClear, NavigationMenuIcon) to the new option. mergeProps/evaluateRenderProp are untouched — this only changes what gets fed into them.

Deliberately out of scope: value-display components (Select.Value, Progress.Value, Meter.Value, etc.) also assign children from a props object passed to useRenderElement, but that children is the actual computed value the component exists to display, not a placeholder glyph meant to disappear under render. Migrating those to defaultChildren would change their behavior (the value would stop rendering under a childless render element), which isn't what this fix is about.

Testing

  • 5 new unit tests on useRenderElement covering: default path renders defaultChildren; explicit children still overrides it; a render element with its own children is preserved untouched; a childless render element does not receive it; a render function result does not receive it.

  • Regression tests on SelectIcon, ComboboxIcon, ComboboxItemIndicator, ComboboxClear, and NavigationMenuIcon reproducing the exact issue [select] Using render in Select.Icon do not remove the default icon #4752 scenario (render with a childless custom element).

  • SelectScrollArrow is covered by the same useRenderElement fix but wasn't given a dedicated regression test — its visibility state requires more scroll-geometry setup than seemed proportional here; happy to add one if maintainers would like it.

  • Full existing suite passes with no changes needed (pnpm test:jsdom), including all 39 combobox test files (969 passing).

  • pnpm typescript, pnpm eslint, pnpm prettier all clean.

  • I have followed (at least) the PR section of the contributing guide.

Several components (Select.Icon, Select.ItemIndicator, Select.ScrollArrow,
Combobox.Icon, NavigationMenu.Icon) render a default glyph (e.g. the ▼
arrow) via a `children` key baked into their internal default props object.
When `render` is used with a childless custom element (for example
`<Select.Icon render={<span className="arrow-drop-down" />} />`), that
default glyph is never cleared: `render.props` has no `children` key to
overwrite it during the later prop merge in `evaluateRenderProp`, so the
default text renders inside the caller's element. `children` passed the
normal way already works correctly since it flows through the earlier
prop-array merge.

Adds a dedicated `defaultChildren` option to `useRenderElement` so this
class of bug can't recur: it's applied only on the default-element path
(`render == null`), so it can never reach `render`'s prop merge in the
first place, regardless of whether the render element declares its own
children. Migrates the five known instances of the old pattern to use it.

Fixes mui#4752
Combobox.ItemIndicator and Combobox.Clear bake the same literal default
`children` (the checkmark and "x" glyphs) into their props array that
SelectIcon/SelectItemIndicator/SelectScrollArrow/ComboboxIcon/
NavigationMenuIcon had before this branch's first commit — found by an
independent review after the initial grep (keyed on "aria-hidden" +
"children:") missed these two since ComboboxClear has no aria-hidden and
formats differently. Migrates both to `defaultChildren` and adds the same
issue mui#4752 regression test used for the other five components.

Also adds a small coverage gap fix to useRenderElement.test.tsx: an
explicit assertion that a `render` element which *does* declare its own
children is left untouched (previously only implied by the `renderProp
!= null` gate, never directly asserted).
@pkg-pr-new

pkg-pr-new Bot commented Sep 13, 2026

Copy link
Copy Markdown

commit: 278b4ff

@code-infra-dashboard

code-infra-dashboard Bot commented Sep 13, 2026

Copy link
Copy Markdown

Bundle size

Bundle Parsed size Gzip size
@base-ui/react 🔺+127B(+0.03%) 🔺+55B(+0.04%)

Details of bundle changes

Performance

Total duration: 930.39 ms -55.88 ms(-5.7%) | Renders: 76 (+0) | Paint: 1,473.05 ms -115.24 ms(-7.3%)

Test Duration Renders
Select open (500 options) 36.12 ms ▼-14.42 ms(-28.5%) 14 (+0)

14 tests within noise — details


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

@netlify

netlify Bot commented Sep 13, 2026

Copy link
Copy Markdown

Deploy Preview for base-ui ready!

Built without sensitive environment variables

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

@flaviendelangle flaviendelangle left a comment

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.

The fix make sense to me

@michaldudak

Copy link
Copy Markdown
Member

My concern is that this changes existing behavior: call sites using render only to customize the element would lose the default glyph. We can consider this for v2, but for now I’d recommend explicitly removing the default content: <Select.Icon children={null} render={<span className="arrow-drop-down" />} />

@zannager zannager added the scope: all components Widespread work has an impact on almost all components. label Sep 14, 2026
@michaldudak pointed out that automatically suppressing the default
glyph whenever `render` is used would silently change behavior for
existing call sites that use `render` only to swap the rendered tag,
not to replace its content — a real backward-compatibility risk for a
fix that should just be a patch-level bug fix.

Reverts the `defaultChildren` mechanism and all 7 component migrations
from the previous commit. In its place, documents the existing (already
working) escape hatch on each affected component: pairing `render` with
an explicit `children={null}` already clears the default glyph today,
with no code change needed, because `children: null` is a present key
that overwrites the props-array default during the existing merge.

Replaces the regression tests that asserted "render alone clears the
default" (no longer true, and shouldn't be) with tests asserting the
documented `children={null}` pattern works, plus a general-purpose test
in useRenderElement.test.tsx demonstrating both the default (leaky)
behavior and the opt-out, so this stays covered without baking in the
removed default-children plumbing.
@3o14 3o14 changed the title [all components] Fix render prop leaking the default children fallback [all components] Document the children={null} opt-out for render prop defaults Sep 15, 2026
@3o14 3o14 changed the title [all components] Document the children={null} opt-out for render prop defaults [all components] Fix render prop leaking the default children fallback Sep 15, 2026
@github-actions github-actions Bot added the PR: out-of-date The pull request has merge conflicts and can't be merged. label Sep 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

PR: out-of-date The pull request has merge conflicts and can't be merged. scope: all components Widespread work has an impact on almost all components.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[select] Using render in Select.Icon do not remove the default icon

4 participants