Skip to content

Commit a317866

Browse files
Fix hydration of components inside <Suspense> (#2663)
* Add repro for suspense wip * Refactor to wildcard import * Targeted fix for react 18 + suspense * Update changelog * Update types * Add styling * update styling
1 parent 88b068c commit a317866

File tree

3 files changed

+114
-4
lines changed

3 files changed

+114
-4
lines changed

packages/@headlessui-react/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1616
- Fix incorrectly focused `Combobox.Input` component on page load ([#2654](https://github.com/tailwindlabs/headlessui/pull/2654))
1717
- Ensure `appear` works using the `Transition` component (even when used with SSR) ([#2646](https://github.com/tailwindlabs/headlessui/pull/2646))
1818
- Improve resetting values when using the `nullable` prop on the `Combobox` component ([#2660](https://github.com/tailwindlabs/headlessui/pull/2660))
19+
- Fix hydration of components inside `<Suspense>` ([#2663](https://github.com/tailwindlabs/headlessui/pull/2663))
1920

2021
## [1.7.16] - 2023-07-27
2122

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,41 @@
1-
import { useState, useEffect } from 'react'
1+
import * as React from 'react'
22
import { env } from '../utils/env'
33

4+
/**
5+
* This is used to determine if we're hydrating in React 18.
6+
*
7+
* The `useServerHandoffComplete` hook doesn't work with `<Suspense>`
8+
* because it assumes all hydration happens at one time during page load.
9+
*
10+
* Given that the problem only exists in React 18 we can rely
11+
* on newer APIs to determine if hydration is happening.
12+
*/
13+
function useIsHydratingInReact18(): boolean {
14+
let isServer = typeof document === 'undefined'
15+
16+
// React < 18 doesn't have any way to figure this out afaik
17+
if (!('useSyncExternalStore' in React)) {
18+
return false
19+
}
20+
21+
// This weird pattern makes sure bundlers don't throw at build time
22+
// because `useSyncExternalStore` isn't defined in React < 18
23+
const useSyncExternalStore = ((r) => r.useSyncExternalStore)(React)
24+
25+
// @ts-ignore
26+
let result = useSyncExternalStore(
27+
() => () => {},
28+
() => false,
29+
() => (isServer ? false : true)
30+
)
31+
32+
return result
33+
}
34+
35+
// TODO: We want to get rid of this hook eventually
436
export function useServerHandoffComplete() {
5-
let [complete, setComplete] = useState(env.isHandoffComplete)
37+
let isHydrating = useIsHydratingInReact18()
38+
let [complete, setComplete] = React.useState(env.isHandoffComplete)
639

740
if (complete && env.isHandoffComplete === false) {
841
// This means we are in a test environment and we need to reset the handoff state
@@ -11,13 +44,17 @@ export function useServerHandoffComplete() {
1144
setComplete(false)
1245
}
1346

14-
useEffect(() => {
47+
React.useEffect(() => {
1548
if (complete === true) return
1649
setComplete(true)
1750
}, [complete])
1851

1952
// Transition from pending to complete (forcing a re-render when server rendering)
20-
useEffect(() => env.handoff(), [])
53+
React.useEffect(() => env.handoff(), [])
54+
55+
if (isHydrating) {
56+
return false
57+
}
2158

2259
return complete
2360
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
'use client'
2+
3+
import { Portal } from '@headlessui/react'
4+
import { Suspense, lazy } from 'react'
5+
6+
function MyComponent({ children }: { children(message: string): JSX.Element }) {
7+
return <>{children('test')}</>
8+
}
9+
10+
let MyComponentLazy = lazy(async () => {
11+
await new Promise((resolve) => setTimeout(resolve, 4000))
12+
13+
return { default: MyComponent }
14+
})
15+
16+
export default function Index() {
17+
return (
18+
<div>
19+
<h1 className="p-8 text-3xl font-bold">Suspense + Portals</h1>
20+
21+
<Portal>
22+
<div className="absolute top-24 right-48 z-10 flex h-32 w-32 flex-col items-center justify-center rounded border border-black/5 bg-white bg-clip-padding p-px shadow">
23+
<div className="w-full rounded-t-sm bg-gray-100 p-1 text-center text-gray-700">
24+
Instant
25+
</div>
26+
<div className="flex w-full flex-1 items-center justify-center text-3xl font-bold text-gray-400">
27+
1
28+
</div>
29+
</div>
30+
</Portal>
31+
<Portal>
32+
<div className="absolute top-24 right-8 z-10 flex h-32 w-32 flex-col items-center justify-center rounded border border-black/5 bg-white bg-clip-padding p-px shadow">
33+
<div className="w-full rounded-t-sm bg-gray-100 p-1 text-center text-gray-700">
34+
Instant
35+
</div>
36+
<div className="flex w-full flex-1 items-center justify-center text-3xl font-bold text-gray-400">
37+
2
38+
</div>
39+
</div>
40+
</Portal>
41+
42+
<Suspense fallback={<span>Loading ...</span>}>
43+
<MyComponentLazy>
44+
{(env) => (
45+
<div>
46+
<Portal>
47+
<div className="absolute top-64 right-48 z-10 flex h-32 w-32 flex-col items-center justify-center rounded border border-black/5 bg-white bg-clip-padding p-px shadow">
48+
<div className="w-full rounded-t-sm bg-gray-100 p-1 text-center text-gray-700">
49+
Suspense
50+
</div>
51+
<div className="flex w-full flex-1 items-center justify-center text-3xl font-bold text-gray-400">
52+
{env} 1
53+
</div>
54+
</div>
55+
</Portal>
56+
<Portal>
57+
<div className="absolute top-64 right-8 z-10 flex h-32 w-32 flex-col items-center justify-center rounded border border-black/5 bg-white bg-clip-padding p-px shadow">
58+
<div className="w-full rounded-t-sm bg-gray-100 p-1 text-center text-gray-700">
59+
Suspense
60+
</div>
61+
<div className="flex w-full flex-1 items-center justify-center text-3xl font-bold text-gray-400">
62+
{env} 2
63+
</div>
64+
</div>
65+
</Portal>
66+
</div>
67+
)}
68+
</MyComponentLazy>
69+
</Suspense>
70+
</div>
71+
)
72+
}

0 commit comments

Comments
 (0)