Fix SSR tab rendering on React 17 (#2102)

* Allow clicks inside dialog panel when target is inside shadow root

* Introduce resettable “server” state

This will aid in testing

* Add SSR and hydration tests for react

* Fix server rendering of Tabs on React 17

* Fix CS

* Skip hydration tests

* Tweak SSR implementation in Vue

* Update changelog
This commit is contained in:
Jordan Pittman
2022-12-16 12:55:51 -05:00
committed by GitHub
parent 404c5093e8
commit 865bd57357
22 changed files with 487 additions and 106 deletions
+3 -1
View File
@@ -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
@@ -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()
@@ -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
@@ -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()
@@ -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())
}
@@ -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) {
@@ -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 (
@@ -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) => {
@@ -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
@@ -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>
@@ -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
@@ -1,6 +1,7 @@
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
@@ -8,21 +9,16 @@ import { useServerHandoffComplete } from './use-server-handoff-complete'
//
// 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
@@ -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)
}
}
@@ -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)
let [complete, setComplete] = useState(env.isHandoffComplete)
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 (serverHandoffComplete === true) return
if (complete === true) return
setComplete(true)
}, [complete])
setServerHandoffComplete(true)
}, [serverHandoffComplete])
// Transition from pending to complete (forcing a re-render when server rendering)
useEffect(() => env.handoff(), [])
useEffect(() => {
if (state.serverHandoffComplete === false) state.serverHandoffComplete = true
}, [])
return serverHandoffComplete
return complete
}
@@ -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()
@@ -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
@@ -1 +0,0 @@
export const isServer = typeof window === 'undefined' || typeof document === 'undefined'
+3 -1
View File
@@ -7,7 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
- Nothing yet!
### Changed
- Adjust SSR detection mechanism ([#2102](https://github.com/tailwindlabs/headlessui/pull/2102))
## [1.7.7] - 2022-12-16
@@ -1475,6 +1475,107 @@ describe('Mouse interactions', () => {
})
)
fit(
'should be possible to click elements inside the dialog when they reside inside a shadow boundary',
suppressConsoleLogs(async () => {
let fn = jest.fn()
let ShadowChildren = defineComponent({
props: ['id', 'buttonId'],
setup(props) {
let container = ref<HTMLDivElement | null>(null)
onMounted(() => {
if (!container.value || container.value.shadowRoot) {
return
}
let shadowRoot = container.value.attachShadow({ mode: 'open' })
let button = document.createElement('button')
button.id = props.buttonId
button.textContent = 'Inside shadow root'
button.addEventListener('click', fn)
shadowRoot.appendChild(button)
})
return () => h('div', { id: props.id, ref: container })
},
})
renderTemplate({
components: { ShadowChildren },
template: `
<div>
<button @click="setIsOpen(true)">open</button>
<Dialog :open="isOpen" @close="setIsOpen(false)">
<div>
<button id="btn_outside_light" @click="fn">
Button
</button>
<ShadowChildren id="outside_shadow" buttonId="btn_outside_shadow" />
</div>
<DialogPanel>
<button id="btn_inside_light" @click="fn">
Button
</button>
<ShadowChildren id="inside_shadow" buttonId="btn_inside_shadow" />
</DialogPanel>
</Dialog>
</div>
`,
setup() {
let isOpen = ref(true)
return {
fn,
isOpen,
setIsOpen(value: boolean) {
isOpen.value = value
},
}
},
})
await nextFrame()
// Verify it is open
assertDialog({ state: DialogState.Visible })
// Click the button inside the dialog (light DOM)
await click(document.querySelector('#btn_inside_light'))
// Verify the button was clicked
expect(fn).toHaveBeenCalledTimes(1)
// Verify the dialog is still open
assertDialog({ state: DialogState.Visible })
// Click the button inside the dialog (shadow DOM)
await click(
document.querySelector('#inside_shadow')?.shadowRoot?.querySelector('#btn_inside_shadow') ??
null
)
// Verify the button was clicked
expect(fn).toHaveBeenCalledTimes(2)
// Verify the dialog is still open
assertDialog({ state: DialogState.Visible })
// Click the button outside the dialog (shadow DOM)
await click(
document
.querySelector('#outside_shadow')
?.shadowRoot?.querySelector('#btn_outside_shadow') ?? null
)
// Verify the button was clicked
expect(fn).toHaveBeenCalledTimes(3)
// Verify the dialog is closed
assertDialog({ state: DialogState.InvisibleUnmounted })
})
)
it(
'should be possible to click elements inside the dialog when they reside inside a shadow boundary',
suppressConsoleLogs(async () => {
@@ -0,0 +1,99 @@
import { createApp, createSSRApp, defineComponent, h } from 'vue'
import { renderToString } from 'vue/server-renderer'
import { TabGroup, TabList, Tab, TabPanels, TabPanel } from './tabs'
import { html } from '../../test-utils/html'
import { render } from '../../test-utils/vue-testing-library'
import { env } from '../../utils/env'
jest.mock('../../hooks/use-id')
beforeAll(() => {
jest.spyOn(window, 'requestAnimationFrame').mockImplementation(setImmediate as any)
jest.spyOn(window, 'cancelAnimationFrame').mockImplementation(clearImmediate as any)
})
afterAll(() => jest.restoreAllMocks())
let Example = defineComponent({
components: { TabGroup, TabList, Tab, TabPanels, TabPanel },
template: html`
<TabGroup>
<TabList>
<Tab>Tab 1</Tab>
<Tab>Tab 2</Tab>
<Tab>Tab 3</Tab>
</TabList>
<TabPanels>
<TabPanel>Content 1</TabPanel>
<TabPanel>Content 2</TabPanel>
<TabPanel>Content 3</TabPanel>
</TabPanels>
</TabGroup>
`,
})
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`)
})
})
describe('Hydration', () => {
it('should be possible to server side render the first Tab and Panel', async () => {
let { 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 () => {
let { contents } = await hydrateRender(Example, { defaultIndex: 1 })
expect(contents).not.toContain(`Content 1`)
expect(contents).toContain(`Content 2`)
expect(contents).not.toContain(`Content 3`)
})
})
})
async function serverRender(component: any, rootProps: any = {}) {
let container = document.createElement('div')
document.body.appendChild(container)
// Render on the server
env.set('server')
let app = createSSRApp(component, rootProps)
let contents = await renderToString(app)
container.innerHTML = contents
return {
contents,
hydrate() {
let app = createApp(component, rootProps)
app.mount(container)
return {
contents: container.innerHTML,
}
},
}
}
async function hydrateRender(component: any, rootProps: any = {}) {
return serverRender(component, rootProps).then(({ hydrate }) => hydrate())
}
@@ -1,5 +1,4 @@
import { createSSRApp, nextTick, ref } from 'vue'
import { renderToString } from 'vue/server-renderer'
import { nextTick, ref } from 'vue'
import { createRenderTemplate, render } from '../../test-utils/vue-testing-library'
import { TabGroup, TabList, Tab, TabPanels, TabPanel } from './tabs'
import { suppressConsoleLogs } from '../../test-utils/suppress-console-logs'
@@ -555,60 +554,6 @@ describe('Rendering', () => {
assertTabs({ active: 2 })
})
})
describe('SSR', () => {
it('should be possible to server side render the first Tab and Panel', async () => {
let app = createSSRApp({
components: { TabGroup, TabList, Tab, TabPanels, TabPanel },
template: html`
<TabGroup>
<TabList>
<Tab>Tab 1</Tab>
<Tab>Tab 2</Tab>
<Tab>Tab 3</Tab>
</TabList>
<TabPanels>
<TabPanel>Content 1</TabPanel>
<TabPanel>Content 2</TabPanel>
<TabPanel>Content 3</TabPanel>
</TabPanels>
</TabGroup>
`,
})
let contents = await renderToString(app)
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 app = createSSRApp({
components: { TabGroup, TabList, Tab, TabPanels, TabPanel },
template: html`
<TabGroup :defaultIndex="1">
<TabList>
<Tab>Tab 1</Tab>
<Tab>Tab 2</Tab>
<Tab>Tab 3</Tab>
</TabList>
<TabPanels>
<TabPanel>Content 1</TabPanel>
<TabPanel>Content 2</TabPanel>
<TabPanel>Content 3</TabPanel>
</TabPanels>
</TabGroup>
`,
})
let contents = await renderToString(app)
expect(contents).not.toContain(`Content 1`)
expect(contents).toContain(`Content 2`)
expect(contents).not.toContain(`Content 3`)
})
})
})
describe('`selectedIndex`', () => {
@@ -246,6 +246,7 @@ export let Tab = defineComponent({
onUnmounted(() => api.unregisterTab(internalTabRef))
let SSRContext = inject(TabsSSRContext)!
// Note: there's a divergence here between React and Vue. Vue can work with `indexOf` implementation while React on the server can't.
let mySSRIndex = computed(() => {
if (SSRContext.value) {
let mySSRIndex = SSRContext.value.tabs.indexOf(props.id)
@@ -1,12 +1,12 @@
import { watchEffect } from 'vue'
import { isServer } from '../utils/ssr'
import { env } from '../utils/env'
export function useDocumentEvent<TType extends keyof DocumentEventMap>(
type: TType,
listener: (this: Document, ev: DocumentEventMap[TType]) => any,
options?: boolean | AddEventListenerOptions
) {
if (isServer) return
if (env.isServer) return
watchEffect((onInvalidate) => {
document.addEventListener(type, listener, options)
@@ -1,5 +1,5 @@
import { watchEffect } from 'vue'
import { isServer } from '../utils/ssr'
import { env } from '../utils/env'
export function useEventListener<TType extends keyof WindowEventMap>(
element: HTMLElement | Document | Window | EventTarget | null | undefined,
@@ -7,7 +7,7 @@ export function useEventListener<TType extends keyof WindowEventMap>(
listener: (event: WindowEventMap[TType]) => any,
options?: boolean | AddEventListenerOptions
) {
if (isServer) return
if (env.isServer) return
watchEffect((onInvalidate) => {
element = element ?? window
@@ -1,12 +1,12 @@
import { watchEffect } from 'vue'
import { isServer } from '../utils/ssr'
import { env } from '../utils/env'
export function useWindowEvent<TType extends keyof WindowEventMap>(
type: TType,
listener: (this: Window, ev: WindowEventMap[TType]) => any,
options?: boolean | AddEventListenerOptions
) {
if (isServer) return
if (env.isServer) return
watchEffect((onInvalidate) => {
window.addEventListener(type, listener, options)
+39
View File
@@ -0,0 +1,39 @@
type RenderEnv = 'client' | 'server'
class Env {
current: RenderEnv = this.detect()
currentId = 0
set(env: RenderEnv): void {
if (this.current === env) return
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'
}
}
export let env = new Env()
+2 -2
View File
@@ -1,11 +1,11 @@
import { Ref } from 'vue'
import { dom } from './dom'
import { isServer } from './ssr'
import { env } from './env'
export function getOwnerDocument<T extends Element | Ref<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('value')) {
let domElement = dom(element)
@@ -1 +0,0 @@
export const isServer = typeof window === 'undefined' || typeof document === 'undefined'