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:
@@ -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'
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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'
|
||||
Reference in New Issue
Block a user