-
-
Notifications
You must be signed in to change notification settings - Fork 32.6k
[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
base: master
Are you sure you want to change the base?
[Tabs] Replace cloneElement
with Context API to support custom and wrapped Tab components
#46333
Conversation
Netlify deploy previewhttps://deploy-preview-46333--material-ui.netlify.app/ Bundle size report
|
cloneElement
with Context API to support custom and wrapped Tab components
if (!hasRegisteredRef.current) { | ||
hasRegisteredRef.current = true; |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 🤔
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this 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?
👍 Reverted. I think there is no breaking change then cc @DiegoAndai |
@ZeeshanTamboli I agree with @sai6855 that |
@DiegoAndai I think we can safely remove the In both the current version (master) and this PR:
You can see this behavior is consistent in both versions: |
@DiegoAndai Addressed in f47c38e |
There was a problem hiding this 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; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done in acc42c2
There was a problem hiding this 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']; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
registerTab?: (tabValue: TabProps['value']) => { | ||
finalValue: TabProps['value']; | ||
assignedIndex: number; | ||
}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
There was a problem hiding this 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
@siriwatknp I think we can do this. We can use the context from Tabs by setting Then we can remove the need of the I think we can even remove |
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>
<Tab></Tab>
<Tab></Tab>
<Tab></Tab>
<TabPanel></TabPanel>
<TabPanel></TabPanel>
<TabPanel></TabPanel>
</Tabs> |
@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 Supporting This PR is just about improving the core behavior when using |
Replace cloneElement with React's Context API without any breaking changes. This closes the following issues:
Tab
not working: Closes [Tabs] Conditionally rendering ofTab
not working properly #34740