Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix SSR tab rendering on React 17 #2102

Merged
merged 8 commits into from
Dec 16, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion packages/@headlessui-react/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

- Nothing yet!
### Fixed

- Fix SSR tab rendering on React 17 ([#2102](https://github.com/tailwindlabs/headlessui/pull/2102))

## [1.7.7] - 2022-12-16

Expand Down
6 changes: 3 additions & 3 deletions packages/@headlessui-react/src/components/portal/portal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { useServerHandoffComplete } from '../../hooks/use-server-handoff-complet
import { optionalRef, useSyncRefs } from '../../hooks/use-sync-refs'
import { useOwnerDocument } from '../../hooks/use-owner'
import { microTask } from '../../utils/micro-task'
import { isServer } from '../../utils/ssr'
import { env } from '../../utils/env'

function usePortalTarget(ref: MutableRefObject<HTMLElement | null>): HTMLElement | null {
let forceInRoot = usePortalRoot()
Expand All @@ -34,7 +34,7 @@ function usePortalTarget(ref: MutableRefObject<HTMLElement | null>): HTMLElement
if (!forceInRoot && groupTarget !== null) return null

// No group context is used, let's create a default portal root
if (isServer) return null
if (env.isServer) return null
let existingRoot = ownerDocument?.getElementById('headlessui-portal-root')
if (existingRoot) return existingRoot

Expand Down Expand Up @@ -82,7 +82,7 @@ let PortalRoot = forwardRefWithAs(function Portal<
let ownerDocument = useOwnerDocument(internalPortalRootRef)
let target = usePortalTarget(internalPortalRootRef)
let [element] = useState<HTMLDivElement | null>(() =>
isServer ? null : ownerDocument?.createElement('div') ?? null
env.isServer ? null : ownerDocument?.createElement('div') ?? null
)

let ready = useServerHandoffComplete()
Expand Down
134 changes: 134 additions & 0 deletions packages/@headlessui-react/src/components/tabs/tabs.ssr.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { RenderResult } from '@testing-library/react'
import { render, RenderOptions } from '@testing-library/react'
import React, { ReactElement } from 'react'
import { renderToString } from 'react-dom/server'
import { Tab } from './tabs'
import { env } from '../../utils/env'

beforeAll(() => {
jest.spyOn(window, 'requestAnimationFrame').mockImplementation(setImmediate as any)
jest.spyOn(window, 'cancelAnimationFrame').mockImplementation(clearImmediate as any)
})

function Example({ defaultIndex = 0 }) {
return (
<Tab.Group defaultIndex={defaultIndex}>
<Tab.List>
<Tab>Tab 1</Tab>
<Tab>Tab 2</Tab>
<Tab>Tab 3</Tab>
</Tab.List>

<Tab.Panels>
<Tab.Panel>Content 1</Tab.Panel>
<Tab.Panel>Content 2</Tab.Panel>
<Tab.Panel>Content 3</Tab.Panel>
</Tab.Panels>
</Tab.Group>
)
}

describe('Rendering', () => {
describe('SSR', () => {
it('should be possible to server side render the first Tab and Panel', async () => {
let { contents } = await serverRender(<Example />)

expect(contents).toContain(`Content 1`)
expect(contents).not.toContain(`Content 2`)
expect(contents).not.toContain(`Content 3`)
})

it('should be possible to server side render the defaultIndex Tab and Panel', async () => {
let { contents } = await serverRender(<Example defaultIndex={1} />)

expect(contents).not.toContain(`Content 1`)
expect(contents).toContain(`Content 2`)
expect(contents).not.toContain(`Content 3`)
})
})

// The hydration tests don't work in React 18 due to some bug in Testing Library maybe?
// Skipping for now
xdescribe('Hydration', () => {
it('should be possible to server side render the first Tab and Panel', async () => {
const { contents } = await hydrateRender(<Example />)

expect(contents).toContain(`Content 1`)
expect(contents).not.toContain(`Content 2`)
expect(contents).not.toContain(`Content 3`)
})

it('should be possible to server side render the defaultIndex Tab and Panel', async () => {
const { contents } = await hydrateRender(<Example defaultIndex={1} />)

expect(contents).not.toContain(`Content 1`)
expect(contents).toContain(`Content 2`)
expect(contents).not.toContain(`Content 3`)
})
})
})

type ServerRenderOptions = Omit<RenderOptions, 'queries'> & {
strict?: boolean
}

interface ServerRenderResult {
type: 'ssr' | 'hydrate'
contents: string
result: RenderResult
hydrate: () => Promise<ServerRenderResult>
}

async function serverRender(
ui: ReactElement,
options: ServerRenderOptions = {}
): Promise<ServerRenderResult> {
let container = document.createElement('div')
document.body.appendChild(container)
options = { ...options, container }

if (options.strict) {
options = {
...options,
wrapper({ children }) {
return <React.StrictMode>{children}</React.StrictMode>
},
}
}

env.set('server')
let contents = renderToString(ui)
let result = render(<div dangerouslySetInnerHTML={{ __html: contents }} />, options)

async function hydrate(): Promise<ServerRenderResult> {
// This hack-ish way of unmounting the server rendered content is necessary
// otherwise we won't actually end up testing the hydration code path properly.
// Probably because React hangs on to internal references on the DOM nodes
result.unmount()
container.innerHTML = contents

env.set('client')
let newResult = render(ui, {
...options,
hydrate: true,
})

return {
type: 'hydrate',
contents: container.innerHTML,
result: newResult,
hydrate,
}
}

return {
type: 'ssr',
contents,
result,
hydrate,
}
}

async function hydrateRender(el: ReactElement, options: ServerRenderOptions = {}) {
return serverRender(el, options).then((r) => r.hydrate())
}
22 changes: 12 additions & 10 deletions packages/@headlessui-react/src/components/tabs/tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,9 +110,7 @@ let reducers: {
},
}

let TabsSSRContext = createContext<MutableRefObject<{ tabs: string[]; panels: string[] }> | null>(
null
)
let TabsSSRContext = createContext<MutableRefObject<{ tabs: number; panels: number }> | null>(null)
TabsSSRContext.displayName = 'TabsSSRContext'

function useSSRTabsCounter(component: string) {
Expand Down Expand Up @@ -239,7 +237,7 @@ let Tabs = forwardRefWithAs(function Tabs<TTag extends ElementType = typeof DEFA
dispatch({ type: ActionTypes.SetSelectedIndex, index: selectedIndex ?? defaultIndex })
}, [selectedIndex /* Deliberately skipping defaultIndex */])

let SSRCounter = useRef({ tabs: [], panels: [] })
let SSRCounter = useRef({ tabs: 0, panels: 0 })
let ourProps = { ref: tabsRef }

return (
Expand Down Expand Up @@ -331,11 +329,13 @@ let TabRoot = forwardRefWithAs(function Tab<TTag extends ElementType = typeof DE

useIsoMorphicEffect(() => actions.registerTab(internalTabRef), [actions, internalTabRef])

let mySSRIndex = SSRContext.current.tabs.indexOf(id)
if (mySSRIndex === -1) mySSRIndex = SSRContext.current.tabs.push(id) - 1
let mySSRIndex = useRef(-1)
if (mySSRIndex.current === -1) {
mySSRIndex.current = SSRContext.current ? SSRContext.current.tabs++ : -1
}

let myIndex = tabs.indexOf(internalTabRef)
if (myIndex === -1) myIndex = mySSRIndex
if (myIndex === -1) myIndex = mySSRIndex.current
let selected = myIndex === selectedIndex

let activateUsing = useEvent((cb: () => FocusResult) => {
Expand Down Expand Up @@ -492,11 +492,13 @@ let Panel = forwardRefWithAs(function Panel<TTag extends ElementType = typeof DE

useIsoMorphicEffect(() => actions.registerPanel(internalPanelRef), [actions, internalPanelRef])

let mySSRIndex = SSRContext.current.panels.indexOf(id)
if (mySSRIndex === -1) mySSRIndex = SSRContext.current.panels.push(id) - 1
let mySSRIndex = useRef(-1)
if (mySSRIndex.current === -1) {
mySSRIndex.current = SSRContext.current ? SSRContext.current.panels++ : -1
}

let myIndex = panels.indexOf(internalPanelRef)
if (myIndex === -1) myIndex = mySSRIndex
if (myIndex === -1) myIndex = mySSRIndex.current

let selected = myIndex === selectedIndex

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { useTransition } from '../../hooks/use-transition'
import { useEvent } from '../../hooks/use-event'
import { useDisposables } from '../../hooks/use-disposables'
import { classNames } from '../../utils/class-names'
import { env } from '../../utils/env'

type ContainerElement = MutableRefObject<HTMLElement | null>

Expand Down Expand Up @@ -413,8 +414,7 @@ let TransitionChild = forwardRefWithAs(function TransitionChild<
let theirProps = rest
let ourProps = { ref: transitionRef }

let isServer = typeof window === 'undefined' || typeof document === 'undefined'
if (appear && show && isServer) {
if (appear && show && env.isServer) {
theirProps = {
...theirProps,
// Already apply the `enter` and `enterFrom` on the server if required
Expand Down
10 changes: 3 additions & 7 deletions packages/@headlessui-react/src/hooks/use-id.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,24 @@
import React from 'react'
import { useIsoMorphicEffect } from './use-iso-morphic-effect'
import { useServerHandoffComplete } from './use-server-handoff-complete'
import { env } from '../utils/env'

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

let id = 0
function generateId() {
return ++id
}

export let useId =
// Prefer React's `useId` if it's available.
// @ts-expect-error - `useId` doesn't exist in React < 18.
React.useId ??
function useId() {
let ready = useServerHandoffComplete()
let [id, setId] = React.useState(ready ? generateId : null)
let [id, setId] = React.useState(ready ? () => env.nextId() : null)

useIsoMorphicEffect(() => {
if (id === null) setId(generateId())
if (id === null) setId(env.nextId())
}, [id])

return id != null ? '' + id : undefined
Expand Down
12 changes: 9 additions & 3 deletions packages/@headlessui-react/src/hooks/use-iso-morphic-effect.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { useLayoutEffect, useEffect } from 'react'
import { isServer } from '../utils/ssr'
import { useLayoutEffect, useEffect, EffectCallback, DependencyList } from 'react'
import { env } from '../utils/env'

export let useIsoMorphicEffect = isServer ? useEffect : useLayoutEffect
export let useIsoMorphicEffect = (effect: EffectCallback, deps?: DependencyList | undefined) => {
if (env.isServer) {
useEffect(effect, deps)
} else {
useLayoutEffect(effect, deps)
}
}
26 changes: 15 additions & 11 deletions packages/@headlessui-react/src/hooks/use-server-handoff-complete.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,23 @@
import { useState, useEffect } from 'react'

let state = { serverHandoffComplete: false }
import { env } from '../utils/env'

export function useServerHandoffComplete() {
let [serverHandoffComplete, setServerHandoffComplete] = useState(state.serverHandoffComplete)

useEffect(() => {
if (serverHandoffComplete === true) return
let [complete, setComplete] = useState(env.isHandoffComplete)

setServerHandoffComplete(true)
}, [serverHandoffComplete])
if (complete && env.isHandoffComplete === false) {
// This means we are in a test environment and we need to reset the handoff state
// This kinda breaks the rules of React but this is only used for testing purposes
// And should theoretically be fine
setComplete(false)
}

useEffect(() => {
if (state.serverHandoffComplete === false) state.serverHandoffComplete = true
}, [])
if (complete === true) return
setComplete(true)
}, [complete])

// Transition from pending to complete (forcing a re-render when server rendering)
useEffect(() => env.handoff(), [])

return serverHandoffComplete
return complete
}
52 changes: 52 additions & 0 deletions packages/@headlessui-react/src/utils/env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
type RenderEnv = 'client' | 'server'
type HandoffState = 'pending' | 'complete'

class Env {
current: RenderEnv = this.detect()
handoffState: HandoffState = 'pending'
currentId = 0

set(env: RenderEnv): void {
if (this.current === env) return

this.handoffState = 'pending'
this.currentId = 0
this.current = env
}

reset(): void {
this.set(this.detect())
}

nextId() {
return ++this.currentId
}

get isServer(): boolean {
return this.current === 'server'
}

get isClient(): boolean {
return this.current === 'client'
}

private detect(): RenderEnv {
if (typeof window === 'undefined' || typeof document === 'undefined') {
return 'server'
}

return 'client'
}

handoff(): void {
if (this.handoffState === 'pending') {
this.handoffState = 'complete'
}
}

get isHandoffComplete(): boolean {
return this.handoffState === 'complete'
}
}

export let env = new Env()
4 changes: 2 additions & 2 deletions packages/@headlessui-react/src/utils/owner.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { MutableRefObject } from 'react'
import { isServer } from './ssr'
import { env } from './env'

export function getOwnerDocument<T extends Element | MutableRefObject<Element | null>>(
element: T | null | undefined
) {
if (isServer) return null
if (env.isServer) return null
if (element instanceof Node) return element.ownerDocument
if (element?.hasOwnProperty('current')) {
if (element.current instanceof Node) return element.current.ownerDocument
Expand Down
1 change: 0 additions & 1 deletion packages/@headlessui-react/src/utils/ssr.ts

This file was deleted.

Loading