Skip to content

Commit 3501289

Browse files
Fix hydration of components inside <Suspense> (#2633)
* Only use useServerHandoffComplete in React < 18 It’s only useful for the useId hook. It is not compatible with `<Suspense>` because hydration is delayed then. * Make sure portals first render matches the server by rendering nothing Since Portals cannot SSR the first render MUST also return `null`. React really needs an `isHydrating` API. * Lazily resolve root containers This fixes a problem where clicks were assumed to be outside because of the delayed `<Portal>` render. The second portal render doesn’t cause the dialog to re-render thus the initial ref values were stale. * Update changelog --------- Co-authored-by: Robin Malfait <[email protected]>
1 parent 4f6f67c commit 3501289

File tree

9 files changed

+37
-123
lines changed

9 files changed

+37
-123
lines changed

packages/@headlessui-react/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111

1212
- Use correct value when resetting `<Listbox multiple>` and `<Combobox multiple>` ([#2626](https://github.com/tailwindlabs/headlessui/pull/2626))
1313
- Render `<MainTreeNode />` in `Popover.Group` component only ([#2634](https://github.com/tailwindlabs/headlessui/pull/2634))
14+
- Fix hydration of components inside `<Suspense>` ([#2633](https://github.com/tailwindlabs/headlessui/pull/2633))
1415

1516
## [1.7.16] - 2023-07-27
1617

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

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,6 @@ import { Portal, useNestedPortals } from '../../components/portal/portal'
3737
import { ForcePortalRoot } from '../../internal/portal-force-root'
3838
import { ComponentDescription, Description, useDescriptions } from '../description/description'
3939
import { useOpenClosed, State } from '../../internal/open-closed'
40-
import { useServerHandoffComplete } from '../../hooks/use-server-handoff-complete'
4140
import { StackProvider, StackMessage } from '../../internal/stack-context'
4241
import { useOutsideClick } from '../../hooks/use-outside-click'
4342
import { useOwnerDocument } from '../../hooks/use-owner'
@@ -205,8 +204,7 @@ function DialogFn<TTag extends ElementType = typeof DEFAULT_DIALOG_TAG>(
205204

206205
let setTitleId = useEvent((id: string | null) => dispatch({ type: ActionTypes.SetTitleId, id }))
207206

208-
let ready = useServerHandoffComplete()
209-
let enabled = ready ? (__demoMode ? false : dialogState === DialogStates.Open) : false
207+
let enabled = __demoMode ? false : dialogState === DialogStates.Open
210208
let hasNestedDialogs = nestedDialogCount > 1 // 1 is the current dialog
211209
let hasParentDialog = useContext(DialogContext) !== null
212210
let [portals, PortalWrapper] = useNestedPortals()
@@ -216,7 +214,7 @@ function DialogFn<TTag extends ElementType = typeof DEFAULT_DIALOG_TAG>(
216214
MainTreeNode,
217215
} = useRootContainers({
218216
portals,
219-
defaultContainers: [state.panelRef.current ?? internalDialogRef.current],
217+
defaultContainers: [() => state.panelRef.current ?? internalDialogRef.current],
220218
})
221219

222220
// If there are multiple dialogs, then you can be the root, the leaf or one

packages/@headlessui-react/src/components/focus-trap/focus-trap.tsx

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import React, {
1010

1111
import { Props } from '../../types'
1212
import { forwardRefWithAs, HasDisplayName, RefProp, render } from '../../utils/render'
13-
import { useServerHandoffComplete } from '../../hooks/use-server-handoff-complete'
1413
import { useSyncRefs } from '../../hooks/use-sync-refs'
1514
import { Features as HiddenFeatures, Hidden } from '../../internal/hidden'
1615
import { focusElement, focusIn, Focus, FocusResult } from '../../utils/focus-management'
@@ -82,10 +81,6 @@ function FocusTrapFn<TTag extends ElementType = typeof DEFAULT_FOCUS_TRAP_TAG>(
8281
let focusTrapRef = useSyncRefs(container, ref)
8382
let { initialFocus, containers, features = Features.All, ...theirProps } = props
8483

85-
if (!useServerHandoffComplete()) {
86-
features = Features.None
87-
}
88-
8984
let ownerDocument = useOwnerDocument(container)
9085

9186
useRestoreFocus({ ownerDocument }, Boolean(features & Features.RestoreFocus))

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ import { Props } from '../../types'
1919
import { forwardRefWithAs, RefProp, HasDisplayName, render } from '../../utils/render'
2020
import { useIsoMorphicEffect } from '../../hooks/use-iso-morphic-effect'
2121
import { usePortalRoot } from '../../internal/portal-force-root'
22-
import { useServerHandoffComplete } from '../../hooks/use-server-handoff-complete'
2322
import { optionalRef, useSyncRefs } from '../../hooks/use-sync-refs'
2423
import { useOnUnmount } from '../../hooks/use-on-unmount'
2524
import { useOwnerDocument } from '../../hooks/use-owner'
@@ -91,7 +90,6 @@ function PortalFn<TTag extends ElementType = typeof DEFAULT_PORTAL_TAG>(
9190
env.isServer ? null : ownerDocument?.createElement('div') ?? null
9291
)
9392
let parent = useContext(PortalParentContext)
94-
let ready = useServerHandoffComplete()
9593

9694
useIsoMorphicEffect(() => {
9795
if (!target || !element) return
@@ -123,7 +121,9 @@ function PortalFn<TTag extends ElementType = typeof DEFAULT_PORTAL_TAG>(
123121
}
124122
})
125123

126-
if (!ready) return null
124+
let [isFirstRender, setIsFirstRender] = useState(true)
125+
useEffect(() => setIsFirstRender(false), [])
126+
if (isFirstRender) return null
127127

128128
let ourProps = { ref: portalRef }
129129

packages/@headlessui-react/src/components/transitions/transition.test.tsx

Lines changed: 0 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -155,27 +155,6 @@ describe('Setup API', () => {
155155
</span>
156156
`)
157157
})
158-
159-
it(
160-
'should yell at us when we forget to forward the ref when using a render prop',
161-
suppressConsoleLogs(() => {
162-
expect.assertions(1)
163-
164-
function Dummy(props: any) {
165-
return <span {...props}>Children</span>
166-
}
167-
168-
expect(() => {
169-
render(
170-
<Transition show={true} as={Fragment}>
171-
{() => <Dummy />}
172-
</Transition>
173-
)
174-
}).toThrowErrorMatchingInlineSnapshot(
175-
`"Did you forget to passthrough the \`ref\` to the actual DOM node?"`
176-
)
177-
})
178-
)
179158
})
180159

181160
describe('nested', () => {
@@ -349,58 +328,6 @@ describe('Setup API', () => {
349328
</div>
350329
`)
351330
})
352-
353-
it(
354-
'should yell at us when we forgot to forward the ref on one of the Transition.Child components',
355-
suppressConsoleLogs(() => {
356-
expect.assertions(1)
357-
358-
function Dummy(props: any) {
359-
return <div {...props} />
360-
}
361-
362-
expect(() => {
363-
render(
364-
<div className="My Page">
365-
<Transition show={true}>
366-
<Transition.Child as={Fragment}>{() => <Dummy>Sidebar</Dummy>}</Transition.Child>
367-
<Transition.Child as={Fragment}>{() => <Dummy>Content</Dummy>}</Transition.Child>
368-
</Transition>
369-
</div>
370-
)
371-
}).toThrowErrorMatchingInlineSnapshot(
372-
`"Did you forget to passthrough the \`ref\` to the actual DOM node?"`
373-
)
374-
})
375-
)
376-
377-
it(
378-
'should yell at us when we forgot to forward a ref on the Transition component',
379-
suppressConsoleLogs(() => {
380-
expect.assertions(1)
381-
382-
function Dummy(props: any) {
383-
return <div {...props} />
384-
}
385-
386-
expect(() => {
387-
render(
388-
<div className="My Page">
389-
<Transition show={true} as={Fragment}>
390-
{() => (
391-
<Dummy>
392-
<Transition.Child>{() => <aside>Sidebar</aside>}</Transition.Child>
393-
<Transition.Child>{() => <section>Content</section>}</Transition.Child>
394-
</Dummy>
395-
)}
396-
</Transition>
397-
</div>
398-
)
399-
}).toThrowErrorMatchingInlineSnapshot(
400-
`"Did you forget to passthrough the \`ref\` to the actual DOM node?"`
401-
)
402-
})
403-
)
404331
})
405332

406333
describe('transition classes', () => {

packages/@headlessui-react/src/components/transitions/transition.tsx

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@ import { match } from '../../utils/match'
2727
import { useIsMounted } from '../../hooks/use-is-mounted'
2828
import { useIsoMorphicEffect } from '../../hooks/use-iso-morphic-effect'
2929
import { useLatestValue } from '../../hooks/use-latest-value'
30-
import { useServerHandoffComplete } from '../../hooks/use-server-handoff-complete'
3130
import { useSyncRefs } from '../../hooks/use-sync-refs'
3231
import { useTransition } from '../../hooks/use-transition'
3332
import { useEvent } from '../../hooks/use-event'
@@ -348,19 +347,10 @@ function TransitionChildFn<TTag extends ElementType = typeof DEFAULT_TRANSITION_
348347
afterLeave,
349348
})
350349

351-
let ready = useServerHandoffComplete()
352-
353-
useEffect(() => {
354-
if (ready && state === TreeStates.Visible && container.current === null) {
355-
throw new Error('Did you forget to passthrough the `ref` to the actual DOM node?')
356-
}
357-
}, [container, state, ready])
358-
359350
// Skipping initial transition
360351
let skip = initial && !appear
361352

362353
let transitionDirection = (() => {
363-
if (!ready) return 'idle'
364354
if (skip) return 'idle'
365355
if (prevShow.current === show) return 'idle'
366356
return show ? 'enter' : 'leave'
@@ -480,9 +470,6 @@ function TransitionRootFn<TTag extends ElementType = typeof DEFAULT_TRANSITION_C
480470
let internalTransitionRef = useRef<HTMLElement | null>(null)
481471
let transitionRef = useSyncRefs(internalTransitionRef, ref)
482472

483-
// The TransitionChild will also call this hook, and we have to make sure that we are ready.
484-
useServerHandoffComplete()
485-
486473
let usesOpenClosedState = useOpenClosed()
487474

488475
if (show === undefined && usesOpenClosedState !== null) {

packages/@headlessui-react/src/hooks/use-id.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import React from 'react'
22
import { useIsoMorphicEffect } from './use-iso-morphic-effect'
3-
import { useServerHandoffComplete } from './use-server-handoff-complete'
43
import { env } from '../utils/env'
54

65
// We used a "simple" approach first which worked for SSR and rehydration on the client. However we
@@ -23,3 +22,26 @@ export let useId =
2322

2423
return id != null ? '' + id : undefined
2524
}
25+
26+
// NOTE: Do NOT use this outside of the `useId` hook
27+
// It is not compatible with `<Suspense>` (which is in React 18 which has its own `useId` hook)
28+
function useServerHandoffComplete() {
29+
let [complete, setComplete] = React.useState(env.isHandoffComplete)
30+
31+
if (complete && env.isHandoffComplete === false) {
32+
// This means we are in a test environment and we need to reset the handoff state
33+
// This kinda breaks the rules of React but this is only used for testing purposes
34+
// And should theoretically be fine
35+
setComplete(false)
36+
}
37+
38+
React.useEffect(() => {
39+
if (complete === true) return
40+
setComplete(true)
41+
}, [complete])
42+
43+
// Transition from pending to complete (forcing a re-render when server rendering)
44+
React.useEffect(() => env.handoff(), [])
45+
46+
return complete
47+
}

packages/@headlessui-react/src/hooks/use-root-containers.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,15 @@ import { Hidden, Features as HiddenFeatures } from '../internal/hidden'
33
import { useEvent } from './use-event'
44
import { useOwnerDocument } from './use-owner'
55

6+
type Container = HTMLElement | null | MutableRefObject<HTMLElement | null>
7+
type MaybeContainerFn = Container | (() => Container)
8+
69
export function useRootContainers({
710
defaultContainers = [],
811
portals,
912
mainTreeNodeRef: _mainTreeNodeRef,
1013
}: {
11-
defaultContainers?: (HTMLElement | null | MutableRefObject<HTMLElement | null>)[]
14+
defaultContainers?: MaybeContainerFn[]
1215
portals?: MutableRefObject<HTMLElement[]>
1316
mainTreeNodeRef?: MutableRefObject<HTMLElement | null>
1417
} = {}) {
@@ -21,6 +24,10 @@ export function useRootContainers({
2124

2225
// Resolve default containers
2326
for (let container of defaultContainers) {
27+
if (typeof container === 'function') {
28+
container = container()
29+
}
30+
2431
if (container === null) continue
2532
if (container instanceof HTMLElement) {
2633
containers.push(container)

packages/@headlessui-react/src/hooks/use-server-handoff-complete.ts

Lines changed: 0 additions & 23 deletions
This file was deleted.

0 commit comments

Comments
 (0)