diff --git a/packages/@headlessui-react/CHANGELOG.md b/packages/@headlessui-react/CHANGELOG.md index 9d93b54740..394780c51b 100644 --- a/packages/@headlessui-react/CHANGELOG.md +++ b/packages/@headlessui-react/CHANGELOG.md @@ -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 diff --git a/packages/@headlessui-react/src/components/portal/portal.tsx b/packages/@headlessui-react/src/components/portal/portal.tsx index 614a80a1b9..8ea5bf56e5 100644 --- a/packages/@headlessui-react/src/components/portal/portal.tsx +++ b/packages/@headlessui-react/src/components/portal/portal.tsx @@ -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 { let forceInRoot = usePortalRoot() @@ -34,7 +34,7 @@ function usePortalTarget(ref: MutableRefObject): 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(() => - isServer ? null : ownerDocument?.createElement('div') ?? null + env.isServer ? null : ownerDocument?.createElement('div') ?? null ) let ready = useServerHandoffComplete() diff --git a/packages/@headlessui-react/src/components/tabs/tabs.ssr.test.tsx b/packages/@headlessui-react/src/components/tabs/tabs.ssr.test.tsx new file mode 100644 index 0000000000..31736b6a0b --- /dev/null +++ b/packages/@headlessui-react/src/components/tabs/tabs.ssr.test.tsx @@ -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 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + + ) +} + +describe('Rendering', () => { + describe('SSR', () => { + it('should be possible to server side render the first Tab and Panel', async () => { + let { contents } = await serverRender() + + 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() + + 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() + + 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() + + expect(contents).not.toContain(`Content 1`) + expect(contents).toContain(`Content 2`) + expect(contents).not.toContain(`Content 3`) + }) + }) +}) + +type ServerRenderOptions = Omit & { + strict?: boolean +} + +interface ServerRenderResult { + type: 'ssr' | 'hydrate' + contents: string + result: RenderResult + hydrate: () => Promise +} + +async function serverRender( + ui: ReactElement, + options: ServerRenderOptions = {} +): Promise { + let container = document.createElement('div') + document.body.appendChild(container) + options = { ...options, container } + + if (options.strict) { + options = { + ...options, + wrapper({ children }) { + return {children} + }, + } + } + + env.set('server') + let contents = renderToString(ui) + let result = render(
, options) + + async function hydrate(): Promise { + // 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()) +} diff --git a/packages/@headlessui-react/src/components/tabs/tabs.tsx b/packages/@headlessui-react/src/components/tabs/tabs.tsx index 0493e5abe0..7356c321fe 100644 --- a/packages/@headlessui-react/src/components/tabs/tabs.tsx +++ b/packages/@headlessui-react/src/components/tabs/tabs.tsx @@ -110,9 +110,7 @@ let reducers: { }, } -let TabsSSRContext = createContext | null>( - null -) +let TabsSSRContext = createContext | null>(null) TabsSSRContext.displayName = 'TabsSSRContext' function useSSRTabsCounter(component: string) { @@ -239,7 +237,7 @@ let Tabs = forwardRefWithAs(function Tabs 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 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 diff --git a/packages/@headlessui-react/src/components/transitions/transition.tsx b/packages/@headlessui-react/src/components/transitions/transition.tsx index a87a26a6a3..59c4f6349e 100644 --- a/packages/@headlessui-react/src/components/transitions/transition.tsx +++ b/packages/@headlessui-react/src/components/transitions/transition.tsx @@ -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 @@ -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 diff --git a/packages/@headlessui-react/src/hooks/use-id.ts b/packages/@headlessui-react/src/hooks/use-id.ts index 772d6512a3..761d6c1c34 100644 --- a/packages/@headlessui-react/src/hooks/use-id.ts +++ b/packages/@headlessui-react/src/hooks/use-id.ts @@ -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 diff --git a/packages/@headlessui-react/src/hooks/use-iso-morphic-effect.ts b/packages/@headlessui-react/src/hooks/use-iso-morphic-effect.ts index 79a221926c..8f81b4efad 100644 --- a/packages/@headlessui-react/src/hooks/use-iso-morphic-effect.ts +++ b/packages/@headlessui-react/src/hooks/use-iso-morphic-effect.ts @@ -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) + } +} diff --git a/packages/@headlessui-react/src/hooks/use-server-handoff-complete.ts b/packages/@headlessui-react/src/hooks/use-server-handoff-complete.ts index b24bc66838..b931ea2d68 100644 --- a/packages/@headlessui-react/src/hooks/use-server-handoff-complete.ts +++ b/packages/@headlessui-react/src/hooks/use-server-handoff-complete.ts @@ -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 } diff --git a/packages/@headlessui-react/src/utils/env.ts b/packages/@headlessui-react/src/utils/env.ts new file mode 100644 index 0000000000..38a47df156 --- /dev/null +++ b/packages/@headlessui-react/src/utils/env.ts @@ -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() diff --git a/packages/@headlessui-react/src/utils/owner.ts b/packages/@headlessui-react/src/utils/owner.ts index 5bbc1862d2..71f256ed95 100644 --- a/packages/@headlessui-react/src/utils/owner.ts +++ b/packages/@headlessui-react/src/utils/owner.ts @@ -1,10 +1,10 @@ import { MutableRefObject } from 'react' -import { isServer } from './ssr' +import { env } from './env' export function getOwnerDocument>( 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 diff --git a/packages/@headlessui-react/src/utils/ssr.ts b/packages/@headlessui-react/src/utils/ssr.ts deleted file mode 100644 index 5df1767248..0000000000 --- a/packages/@headlessui-react/src/utils/ssr.ts +++ /dev/null @@ -1 +0,0 @@ -export const isServer = typeof window === 'undefined' || typeof document === 'undefined' diff --git a/packages/@headlessui-vue/CHANGELOG.md b/packages/@headlessui-vue/CHANGELOG.md index f9c095cdee..e951d717fb 100644 --- a/packages/@headlessui-vue/CHANGELOG.md +++ b/packages/@headlessui-vue/CHANGELOG.md @@ -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 diff --git a/packages/@headlessui-vue/src/components/dialog/dialog.test.ts b/packages/@headlessui-vue/src/components/dialog/dialog.test.ts index c54ff3f796..3ec863e62e 100644 --- a/packages/@headlessui-vue/src/components/dialog/dialog.test.ts +++ b/packages/@headlessui-vue/src/components/dialog/dialog.test.ts @@ -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(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: ` +
+ + +
+ + +
+ + + + +
+
+ `, + 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 () => { diff --git a/packages/@headlessui-vue/src/components/tabs/tabs.ssr.test.ts b/packages/@headlessui-vue/src/components/tabs/tabs.ssr.test.ts new file mode 100644 index 0000000000..eaa9648c18 --- /dev/null +++ b/packages/@headlessui-vue/src/components/tabs/tabs.ssr.test.ts @@ -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` + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + + `, +}) + +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()) +} diff --git a/packages/@headlessui-vue/src/components/tabs/tabs.test.ts b/packages/@headlessui-vue/src/components/tabs/tabs.test.ts index af8636c14f..f0a3aa6904 100644 --- a/packages/@headlessui-vue/src/components/tabs/tabs.test.ts +++ b/packages/@headlessui-vue/src/components/tabs/tabs.test.ts @@ -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` - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - - `, - }) - - 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` - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - - `, - }) - - let contents = await renderToString(app) - expect(contents).not.toContain(`Content 1`) - expect(contents).toContain(`Content 2`) - expect(contents).not.toContain(`Content 3`) - }) - }) }) describe('`selectedIndex`', () => { diff --git a/packages/@headlessui-vue/src/components/tabs/tabs.ts b/packages/@headlessui-vue/src/components/tabs/tabs.ts index dd38c2ca08..34ced27784 100644 --- a/packages/@headlessui-vue/src/components/tabs/tabs.ts +++ b/packages/@headlessui-vue/src/components/tabs/tabs.ts @@ -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) diff --git a/packages/@headlessui-vue/src/hooks/use-document-event.ts b/packages/@headlessui-vue/src/hooks/use-document-event.ts index b0aa33dd72..980011beec 100644 --- a/packages/@headlessui-vue/src/hooks/use-document-event.ts +++ b/packages/@headlessui-vue/src/hooks/use-document-event.ts @@ -1,12 +1,12 @@ import { watchEffect } from 'vue' -import { isServer } from '../utils/ssr' +import { env } from '../utils/env' export function useDocumentEvent( 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) diff --git a/packages/@headlessui-vue/src/hooks/use-event-listener.ts b/packages/@headlessui-vue/src/hooks/use-event-listener.ts index 07a507e120..3c40479e88 100644 --- a/packages/@headlessui-vue/src/hooks/use-event-listener.ts +++ b/packages/@headlessui-vue/src/hooks/use-event-listener.ts @@ -1,5 +1,5 @@ import { watchEffect } from 'vue' -import { isServer } from '../utils/ssr' +import { env } from '../utils/env' export function useEventListener( element: HTMLElement | Document | Window | EventTarget | null | undefined, @@ -7,7 +7,7 @@ export function useEventListener( listener: (event: WindowEventMap[TType]) => any, options?: boolean | AddEventListenerOptions ) { - if (isServer) return + if (env.isServer) return watchEffect((onInvalidate) => { element = element ?? window diff --git a/packages/@headlessui-vue/src/hooks/use-window-event.ts b/packages/@headlessui-vue/src/hooks/use-window-event.ts index 267d78b70f..d1ee703caf 100644 --- a/packages/@headlessui-vue/src/hooks/use-window-event.ts +++ b/packages/@headlessui-vue/src/hooks/use-window-event.ts @@ -1,12 +1,12 @@ import { watchEffect } from 'vue' -import { isServer } from '../utils/ssr' +import { env } from '../utils/env' export function useWindowEvent( 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) diff --git a/packages/@headlessui-vue/src/utils/env.ts b/packages/@headlessui-vue/src/utils/env.ts new file mode 100644 index 0000000000..2f53f13ee3 --- /dev/null +++ b/packages/@headlessui-vue/src/utils/env.ts @@ -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() diff --git a/packages/@headlessui-vue/src/utils/owner.ts b/packages/@headlessui-vue/src/utils/owner.ts index eebd7c6550..59a4bd5bc4 100644 --- a/packages/@headlessui-vue/src/utils/owner.ts +++ b/packages/@headlessui-vue/src/utils/owner.ts @@ -1,11 +1,11 @@ import { Ref } from 'vue' import { dom } from './dom' -import { isServer } from './ssr' +import { env } from './env' export function getOwnerDocument>( 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) diff --git a/packages/@headlessui-vue/src/utils/ssr.ts b/packages/@headlessui-vue/src/utils/ssr.ts deleted file mode 100644 index 5df1767248..0000000000 --- a/packages/@headlessui-vue/src/utils/ssr.ts +++ /dev/null @@ -1 +0,0 @@ -export const isServer = typeof window === 'undefined' || typeof document === 'undefined'