Skip to content

Commit c13e6b7

Browse files
authored
Improve dialog and SSR (#477)
* delay initialization of Dialog We were using a useLayoutEffect, now let's use a useEffect instead. It still moves focus to the correct element, but that process is now a bit delayed. This means that users will less-likely be urged to "hack" around the issue by using fake focusable elements which will result in worse accessibility. * add hook to deal with server handoff This will allow us to delay certain features. For example we can delay the focus trapping until it is fully hydrated. We can also delay rendering the Portal to ensure hydration works correctly. * use server handoff complete hook * update changelog
1 parent ab92811 commit c13e6b7

File tree

8 files changed

+47
-18
lines changed

8 files changed

+47
-18
lines changed

CHANGELOG.md

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

1212
- Introduce Open/Closed state, to simplify component communication ([#466](https://github.com/tailwindlabs/headlessui/pull/466))
1313

14+
### Fixes
15+
16+
- Improve SSR for `Dialog` ([#477](https://github.com/tailwindlabs/headlessui/pull/477))
17+
- Delay focus trap initialization ([#477](https://github.com/tailwindlabs/headlessui/pull/477))
18+
1419
## [Unreleased - Vue]
1520

1621
### Added

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import { contains } from '../../internal/dom-containers'
3333
import { Description, useDescriptions } from '../description/description'
3434
import { useWindowEvent } from '../../hooks/use-window-event'
3535
import { useOpenClosed, State } from '../../internal/open-closed'
36+
import { useServerHandoffComplete } from '../../hooks/use-server-handoff-complete'
3637

3738
enum DialogStates {
3839
Open,
@@ -235,7 +236,8 @@ let DialogRoot = forwardRefWithAs(function Dialog<
235236
return () => observer.disconnect()
236237
}, [dialogState, internalDialogRef, close])
237238

238-
let enabled = dialogState === DialogStates.Open
239+
let ready = useServerHandoffComplete()
240+
let enabled = ready && dialogState === DialogStates.Open
239241

240242
useFocusTrap(containers, enabled, { initialFocus })
241243
useInertOthers(internalDialogRef, enabled)

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
import { Props } from '../../types'
1010
import { render } from '../../utils/render'
1111
import { useFocusTrap } from '../../hooks/use-focus-trap'
12+
import { useServerHandoffComplete } from '../../hooks/use-server-handoff-complete'
1213

1314
let DEFAULT_FOCUS_TRAP_TAG = 'div' as const
1415

@@ -18,7 +19,8 @@ export function FocusTrap<TTag extends ElementType = typeof DEFAULT_FOCUS_TRAP_T
1819
let containers = useRef<Set<HTMLElement>>(new Set())
1920
let { initialFocus, ...passthroughProps } = props
2021

21-
useFocusTrap(containers, true, { initialFocus })
22+
let ready = useServerHandoffComplete()
23+
useFocusTrap(containers, ready, { initialFocus })
2224

2325
let propsWeControl = {
2426
ref(element: HTMLElement | null) {

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

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { render } from '../../utils/render'
1616
import { useIsoMorphicEffect } from '../../hooks/use-iso-morphic-effect'
1717
import { useElementStack, StackProvider } from '../../internal/stack-context'
1818
import { usePortalRoot } from '../../internal/portal-force-root'
19+
import { useServerHandoffComplete } from '../../hooks/use-server-handoff-complete'
1920

2021
function usePortalTarget(): HTMLElement | null {
2122
let forceInRoot = usePortalRoot()
@@ -57,6 +58,8 @@ export function Portal<TTag extends ElementType = typeof DEFAULT_PORTAL_TAG>(
5758
typeof window === 'undefined' ? null : document.createElement('div')
5859
)
5960

61+
let ready = useServerHandoffComplete()
62+
6063
useElementStack(element)
6164

6265
useIsoMorphicEffect(() => {
@@ -77,16 +80,14 @@ export function Portal<TTag extends ElementType = typeof DEFAULT_PORTAL_TAG>(
7780
}
7881
}, [target, element])
7982

83+
if (!ready) return null
84+
8085
return (
8186
<StackProvider>
8287
{!target || !element
8388
? null
8489
: createPortal(
85-
render({
86-
props: passthroughProps,
87-
defaultTag: DEFAULT_PORTAL_TAG,
88-
name: 'Portal',
89-
}),
90+
render({ props: passthroughProps, defaultTag: DEFAULT_PORTAL_TAG, name: 'Portal' }),
9091
element
9192
)}
9293
</StackProvider>

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { useIsoMorphicEffect } from '../../hooks/use-iso-morphic-effect'
2323
import { Features, PropsForFeatures, render, RenderStrategy } from '../../utils/render'
2424
import { Reason, transition } from './utils/transition'
2525
import { OpenClosedProvider, State, useOpenClosed } from '../../internal/open-closed'
26+
import { useServerHandoffComplete } from '../../hooks/use-server-handoff-complete'
2627

2728
type ID = ReturnType<typeof useId>
2829

@@ -260,11 +261,13 @@ function TransitionChild<TTag extends ElementType = typeof DEFAULT_TRANSITION_CH
260261

261262
let events = useEvents({ beforeEnter, afterEnter, beforeLeave, afterLeave })
262263

264+
let ready = useServerHandoffComplete()
265+
263266
useEffect(() => {
264-
if (state === TreeStates.Visible && container.current === null) {
267+
if (ready && state === TreeStates.Visible && container.current === null) {
265268
throw new Error('Did you forget to passthrough the `ref` to the actual DOM node?')
266269
}
267-
}, [container, state])
270+
}, [container, state, ready])
268271

269272
// Skipping initial transition
270273
let skip = initial && !appear

packages/@headlessui-react/src/hooks/use-focus-trap.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@ import {
22
useRef,
33
// Types
44
MutableRefObject,
5+
useEffect,
56
} from 'react'
67

78
import { Keys } from '../components/keyboard'
8-
import { useIsoMorphicEffect } from './use-iso-morphic-effect'
99
import { focusElement, focusIn, Focus, FocusResult } from '../utils/focus-management'
1010
import { contains } from '../internal/dom-containers'
1111
import { useWindowEvent } from './use-window-event'
@@ -22,7 +22,7 @@ export function useFocusTrap(
2222
let mounted = useRef(false)
2323

2424
// Handle initial focus
25-
useIsoMorphicEffect(() => {
25+
useEffect(() => {
2626
if (!enabled) return
2727
if (containers.current.size !== 1) return
2828

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,25 @@
1-
import { useState, useEffect } from 'react'
1+
import { useState } from 'react'
22
import { useIsoMorphicEffect } from './use-iso-morphic-effect'
3+
import { useServerHandoffComplete } from './use-server-handoff-complete'
34

45
// We used a "simple" approach first which worked for SSR and rehydration on the client. However we
56
// didn't take care of the Suspense case. To fix this we used the approach the @reach-ui/auto-id
67
// uses.
78
//
89
// Credits: https://github.com/reach/reach-ui/blob/develop/packages/auto-id/src/index.tsx
910

10-
let state = { serverHandoffComplete: false }
1111
let id = 0
1212
function generateId() {
1313
return ++id
1414
}
1515

1616
export function useId() {
17-
let [id, setId] = useState(state.serverHandoffComplete ? generateId : null)
17+
let ready = useServerHandoffComplete()
18+
let [id, setId] = useState(ready ? generateId : null)
1819

1920
useIsoMorphicEffect(() => {
2021
if (id === null) setId(generateId())
2122
}, [id])
2223

23-
useEffect(() => {
24-
if (state.serverHandoffComplete === false) state.serverHandoffComplete = true
25-
}, [])
26-
2724
return id != null ? '' + id : undefined
2825
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { useState, useEffect } from 'react'
2+
3+
let state = { serverHandoffComplete: false }
4+
5+
export function useServerHandoffComplete() {
6+
let [serverHandoffComplete, setServerHandoffComplete] = useState(state.serverHandoffComplete)
7+
8+
useEffect(() => {
9+
if (serverHandoffComplete === true) return
10+
11+
setServerHandoffComplete(true)
12+
}, [serverHandoffComplete])
13+
14+
useEffect(() => {
15+
if (state.serverHandoffComplete === false) state.serverHandoffComplete = true
16+
}, [])
17+
18+
return serverHandoffComplete
19+
}

0 commit comments

Comments
 (0)