Skip to content

Commit b0dcd8f

Browse files
authored
Handle props on Fragment error due to Symbol(react.lazy) (#3873)
This PR fixes an error where you get an error that props were forwarded to a Fragment. This is because we forward props to the children in case of a Fragment. We only do this to valid elements, but in some scenarios, it could be that we receive a `Symbol(react.lazy)`. Sometimes we also get a `Symbol(react.transitional.element)` but that doesn't seem like an issue. With this PR, we resolve the payload of fulfilled `react.lazy` elements. Fixes: tailwindlabs/tailwind-plus-issues#1814 ## Test plan Tested this behavior in Catalyst. Before: https://github.com/user-attachments/assets/4389ffe4-9fd6-4fe9-b290-8edbc86eb94b After: https://github.com/user-attachments/assets/b6b34b47-e3d1-4e79-a0ef-f215ff141bc8
1 parent 7c06d2b commit b0dcd8f

61 files changed

Lines changed: 563 additions & 318 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

package-lock.json

Lines changed: 402 additions & 199 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/@headlessui-react/CHANGELOG.md

Lines changed: 1 addition & 0 deletions

packages/@headlessui-react/src/utils/render.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,11 @@ function _render<TTag extends ElementType, TSlot>(
148148
| ReactElement
149149
| ReactElement[]
150150

151+
// Unwrap `react.lazy` wrappers when the payload has already been fulfilled.
152+
// This can happen with e.g. Next.js client-side navigations where the child
153+
// element is wrapped in a lazy boundary from React Server Components.
154+
resolvedChildren = resolveLazyElement(resolvedChildren)
155+
151156
// Allow for className to be a function with the slot as the contents
152157
if ('className' in rest && rest.className && typeof rest.className === 'function') {
153158
rest.className = rest.className(slot)
@@ -467,6 +472,23 @@ function getElementRef(element: React.ReactElement) {
467472
return React.version.split('.')[0] >= '19' ? element.props.ref : element.ref
468473
}
469474

475+
/**
476+
* Resolve a `react.lazy` wrapper to the underlying element when the lazy
477+
* payload has already been fulfilled. This handles the case where frameworks
478+
* like Next.js wrap elements in lazy boundaries (e.g. during client-side
479+
* navigation with React Server Components). If the payload isn't resolved
480+
* yet, the original value is returned as-is.
481+
*/
482+
function resolveLazyElement(element: any): any {
483+
if (element != null && element.$$typeof === Symbol.for('react.lazy')) {
484+
let payload = element._payload
485+
if (payload != null && payload.status === 'fulfilled') {
486+
return resolveLazyElement(payload.value)
487+
}
488+
}
489+
return element
490+
}
491+
470492
export function isFragment(element: any): element is typeof Fragment {
471493
return element === Fragment || element === Symbol.for('react.fragment')
472494
}

playgrounds/react/.prettierignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
.next
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
'use client'
2+
3+
import dynamic from 'next/dynamic'
4+
import { notFound } from 'next/navigation'
5+
6+
export function PageLoader({ slug }: { slug: string }) {
7+
let Component = dynamic(() => import(`../../page-examples/${slug}`))
8+
9+
if (!Component) {
10+
notFound()
11+
}
12+
13+
return <Component />
14+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import fs from 'fs'
2+
import { notFound } from 'next/navigation'
3+
import path from 'path'
4+
import { PageLoader } from './page-loader'
5+
6+
export default async function CatchAllPage({ params }: { params: Promise<{ slug: string[] }> }) {
7+
let { slug } = await params
8+
let pagePath = slug.join('/')
9+
10+
let file = path.resolve(process.cwd(), 'page-examples', `${pagePath}.tsx`)
11+
if (!fs.existsSync(file)) {
12+
notFound()
13+
}
14+
15+
return <PageLoader slug={pagePath} />
16+
}
Lines changed: 28 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1+
'use client'
2+
13
import Link from 'next/link'
4+
import { useSearchParams } from 'next/navigation'
25
import { useEffect, useState } from 'react'
36

4-
import { useRouter } from 'next/router'
5-
import './styles.css'
6-
77
function disposables() {
88
let disposables: Function[] = []
99

@@ -38,8 +38,7 @@ function disposables() {
3838
return api
3939
}
4040

41-
export function useDisposables() {
42-
// Using useState instead of useRef so that we can use the initializer function.
41+
function useDisposables() {
4342
let [d] = useState(disposables)
4443
useEffect(() => () => d.dispose(), [d])
4544
return d
@@ -128,33 +127,7 @@ function KeyCaster() {
128127
)
129128
}
130129

131-
function MyApp({ Component, pageProps }) {
132-
let router = useRouter()
133-
if (router.query.raw !== undefined) {
134-
return <Component {...pageProps} />
135-
}
136-
137-
return (
138-
<>
139-
<div className="flex h-screen flex-col overflow-hidden bg-gray-700 font-sans text-gray-900 antialiased">
140-
<header className="relative z-10 flex shrink-0 items-center justify-between border-b border-gray-200 bg-gray-700 px-4 py-4 sm:px-6 lg:px-8">
141-
<Link href="/">
142-
<Logo className="h-6" />
143-
</Link>
144-
<span className="font-bold text-white">(React)</span>
145-
</header>
146-
147-
<KeyCaster />
148-
149-
<main className="flex-1 overflow-auto bg-gray-50">
150-
<Component {...pageProps} />
151-
</main>
152-
</div>
153-
</>
154-
)
155-
}
156-
157-
function Logo({ className }) {
130+
function Logo({ className }: { className?: string }) {
158131
return (
159132
<svg className={className} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 243 42">
160133
<path
@@ -211,4 +184,26 @@ function Logo({ className }) {
211184
)
212185
}
213186

214-
export default MyApp
187+
export function LayoutChrome({ children }: { children: React.ReactNode }) {
188+
let searchParams = useSearchParams()
189+
let raw = searchParams.get('raw') !== null
190+
191+
if (raw) {
192+
return <>{children}</>
193+
}
194+
195+
return (
196+
<div className="flex h-screen flex-col overflow-hidden bg-gray-700 font-sans text-gray-900 antialiased">
197+
<header className="relative z-10 flex shrink-0 items-center justify-between border-b border-gray-200 bg-gray-700 px-4 py-4 sm:px-6 lg:px-8">
198+
<Link href="/">
199+
<Logo className="h-6" />
200+
</Link>
201+
<span className="font-bold text-white">(React)</span>
202+
</header>
203+
204+
<KeyCaster />
205+
206+
<main className="flex-1 overflow-auto bg-gray-50">{children}</main>
207+
</div>
208+
)
209+
}
Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1-
import { Head, Html, Main, NextScript } from 'next/document'
1+
import { Suspense } from 'react'
2+
import '../page-examples/styles.css'
3+
import { LayoutChrome } from './layout-chrome'
24

3-
export default function Document() {
5+
export default function RootLayout({ children }: { children: React.ReactNode }) {
46
return (
5-
<Html>
6-
<Head>
7+
<html>
8+
<head>
79
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
810
<meta name="viewport" content="width=device-width, initial-scale=1" />
911

@@ -21,11 +23,12 @@ export default function Document() {
2123
sizes="16x16"
2224
href="https://headlessui.dev/favicon-16x16.png"
2325
/>
24-
</Head>
26+
</head>
2527
<body>
26-
<Main />
27-
<NextScript />
28+
<Suspense>
29+
<LayoutChrome>{children}</LayoutChrome>
30+
</Suspense>
2831
</body>
29-
</Html>
32+
</html>
3033
)
3134
}

playgrounds/react/app/page.tsx

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import Link from 'next/link'
2+
import { ExamplesType, resolveAllExamples } from '../utils/resolve-all-examples'
3+
4+
export default async function Page() {
5+
let examples = await resolveAllExamples('page-examples')
6+
7+
if (examples === false) {
8+
return <p>No examples found.</p>
9+
}
10+
11+
return (
12+
<div className="container mx-auto my-24">
13+
<div className="prose">
14+
<h2>Examples</h2>
15+
<Examples examples={examples} />
16+
</div>
17+
</div>
18+
)
19+
}
20+
21+
function Examples(props: { examples: ExamplesType[] }) {
22+
return (
23+
<ul>
24+
{props.examples.map((example) => (
25+
<li key={example.path}>
26+
{example.children ? (
27+
<h3 className="text-xl capitalize">{example.name}</h3>
28+
) : (
29+
<Link href={example.path} className="capitalize">
30+
{example.name}
31+
</Link>
32+
)}
33+
{example.children && <Examples examples={example.children} />}
34+
</li>
35+
))}
36+
</ul>
37+
)
38+
}

playgrounds/react/next-env.d.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
/// <reference types="next" />
22
/// <reference types="next/image-types/global" />
3+
import './.next/dev/types/routes.d.ts'
34

45
// NOTE: This file should not be edited
5-
// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.
6+
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

0 commit comments

Comments
 (0)