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 35 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

Bundle Parsed Size Gzip Size
@mui/material 🔺+617B(+0.12%) 🔺+297B(+0.20%)
@mui/lab 🔺+169B(+0.13%) 🔺+89B(+0.22%)
@mui/system 0B(0.00%) 0B(0.00%)
@mui/utils 0B(0.00%) 0B(0.00%)

Details of bundle changes

Generated by 🚫 dangerJS against 6456107

@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 Author

@ZeeshanTamboli ZeeshanTamboli Jun 25, 2025

Choose a reason for hiding this comment

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

Because we want to return the unregistering callback from the effect, so it's run on unmount.

But wouldn't the useEffect's setup function do nothing? It would simply return the output given as an input always when doing registerTab(finalValue).

This is not an option for us unless we want to wait for a new major.

Yes, not an option now.

We're already supporting implicit value, aren't we? With or without my suggestion.

Yes, we are already. Just wanted to point out some issues. I thought it would help us to understand.

Copy link
Member

Choose a reason for hiding this comment

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

But wouldn't the useEffect's setup function do nothing?

Only on the first run of the effect. Subsequent runs would unregister (cleanup) and register back. This is the same as having register/unregister in an effect, except that the very first time we already registered inside useState. I don't see an issue with it if register is idempotent 🤔

Copy link
Member Author

Choose a reason for hiding this comment

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

I tried doing this in 7221ea8 but the tests fail. Any idea why? However, it works locally on browser.

Copy link
Member

Choose a reason for hiding this comment

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

Which test failed? What was the message?

Copy link
Member Author

Choose a reason for hiding this comment

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

Tests pass now. The tests run with Strict Mode, which helped me catch the issue that I wasn’t decrementing the child index on tab unregistration.

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?

@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

Copy link
Member

@siriwatknp siriwatknp left a comment

Choose a reason for hiding this comment

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

👏 Great job, let's refine the Tabs context fields and add docs.

selectionFollowsFocus?: TabsProps['selectionFollowsFocus'];
onChange?: TabsProps['onChange'];
textColor?: TabsProps['textColor'];
tabsValue?: TabsProps['value'];
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
tabsValue?: TabsProps['value'];
value?: TabsProps['value'];

I think it's better to use value here, it's a standard name with onChange. The TabsContext will need to be exposed to public.

Comment on lines +14 to +17
registerTab?: (tabValue: TabProps['value']) => {
finalValue: TabProps['value'];
assignedIndex: number;
};
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
registerTab?: (tabValue: TabProps['value']) => {
finalValue: TabProps['value'];
assignedIndex: number;
};
privateRegisterTab?: (tabValue: TabProps['value']) => {
finalValue: TabProps['value'];
assignedIndex: number;
};

I am not sure about this, ignore this suggestion if you think user can build a custom Tab with this API.

Copy link
Member

@siriwatknp siriwatknp left a comment

Choose a reason for hiding this comment

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

I think we need to be clear on https://mui.com/material-ui/react-tabs/#experimental-api.

In my opinion, we should communicate that user can move away from the lab to this new context API.

We should think about this a bit more. I am struggle on what's the structure should be, ideally I want:

<Tabs>
  <TabList>
    <Tab></TabList>
  <TabPanel></TabPanel>
  <TabPanel></TabPanel>
  <TabPanel></TabPanel>
</Tabs>

but not sure if this is possible given the existing Tabs we have.

Probably the best option is to use new prop to change the layout:

<Tabs
  panels={
    <div>
      <TabPanel></TabPanel>
      <TabPanel></TabPanel>
      <TabPanel></TabPanel>
    </div>
  }
>
  <Tab></Tab>
</Tabs>

@ZeeshanTamboli here is my proposal ZeeshanTamboli#3

@ZeeshanTamboli
Copy link
Member Author

ZeeshanTamboli commented Jun 26, 2025

We should think about this a bit more. I am struggle on what's the structure should be, ideally I want:

<Tabs>
  <TabList>
    <Tab></TabList>
  <TabPanel></TabPanel>
  <TabPanel></TabPanel>
  <TabPanel></TabPanel>
</Tabs>

@siriwatknp I think we can do this. We can use the context from Tabs by setting idPrefix in the Tabs context. Then, we can apply aria-controls and id on each Tab, and aria-labelledby and id on eachTabPanels.

Then we can remove the need of the TabContext component from Lab altogether and move TabList and TabPanel components to Material UI.

I think we can even remove TabList. Just have Tabs, Tab and TabPanel with full accessibility support.

@siriwatknp
Copy link
Member

siriwatknp commented Jun 26, 2025

@siriwatknp I think we can do this. We can use the context from Tabs by setting idPrefix in the Tabs context. Then, we can apply aria-controls and id on each Tab, and aria-labelledby and id on eachTabPanels.

Then we can remove the need of the TabContext component from Lab altogether and move TabList and TabPanel components to Material UI.

I think we can even remove TabList. Just have Tabs, Tab and TabPanel with full accessibility support.

In terms of logic I agree, but for the layout, what's your plan to make it work without a breaking change?

The existing Tabs passes the children inside the scroller, so this would not work:

<Tabs>
  <Tab></Tab>
  <Tab></Tab>
  <Tab></Tab>
  <TabPanel></TabPanel>
  <TabPanel></TabPanel>
  <TabPanel></TabPanel>
</Tabs>

@ZeeshanTamboli
Copy link
Member Author

ZeeshanTamboli commented Jun 28, 2025

I think we need to be clear on https://mui.com/material-ui/react-tabs/#experimental-api.

In my opinion, we should communicate that user can move away from the lab to this new context API.

We should think about this a bit more. I am struggle on what's the structure should be, ideally I want:

<Tabs>
  <TabList>
    <Tab></TabList>
  <TabPanel></TabPanel>
  <TabPanel></TabPanel>
  <TabPanel></TabPanel>
</Tabs>

but not sure if this is possible given the existing Tabs we have.

Probably the best option is to use new prop to change the layout:

<Tabs
  panels={
    <div>
      <TabPanel></TabPanel>
      <TabPanel></TabPanel>
      <TabPanel></TabPanel>
    </div>
  }
>
  <Tab></Tab>
</Tabs>

@ZeeshanTamboli here is my proposal ZeeshanTamboli#3

@siriwatknp Thanks for sharing the proposal. There are a lot of file changes so I didn't see it thoroughly.

That said, I think this PR should focus only on fixing the issues with Tabs and Tab, as mentioned in the description.

Supporting TabPanel directly inside Tabs (because now it has the Context) would require changing the API (like your suggested layout), which makes sense but might be better handled in a separate PR — or ideally in a future major version where we can fully and freely refactor the Tabs structure. For now, the Lab components still work well for TabPanel.

This PR is just about improving the core behavior when using Tabs and Tab alone.

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
6 participants