Skip to content

Commit 1ce86e2

Browse files
authored
Fix hydration issue with Tab component (#1393)
* fix hydration issues with Tabs component * update changelog
1 parent 807ae66 commit 1ce86e2

File tree

2 files changed

+69
-49
lines changed

2 files changed

+69
-49
lines changed

CHANGELOG.md

+3-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased - @headlessui/react]
99

10-
- Nothing yet!
10+
### Fixed
11+
12+
- Fix hydration issue with `Tab` component ([#1393](https://github.com/tailwindlabs/headlessui/pull/1393))
1113

1214
## [Unreleased - @headlessui/vue]
1315

packages/@headlessui-react/src/components/tabs/tabs.tsx

+66-48
Original file line numberDiff line numberDiff line change
@@ -23,15 +23,15 @@ import { render, Features, PropsForFeatures, forwardRefWithAs } from '../../util
2323
import { useId } from '../../hooks/use-id'
2424
import { match } from '../../utils/match'
2525
import { Keys } from '../../components/keyboard'
26-
import { focusIn, Focus } from '../../utils/focus-management'
26+
import { focusIn, Focus, sortByDomNode } from '../../utils/focus-management'
2727
import { useIsoMorphicEffect } from '../../hooks/use-iso-morphic-effect'
2828
import { useSyncRefs } from '../../hooks/use-sync-refs'
2929
import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
3030
import { useLatestValue } from '../../hooks/use-latest-value'
3131
import { FocusSentinel } from '../../internal/focus-sentinel'
3232

3333
interface StateDefinition {
34-
selectedIndex: number | null
34+
selectedIndex: number
3535

3636
orientation: 'horizontal' | 'vertical'
3737
activation: 'auto' | 'manual'
@@ -71,8 +71,29 @@ let reducers: {
7171
) => StateDefinition
7272
} = {
7373
[ActionTypes.SetSelectedIndex](state, action) {
74-
if (state.selectedIndex === action.index) return state
75-
return { ...state, selectedIndex: action.index }
74+
let focusableTabs = state.tabs.filter((tab) => !tab.current?.hasAttribute('disabled'))
75+
76+
// Underflow
77+
if (action.index < 0) {
78+
return { ...state, selectedIndex: state.tabs.indexOf(focusableTabs[0]) }
79+
}
80+
81+
// Overflow
82+
else if (action.index > state.tabs.length) {
83+
return {
84+
...state,
85+
selectedIndex: state.tabs.indexOf(focusableTabs[focusableTabs.length - 1]),
86+
}
87+
}
88+
89+
// Middle
90+
let before = state.tabs.slice(0, action.index)
91+
let after = state.tabs.slice(action.index)
92+
93+
let next = [...after, ...before].find((tab) => focusableTabs.includes(tab))
94+
if (!next) return state
95+
96+
return { ...state, selectedIndex: state.tabs.indexOf(next) }
7697
},
7798
[ActionTypes.SetOrientation](state, action) {
7899
if (state.orientation === action.orientation) return state
@@ -84,10 +105,16 @@ let reducers: {
84105
},
85106
[ActionTypes.RegisterTab](state, action) {
86107
if (state.tabs.includes(action.tab)) return state
87-
return { ...state, tabs: [...state.tabs, action.tab] }
108+
return { ...state, tabs: sortByDomNode([...state.tabs, action.tab], (tab) => tab.current) }
88109
},
89110
[ActionTypes.UnregisterTab](state, action) {
90-
return { ...state, tabs: state.tabs.filter((tab) => tab !== action.tab) }
111+
return {
112+
...state,
113+
tabs: sortByDomNode(
114+
state.tabs.filter((tab) => tab !== action.tab),
115+
(tab) => tab.current
116+
),
117+
}
91118
},
92119
[ActionTypes.RegisterPanel](state, action) {
93120
if (state.panels.includes(action.panel)) return state
@@ -106,9 +133,21 @@ let TabsContext = createContext<
106133
>(null)
107134
TabsContext.displayName = 'TabsContext'
108135

109-
let TabsSSRContext = createContext<MutableRefObject<number> | null>(null)
136+
let TabsSSRContext = createContext<MutableRefObject<{ tabs: string[]; panels: string[] }> | null>(
137+
null
138+
)
110139
TabsSSRContext.displayName = 'TabsSSRContext'
111140

141+
function useSSRTabsCounter(component: string) {
142+
let context = useContext(TabsSSRContext)
143+
if (context === null) {
144+
let err = new Error(`<${component} /> is missing a parent <Tab.Group /> component.`)
145+
if (Error.captureStackTrace) Error.captureStackTrace(err, useSSRTabsCounter)
146+
throw err
147+
}
148+
return context
149+
}
150+
112151
function useTabsContext(component: string) {
113152
let context = useContext(TabsContext)
114153
if (context === null) {
@@ -153,7 +192,7 @@ let Tabs = forwardRefWithAs(function Tabs<TTag extends ElementType = typeof DEFA
153192

154193
let tabsRef = useSyncRefs(ref)
155194
let [state, dispatch] = useReducer(stateReducer, {
156-
selectedIndex: typeof window === 'undefined' ? selectedIndex ?? defaultIndex : null,
195+
selectedIndex: selectedIndex ?? defaultIndex,
157196
tabs: [],
158197
panels: [],
159198
orientation,
@@ -172,38 +211,9 @@ let Tabs = forwardRefWithAs(function Tabs<TTag extends ElementType = typeof DEFA
172211
}, [activation])
173212

174213
useIsoMorphicEffect(() => {
175-
if (state.tabs.length <= 0) return
176-
if (selectedIndex === null && state.selectedIndex !== null) return
177-
178-
let tabs = state.tabs.map((tab) => tab.current).filter(Boolean) as HTMLElement[]
179-
let focusableTabs = tabs.filter((tab) => !tab.hasAttribute('disabled'))
180-
181214
let indexToSet = selectedIndex ?? defaultIndex
182-
183-
// Underflow
184-
if (indexToSet < 0) {
185-
dispatch({ type: ActionTypes.SetSelectedIndex, index: tabs.indexOf(focusableTabs[0]) })
186-
}
187-
188-
// Overflow
189-
else if (indexToSet > state.tabs.length) {
190-
dispatch({
191-
type: ActionTypes.SetSelectedIndex,
192-
index: tabs.indexOf(focusableTabs[focusableTabs.length - 1]),
193-
})
194-
}
195-
196-
// Middle
197-
else {
198-
let before = tabs.slice(0, indexToSet)
199-
let after = tabs.slice(indexToSet)
200-
201-
let next = [...after, ...before].find((tab) => focusableTabs.includes(tab))
202-
if (!next) return
203-
204-
dispatch({ type: ActionTypes.SetSelectedIndex, index: tabs.indexOf(next) })
205-
}
206-
}, [defaultIndex, selectedIndex, state.tabs, state.selectedIndex])
215+
dispatch({ type: ActionTypes.SetSelectedIndex, index: indexToSet })
216+
}, [selectedIndex /* Deliberately skipping defaultIndex */])
207217

208218
let lastChangedIndex = useRef(state.selectedIndex)
209219
useEffect(() => {
@@ -226,14 +236,17 @@ let Tabs = forwardRefWithAs(function Tabs<TTag extends ElementType = typeof DEFA
226236
[state, dispatch]
227237
)
228238

229-
let SSRCounter = useRef(0)
239+
let SSRCounter = useRef({
240+
tabs: [],
241+
panels: [],
242+
})
230243

231244
let ourProps = {
232245
ref: tabsRef,
233246
}
234247

235248
return (
236-
<TabsSSRContext.Provider value={typeof window === 'undefined' ? SSRCounter : null}>
249+
<TabsSSRContext.Provider value={SSRCounter}>
237250
<TabsContext.Provider value={providerBag}>
238251
<FocusSentinel
239252
onFocus={() => {
@@ -308,6 +321,7 @@ let TabRoot = forwardRefWithAs(function Tab<TTag extends ElementType = typeof DE
308321

309322
let [{ selectedIndex, tabs, panels, orientation, activation }, { dispatch, change }] =
310323
useTabsContext('Tab')
324+
let SSRContext = useSSRTabsCounter('Tab')
311325

312326
let internalTabRef = useRef<HTMLElement>(null)
313327
let tabRef = useSyncRefs(internalTabRef, ref, (element) => {
@@ -320,7 +334,11 @@ let TabRoot = forwardRefWithAs(function Tab<TTag extends ElementType = typeof DE
320334
return () => dispatch({ type: ActionTypes.UnregisterTab, tab: internalTabRef })
321335
}, [dispatch, internalTabRef])
322336

337+
let mySSRIndex = SSRContext.current.tabs.indexOf(id)
338+
if (mySSRIndex === -1) mySSRIndex = SSRContext.current.tabs.push(id) - 1
339+
323340
let myIndex = tabs.indexOf(internalTabRef)
341+
if (myIndex === -1) myIndex = mySSRIndex
324342
let selected = myIndex === selectedIndex
325343

326344
let handleKeyDown = useCallback(
@@ -452,11 +470,7 @@ let Panel = forwardRefWithAs(function Panel<TTag extends ElementType = typeof DE
452470
ref: Ref<HTMLElement>
453471
) {
454472
let [{ selectedIndex, tabs, panels }, { dispatch }] = useTabsContext('Tab.Panel')
455-
let SSRContext = useContext(TabsSSRContext)
456-
457-
if (SSRContext !== null && selectedIndex === null) {
458-
selectedIndex = 0 // Should normally not happen, but in case the selectedIndex is null, we can default to 0.
459-
}
473+
let SSRContext = useSSRTabsCounter('Tab.Panel')
460474

461475
let id = `headlessui-tabs-panel-${useId()}`
462476
let internalPanelRef = useRef<HTMLElement>(null)
@@ -470,9 +484,13 @@ let Panel = forwardRefWithAs(function Panel<TTag extends ElementType = typeof DE
470484
return () => dispatch({ type: ActionTypes.UnregisterPanel, panel: internalPanelRef })
471485
}, [dispatch, internalPanelRef])
472486

487+
let mySSRIndex = SSRContext.current.panels.indexOf(id)
488+
if (mySSRIndex === -1) mySSRIndex = SSRContext.current.panels.push(id) - 1
489+
473490
let myIndex = panels.indexOf(internalPanelRef)
474-
let selected =
475-
SSRContext === null ? myIndex === selectedIndex : SSRContext.current++ === selectedIndex
491+
if (myIndex === -1) myIndex = mySSRIndex
492+
493+
let selected = myIndex === selectedIndex
476494

477495
let slot = useMemo(() => ({ selected }), [selected])
478496

0 commit comments

Comments
 (0)