Skip to content

Conversation

theo-sim-dev
Copy link

@theo-sim-dev theo-sim-dev commented Aug 11, 2025

Fixes #39488

Summary

Restore correct first, middle and last styles in ButtonGroup with custom, wrapped, or conditionally rendered Buttons by using runtime registration via context (no JSX/DOM heuristics, no Emotion :first/last-child dependence).

Changes

  • ButtonGroup: add register, unregister, getPosition context; ensuring not to leak these internal props to DOM.
  • Button: register actual root, apply position class from context, merge classes.grouped.

Tests

  • Position classes: single, multiple, wrapped, and hidden children.
  • Button integration: grouped class merge, position class, no unknown-prop warnings.

Others

…OM/JSX

- Add register/unregister/getPosition to ButtonGroupContext
- Button registers its root and applies first/middle/last classes from context
- Merge grouped class from context; no :first-child/DOM-type heuristics
- Works with wrapped/conditional children and custom Button components
- Avoid leaking runtime props to the DOM
- Add unit tests for single child, wrapped children, and dynamic visibility
- Fixes regression since v5.14.9 where custom Buttons broke grouped styles
- Fixes mui#39488
@mui-bot
Copy link

mui-bot commented Aug 11, 2025

Netlify deploy preview

https://deploy-preview-46712--material-ui.netlify.app/

Bundle size report

Bundle Parsed size Gzip size
@mui/material 🔺+724B(+0.14%) 🔺+285B(+0.19%)
@mui/lab 🔺+931B(+0.69%) 🔺+256B(+0.62%)
@mui/system 0B(0.00%) 0B(0.00%)
@mui/utils 0B(0.00%) 0B(0.00%)

Details of bundle changes

Generated by 🚫 dangerJS against def448d

Copy link
Member

@ZeeshanTamboli ZeeshanTamboli left a comment

Choose a reason for hiding this comment

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

@theo-sim-dev The implementation looks promising, and I confirmed it works: https://stackblitz.com/edit/prgp5bra.
I’ve left a few initial comments. Let’s also hear what @siriwatknp thinks.

const id = Symbol('button-group-item');
orderCounter.current += 1;
itemsRef.current.set(id, { node, order: orderCounter.current });
setVersion((v) => v + 1);
Copy link
Member

Choose a reason for hiding this comment

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

Let's use full words as variable names. This improves code readability. Same in multiple other places.

return { id };
}, []);

const unregister = React.useCallback((h) => {
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
const unregister = React.useCallback((h) => {
const unregister = React.useCallback((handler) => {

@@ -284,6 +282,43 @@ const ButtonGroup = React.forwardRef(function ButtonGroup(inProps, ref) {

const classes = useUtilityClasses(ownerState);

// Live set of buttons, in mount/render order
const itemsRef = React.useRef(new Map()); // Map<symbol, { node, order }>
Copy link
Member

Choose a reason for hiding this comment

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

Why itemsRef is not an object, but a Map?

.map(([id, v]) => ({ id, ...v }))
.sort((a, b) => a.order - b.order);

if (arr.length <= 1) {
Copy link
Member

Choose a reason for hiding this comment

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

Shouldn’t this just check for exactly one button?

Suggested change
if (arr.length <= 1) {
if (arr.length === 1) {

In what situation would the group have zero registered buttons? It seems that once a ButtonGroup renders, there’s always at least one child registered; otherwise the group wouldn’t be useful. If 0 ever happened, it would only be during the brief unmount of all children, which isn’t a case that needs position styling anyway.

So arr.length === 1 should be enough.

register: groupRegister,
unregister: groupUnregister,
getPosition: groupGetPosition,
version: groupVersion,
Copy link
Member

Choose a reason for hiding this comment

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

Is the version state only meant to force re-renders? Also, I don’t see groupVersion actually being used inside Button.

const rootRef = React.useRef(null);
const combinedRef = useForkRef(ref, rootRef);

// Opaque handle returned by ButtonGroupContext.register(...)
Copy link
Member

Choose a reason for hiding this comment

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

What do you mean by Opaque handle? What does it mean?


// Register on mount, unregister on unmount.
// Passive effect avoids parent setState during layout-unmount -> re-entrancy loops in dev.
React.useEffect(() => {
Copy link
Member

Choose a reason for hiding this comment

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

I think we should use useEnhancedEffect from utils.

if (!groupRegister || !groupUnregister) {
// Not inside a ButtonGroup, so no need to register
return;
} // not inside a ButtonGroup
Copy link
Member

Choose a reason for hiding this comment

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

Above comment is enough.

Suggested change
} // not inside a ButtonGroup
}

Comment on lines +905 to +908
// position class from getPosition() should be applied after re-render
expect(btn).to.have.class(buttonGroupClasses.firstButton);
expect(btn).not.to.have.class(buttonGroupClasses.middleButton);
expect(btn).not.to.have.class(buttonGroupClasses.lastButton);
Copy link
Member

Choose a reason for hiding this comment

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

I’m not clear on this—if there’s only one Button, why does it still get the firstButton class?

@@ -12,6 +15,15 @@ interface ButtonGroupContextType {
fullWidth?: boolean;
size?: ButtonGroupProps['size'];
variant?: ButtonGroupProps['variant'];

// Child button calls this once when it mounts
register?: (node: HTMLElement | null) => ButtonGroupItemHandle;
Copy link
Member

Choose a reason for hiding this comment

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

Why to support null?

Suggested change
register?: (node: HTMLElement | null) => ButtonGroupItemHandle;
register?: (node: HTMLElement) => ButtonGroupItemHandle;

@ZeeshanTamboli ZeeshanTamboli added scope: button Changes related to the button. type: regression A bug, but worse, it used to behave as expected. labels Aug 18, 2025
@siriwatknp
Copy link
Member

Thank you for submitting the PR but the issue is related to styles. I think we should try to find a solution using CSS first.

Now that we have :has and CSS variables, I think it's highly possible to solve without JavaScript.

@ZeeshanTamboli
Copy link
Member

ZeeshanTamboli commented Aug 29, 2025

@siriwatknp We support Firefox v115, which doesn't support :has selector.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
component: ButtonGroup The React component. scope: button Changes related to the button. type: regression A bug, but worse, it used to behave as expected.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

[material-ui][ButtonGroup] Renders incorrectly when rendering conditionally from custom component.
5 participants