Skip to content

[Tabs] Replace cloneElement with Context API to support custom and wrapped Tab components #46333

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 30 commits into
base: master
Choose a base branch
from

Conversation

ZeeshanTamboli
Copy link
Member

@ZeeshanTamboli ZeeshanTamboli commented Jun 13, 2025

@ZeeshanTamboli ZeeshanTamboli added component: tabs This is the name of the generic UI component, not the React module! enhancement This is not a bug, nor a new feature labels Jun 13, 2025
@ZeeshanTamboli ZeeshanTamboli changed the title [WIP] Tabs remove clone element fix issue 46265 [WIP] Tabs remove clone element Jun 13, 2025
@mui-bot
Copy link

mui-bot commented Jun 13, 2025

Netlify deploy preview

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

Bundle size report

@mui/materialparsed: 🔺+438B(+0.08%) gzip: 🔺+208B(+0.14%)
@mui/labparsed: 🔺+92B(+0.07%) gzip: 🔺+71B(+0.18%)
@mui/systemparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/utilsparsed: 0B(0.00%) gzip: 0B(0.00%)

Show details for 100 more bundles (86 more not shown)

@mui/material/Tabparsed: 🔺+370B(+0.56%) gzip: 🔺+177B(+0.78%)
@mui/lab/TabListparsed: 🔺+96B(+0.12%) gzip: 🔺+66B(+0.24%)
@mui/material/Tabsparsed: 🔺+96B(+0.12%) gzip: 🔺+40B(+0.15%)
@mui/lab/AdapterDateFnsparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/lab/AdapterDayjsparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/lab/AdapterLuxonparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/lab/AdapterMomentparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/lab/CalendarPickerparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/lab/CalendarPickerSkeletonparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/lab/ClockPickerparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/lab/DatePickerparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/lab/DateRangePickerparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/lab/DateRangePickerDayparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/lab/DateTimePickerparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/lab/DesktopDatePickerparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/lab/DesktopDateRangePickerparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/lab/DesktopDateTimePickerparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/lab/DesktopTimePickerparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/lab/LoadingButtonparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/lab/LocalizationProviderparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/lab/Masonryparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/lab/MobileDatePickerparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/lab/MobileDateRangePickerparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/lab/MobileDateTimePickerparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/lab/MobileTimePickerparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/lab/MonthPickerparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/lab/PickersDayparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/lab/StaticDatePickerparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/lab/StaticDateRangePickerparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/lab/StaticDateTimePickerparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/lab/StaticTimePickerparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/lab/TabContextparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/lab/TabPanelparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/lab/Timelineparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/lab/TimelineConnectorparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/lab/TimelineContentparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/lab/TimelineDotparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/lab/TimelineItemparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/lab/TimelineOppositeContentparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/lab/TimelineSeparatorparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/lab/TimePickerparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/lab/TreeItemparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/lab/TreeViewparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/lab/useAutocompleteparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/lab/YearPickerparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/material/Accordionparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/material/AccordionActionsparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/material/AccordionDetailsparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/material/AccordionSummaryparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/material/Alertparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/material/AlertTitleparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/material/AppBarparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/material/Autocompleteparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/material/Avatarparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/material/AvatarGroupparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/material/Backdropparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/material/Badgeparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/material/BottomNavigationparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/material/BottomNavigationActionparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/material/Boxparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/material/Breadcrumbsparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/material/Buttonparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/material/ButtonBaseparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/material/ButtonGroupparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/material/Cardparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/material/CardActionAreaparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/material/CardActionsparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/material/CardContentparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/material/CardHeaderparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/material/CardMediaparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/material/Checkboxparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/material/Chipparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/material/CircularProgressparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/material/ClickAwayListenerparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/material/Collapseparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/material/Containerparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/material/CssBaselineparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/material/DefaultPropsProviderparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/material/Dialogparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/material/DialogActionsparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/material/DialogContentparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/material/DialogContentTextparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/material/DialogTitleparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/material/Dividerparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/material/Drawerparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/material/Fabparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/material/Fadeparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/material/FilledInputparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/material/FormControlparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/material/FormControlLabelparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/material/FormGroupparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/material/FormHelperTextparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/material/FormLabelparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/material/GlobalStylesparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/material/Gridparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/material/GridLegacyparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/material/Growparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/material/Iconparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/material/IconButtonparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/material/ImageListparsed: 0B(0.00%) gzip: 0B(0.00%)

Details of bundle changes

Generated by 🚫 dangerJS against 31ebdea

@ZeeshanTamboli ZeeshanTamboli changed the title [WIP] Tabs remove clone element [Tabs] Remove clone element Jun 16, 2025
@ZeeshanTamboli ZeeshanTamboli changed the title [Tabs] Remove clone element [Tabs] Replace cloneElement with Context API to support custom and wrapped Tab components Jun 16, 2025
@ZeeshanTamboli ZeeshanTamboli marked this pull request as ready for review June 16, 2025 16:51
Comment on lines +222 to +223
if (!hasRegisteredRef.current) {
hasRegisteredRef.current = true;
Copy link
Contributor

Choose a reason for hiding this comment

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

function block inside useState hook runs only once through out component lifecycle (it doesn't run when component re-renders). So curious to understand the purpose of hasRegisteredRef, as it will always be false when this code runs

Copy link
Member Author

@ZeeshanTamboli ZeeshanTamboli Jun 18, 2025

Choose a reason for hiding this comment

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

You're right that the initializer inside useState only runs once per mount. However, in React’s Strict Mode (development only), it's intentionally called twice to detect impure logic. Since registerTab mutates internal state, calling it twice would incorrectly register the tab multiple times, shifting tab indices and breaking the selection or indicator logic.

To avoid this, we guard it with hasRegisteredRef, ensuring registerTab runs only once — even in development. In production, the guard has no effect because the initializer runs only once as expected.

Ideally, the initializer should be pure (per React docs), but we intentionally break that rule here to support SSR — specifically, to precompute tab metadata so that the correct tab is marked selected on the first render (see test case). Without it, we get hydration mismatches..

I considered making registerTab idempotent, but that’s not feasible when we need to assign an implicit value based on the tab's render order. That requires incrementing a shared index counter (childIndexRef) — and we can’t require users to always provide explicit values to Tab without introducing a breaking change.

This approach strikes a balance: it ensures SSR correctness, avoids hydration issues, and works with wrapper components like <Tooltip><Tab /></Tooltip>, while remaining safe under React’s development behavior.

Open to suggestions if you think there's a cleaner way to achieve this.

Copy link
Member

Choose a reason for hiding this comment

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

If I understand it correctly this won't work well if you remove a tab dynamically (as there's no unregister function)

Copy link
Member Author

@ZeeshanTamboli ZeeshanTamboli Jun 19, 2025

Choose a reason for hiding this comment

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

If I understand it correctly this won't work well if you remove a tab dynamically (as there's no unregister function)

It won't. But it isn't supported even in latest version.

This PR: https://stackblitz.com/edit/ry4fan5c-t3b4771r
Master: https://stackblitz.com/edit/ry4fan5c-oqugmytq

Copy link
Member

@DiegoAndai DiegoAndai Jun 20, 2025

Choose a reason for hiding this comment

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

Using the register pattern without an unregister function sounds to me like it can introduce weird edge cases. Did you consider registering/unregistering on an effect instead?

Copy link
Member

Choose a reason for hiding this comment

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

Registering during an effect phase will prevent it from running on the server. Perhaps we could register both during rendering and in an effect and make the register operation idempotent (or register conditionally if it hasn't been registered yet). This will allow the use of the unregister function in the effect cleanup.

Copy link
Member Author

@ZeeshanTamboli ZeeshanTamboli Jun 23, 2025

Choose a reason for hiding this comment

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

Yes, registering during the effect phase would prevent it from running on the server, which breaks SSR.

Perhaps we could register both during rendering and in an effect and make the register operation idempotent (or register conditionally if it hasn't been registered yet). This will allow the use of the unregister function in the effect cleanup.

That could work well only if tabs always have explicit value props. But in our case, we also support implicit values based on render order, like this:

const [tab, setTab] = React.useState(1);

const handleChange = (event, newValue) => {
  setTab(newValue);
};

return (
  <Tabs value={tab} onChange={handleChange}>
    <Tab label="one" />
    <Tooltip title="two helper">
      <Tab label="two" />
    </Tooltip>
  </Tabs>
);

Here, tabs derive their value from their render position (i.e., first tab = 0, second = 1), using an internal childIndexRef.

If we register both during render and in an effect:

  • In React Strict Mode (dev only), render and effect each run twice i.e total of 4 registrations.
  • Even without Strict Mode, a single tab would be registered twice. (one in first render and second in effect).

So, there would be 4 child indexes.

While registerTab is already idempotent when used with explicit values (via valueToIndex.has(finalValue)), we can't enforce value on Tab without introducing a breaking change.

Copy link
Member

Choose a reason for hiding this comment

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

Deriving the value from the position sounds good to me 👍🏼

While registerTab is already idempotent when used with explicit values (via valueToIndex.has(finalValue)), we can't enforce value on Tab without introducing a breaking change.

But if we create a value for the position on our side, then we would have a finalValue, no?

Copy link
Member Author

Choose a reason for hiding this comment

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

Deriving the value from the position sounds good to me 👍🏼

This logic was already present when cloneElement was used. Just picked that up here.

But if we create a value for the position on our side, then we would have a finalValue, no?

Yes, we do generate a finalValue based on position internally when value isn't provided. The issue is that this implicit value depends on render order and a shared index (childIndexRef), which is incremented during registration.

If we allow registerTab to run multiple times — as suggested above, in both render and effect — the index keeps increasing, and the same tab ends up with different finalValues across renders. That breaks selection and causes hydration mismatches.

With explicit value, we don’t rely on index state, so idempotency is safe. But for implicit values, the act of generating finalValue is tied to mutable state — so calling registerTab multiple times isn’t safe unless we move to require explicit values, which would be a breaking change.

Copy link
Member

@DiegoAndai DiegoAndai left a comment

Choose a reason for hiding this comment

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

Thanks for working on this @ZeeshanTamboli, it looks to me like a clear improvement on the implementation.

I added some initial questions.

When we release this, we should do it under a minor version just to be cautious about changes that might be perceived as breaking.

Did you add test for all the issues this would close?

label,
onChange,
Copy link
Member

Choose a reason for hiding this comment

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

I worry this could be perceived as breaking. Could we keep it?

Copy link
Member Author

Choose a reason for hiding this comment

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

Nice catch! Updated in 225cafd.

@ZeeshanTamboli
Copy link
Member Author

ZeeshanTamboli commented Jun 18, 2025

@DiegoAndai

When we release this, we should do it under a minor version just to be cautious about changes that might be perceived as breaking.

Agreed 👍

Did you add test for all the issues this would close?

Not for every case individually, but I believe this test covers the behavior broadly across the scenarios. Let me know if not.

Copy link
Contributor

@sai6855 sai6855 left a comment

Choose a reason for hiding this comment

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

@ZeeshanTamboli I think this change (225cafd) needs to be reverted.

In previous implementation, when onChange is added to both Tabs and Tab, onChange of Tabs always overrides onChange of Tab, check here for implementation =>

In this version, onChange of Tabs doesn't override onChange of Tab thus resulting in broken behaviour of Tabs when both Tabs and Tab has onChange handler.

Check this example built on top of this PR: https://stackblitz.com/edit/ry4fan5c?file=src%2FDemo.tsx, try to change tabs it doesn't work.

I'm aware this is extremely niche use case, not sure if it's really a valid use case, but just wanted to bring it your attention

@DiegoAndai
Copy link
Member

@ZeeshanTamboli I think we should add additional tests for:

(The one we already have covers #46265)

@ZeeshanTamboli
Copy link
Member Author

@ZeeshanTamboli I think this change (225cafd) needs to be reverted.

In previous implementation, when onChange is added to both Tabs and Tab, onChange of Tabs always overrides onChange of Tab, check here for implementation =>

In this version, onChange of Tabs doesn't override onChange of Tab thus resulting in broken behaviour of Tabs when both Tabs and Tab has onChange handler.

Check this example built on top of this PR: https://stackblitz.com/edit/ry4fan5c?file=src%2FDemo.tsx, try to change tabs it doesn't work.

I'm aware this is extremely niche use case, not sure if it's really a valid use case, but just wanted to bring it your attention

👍 Reverted. I think there is no breaking change then cc @DiegoAndai

@DiegoAndai
Copy link
Member

@ZeeshanTamboli I agree with @sai6855 that onChange behavior shouldn't change: when onChange is added to both Tabs and Tab, onChange of Tabs overrides the one on Tab. We should keep that, but we shouldn't remove the prop from Tab. Does this make sense?

@ZeeshanTamboli
Copy link
Member Author

ZeeshanTamboli commented Jun 19, 2025

@ZeeshanTamboli I agree with @sai6855 that onChange behavior shouldn't change: when onChange is added to both Tabs and Tab, onChange of Tabs overrides the one on Tab. We should keep that, but we shouldn't remove the prop from Tab. Does this make sense?

@DiegoAndai I think we can safely remove the onChange prop from Tab.

In both the current version (master) and this PR:

  • ✅ If onChange is provided only to <Tabs>, it’s called.
  • ❌ If onChange is provided only to <Tab>, it’s not called. The undefined onChange from Tabs takes priority. And also <Tab> cannot be used as a standalone component without <Tabs>.
  • ✅ If provided to both, the one from <Tabs> takes priority.

You can see this behavior is consistent in both versions:

@ZeeshanTamboli
Copy link
Member Author

@ZeeshanTamboli I think we should add additional tests for:

(The one we already have covers #46265)

@DiegoAndai Addressed in f47c38e

Copy link
Contributor

@sai6855 sai6855 left a comment

Choose a reason for hiding this comment

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

LGTM

@@ -375,7 +376,7 @@ const Tabs = React.forwardRef(function Tabs(inProps, ref) {
scrollbarWidth: 0,
});

const valueToIndex = React.useRef(new Map()).current;
const valueToIndex = useLazyRef(() => new Map()).current;
Copy link
Member

@michaldudak michaldudak Jun 20, 2025

Choose a reason for hiding this comment

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

() => new Map() can be extracted to a function in the module scope so a new one isn't instantiated every time this code runs.

Copy link
Member Author

Choose a reason for hiding this comment

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

Done in acc42c2

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
component: tabs This is the name of the generic UI component, not the React module! enhancement This is not a bug, nor a new feature
Projects
None yet
5 participants