Skip to content

Commit cc163ea

Browse files
authored
Improve SSR of the Disclosure component (#2645)
* pre-calculate the buttonId/panelId on the server * add simple-tabs example * update changelog * make Disclosure IDs stable between server/client
1 parent c22a8c1 commit cc163ea

File tree

5 files changed

+150
-16
lines changed

5 files changed

+150
-16
lines changed

packages/@headlessui-vue/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1414
- Render `<MainTreeNode />` in `PopoverGroup` component only ([#2634](https://github.com/tailwindlabs/headlessui/pull/2634))
1515
- Disable smooth scrolling when opening/closing `Dialog` components on iOS ([#2635](https://github.com/tailwindlabs/headlessui/pull/2635))
1616
- Don't assume `<Tab />` components are available when setting the next index ([#2642](https://github.com/tailwindlabs/headlessui/pull/2642))
17+
- Improve SSR of the `Disclosure` component ([#2645](https://github.com/tailwindlabs/headlessui/pull/2645))
1718

1819
## [1.7.15] - 2023-07-27
1920

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { defineComponent } from 'vue'
2+
import { Disclosure, DisclosureButton, DisclosurePanel } from './disclosure'
3+
import { html } from '../../test-utils/html'
4+
import { renderHydrate, renderSSR } from '../../test-utils/ssr'
5+
6+
jest.mock('../../hooks/use-id')
7+
8+
beforeAll(() => {
9+
jest.spyOn(window, 'requestAnimationFrame').mockImplementation(setImmediate as any)
10+
jest.spyOn(window, 'cancelAnimationFrame').mockImplementation(clearImmediate as any)
11+
})
12+
13+
afterAll(() => jest.restoreAllMocks())
14+
15+
let Example = defineComponent({
16+
components: { Disclosure, DisclosureButton, DisclosurePanel },
17+
template: html`
18+
<Disclosure>
19+
<DisclosureButton>Toggle</DisclosureButton>
20+
<DisclosurePanel>Contents</DisclosurePanel>
21+
</Disclosure>
22+
`,
23+
})
24+
25+
describe('Rendering', () => {
26+
describe('SSR', () => {
27+
it('should be possible to server side render the Disclosure in a closed state', async () => {
28+
let { contents } = await renderSSR(Example)
29+
30+
expect(contents).toContain(`Toggle`)
31+
expect(contents).not.toContain('aria-controls')
32+
expect(contents).not.toContain(`aria-expanded="true"`)
33+
expect(contents).not.toContain(`Contents`)
34+
})
35+
36+
it('should be possible to server side render the Disclosure in an open state', async () => {
37+
let { contents } = await renderSSR(Example, { defaultOpen: true })
38+
39+
let ariaControlsId = contents.match(
40+
/aria-controls="(headlessui-disclosure-panel-[^"]+)"/
41+
)?.[1]
42+
let id = contents.match(/id="(headlessui-disclosure-panel-[^"]+)"/)?.[1]
43+
44+
expect(id).toEqual(ariaControlsId)
45+
46+
expect(contents).toContain(`Toggle`)
47+
expect(contents).toContain('aria-controls')
48+
expect(contents).toContain(`aria-expanded="true"`)
49+
expect(contents).toContain(`Contents`)
50+
})
51+
})
52+
53+
describe('Hydration', () => {
54+
it('should be possible to server side render the Disclosure in a closed state', async () => {
55+
let { contents } = await renderHydrate(Example)
56+
57+
expect(contents).toContain(`Toggle`)
58+
expect(contents).not.toContain('aria-controls')
59+
expect(contents).not.toContain(`aria-expanded="true"`)
60+
expect(contents).not.toContain(`Contents`)
61+
})
62+
63+
it('should be possible to server side render the Disclosure in an open state', async () => {
64+
let { contents } = await renderHydrate(Example, { defaultOpen: true })
65+
66+
let ariaControlsId = contents.match(
67+
/aria-controls="(headlessui-disclosure-panel-[^"]+)"/
68+
)?.[1]
69+
let id = contents.match(/id="(headlessui-disclosure-panel-[^"]+)"/)?.[1]
70+
71+
expect(id).toEqual(ariaControlsId)
72+
73+
expect(contents).toContain(`Toggle`)
74+
expect(contents).toContain('aria-controls')
75+
expect(contents).toContain(`aria-expanded="true"`)
76+
expect(contents).toContain(`Contents`)
77+
})
78+
})
79+
})

packages/@headlessui-vue/src/components/disclosure/disclosure.ts

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,8 @@ export let Disclosure = defineComponent({
7676
let buttonRef = ref<StateDefinition['button']['value']>(null)
7777

7878
let api = {
79-
buttonId: ref(null),
80-
panelId: ref(null),
79+
buttonId: ref(`headlessui-disclosure-button-${useId()}`),
80+
panelId: ref(`headlessui-disclosure-panel-${useId()}`),
8181
disclosureState,
8282
panel: panelRef,
8383
button: buttonRef,
@@ -138,23 +138,27 @@ export let DisclosureButton = defineComponent({
138138
props: {
139139
as: { type: [Object, String], default: 'button' },
140140
disabled: { type: [Boolean], default: false },
141-
id: { type: String, default: () => `headlessui-disclosure-button-${useId()}` },
141+
id: { type: String, default: null },
142142
},
143143
setup(props, { attrs, slots, expose }) {
144144
let api = useDisclosureContext('DisclosureButton')
145145

146+
let panelContext = useDisclosurePanelContext()
147+
let isWithinPanel = computed(() =>
148+
panelContext === null ? false : panelContext.value === api.panelId.value
149+
)
150+
146151
onMounted(() => {
147-
api.buttonId.value = props.id
152+
if (isWithinPanel.value) return
153+
if (props.id !== null) {
154+
api.buttonId.value = props.id
155+
}
148156
})
149157
onUnmounted(() => {
158+
if (isWithinPanel.value) return
150159
api.buttonId.value = null
151160
})
152161

153-
let panelContext = useDisclosurePanelContext()
154-
let isWithinPanel = computed(() =>
155-
panelContext === null ? false : panelContext.value === api.panelId.value
156-
)
157-
158162
let internalButtonRef = ref<HTMLButtonElement | null>(null)
159163

160164
expose({ el: internalButtonRef, $el: internalButtonRef })
@@ -226,11 +230,14 @@ export let DisclosureButton = defineComponent({
226230
onKeydown: handleKeyDown,
227231
}
228232
: {
229-
id,
233+
id: api.buttonId.value ?? id,
230234
ref: internalButtonRef,
231235
type: type.value,
232236
'aria-expanded': api.disclosureState.value === DisclosureStates.Open,
233-
'aria-controls': dom(api.panel) ? api.panelId.value : undefined,
237+
'aria-controls':
238+
api.disclosureState.value === DisclosureStates.Open || dom(api.panel)
239+
? api.panelId.value
240+
: undefined,
234241
disabled: props.disabled ? true : undefined,
235242
onClick: handleClick,
236243
onKeydown: handleKeyDown,
@@ -257,13 +264,15 @@ export let DisclosurePanel = defineComponent({
257264
as: { type: [Object, String], default: 'div' },
258265
static: { type: Boolean, default: false },
259266
unmount: { type: Boolean, default: true },
260-
id: { type: String, default: () => `headlessui-disclosure-panel-${useId()}` },
267+
id: { type: String, default: null },
261268
},
262269
setup(props, { attrs, slots, expose }) {
263270
let api = useDisclosureContext('DisclosurePanel')
264271

265272
onMounted(() => {
266-
api.panelId.value = props.id
273+
if (props.id !== null) {
274+
api.panelId.value = props.id
275+
}
267276
})
268277
onUnmounted(() => {
269278
api.panelId.value = null
@@ -285,7 +294,7 @@ export let DisclosurePanel = defineComponent({
285294
return () => {
286295
let slot = { open: api.disclosureState.value === DisclosureStates.Open, close: api.close }
287296
let { id, ...theirProps } = props
288-
let ourProps = { id, ref: api.panel }
297+
let ourProps = { id: api.panelId.value ?? id, ref: api.panel }
289298

290299
return render({
291300
ourProps,

packages/@headlessui-vue/src/test-utils/ssr.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { createApp, createSSRApp } from 'vue'
1+
import { createApp, createSSRApp, nextTick } from 'vue'
22
import { renderToString } from 'vue/server-renderer'
33
import { env } from '../utils/env'
44

@@ -14,10 +14,12 @@ export async function renderSSR(component: any, rootProps: any = {}) {
1414

1515
return {
1616
contents,
17-
hydrate() {
17+
async hydrate() {
1818
let app = createApp(component, rootProps)
1919
app.mount(container)
2020

21+
await nextTick()
22+
2123
return {
2224
contents: container.innerHTML,
2325
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<template>
2+
<div class="flex h-full w-screen flex-col items-start space-y-12 bg-gray-50 p-12">
3+
<TabGroup class="flex w-full max-w-3xl flex-col" as="div">
4+
<TabList class="relative z-0 flex divide-x divide-gray-200 rounded-lg shadow">
5+
<Tab
6+
v-for="tab in tabs"
7+
:key="tab.name"
8+
class="group relative min-w-0 flex-1 overflow-hidden bg-white py-4 px-4 text-center text-sm font-medium hover:bg-gray-50 focus:z-10"
9+
v-slot="{ selected }"
10+
>
11+
<span>{{ tab.name }}</span>
12+
<small v-if="tab.disabled" class="inline-block px-4 text-xs">(disabled)</small>
13+
<span
14+
aria-hidden="true"
15+
class="absolute inset-x-0 bottom-0 h-0.5"
16+
:class="{ 'bg-indigo-500': selected, 'bg-transparent': !selected }"
17+
/>
18+
</Tab>
19+
</TabList>
20+
21+
<TabPanels class="mt-4">
22+
<TabPanel v-for="tab in tabs" class="rounded-lg bg-white p-4 shadow" :key="tab.name">
23+
{{ tab.content }}
24+
</TabPanel>
25+
</TabPanels>
26+
</TabGroup>
27+
</div>
28+
</template>
29+
30+
<script setup>
31+
import { TabGroup, TabList, Tab, TabPanels, TabPanel } from '@headlessui/vue'
32+
33+
function classNames(...classes) {
34+
return classes.filter(Boolean).join(' ')
35+
}
36+
37+
let tabs = [
38+
{ name: 'My Account', content: 'Tab content for my account' },
39+
{ name: 'Company', content: 'Tab content for company' },
40+
{ name: 'Team Members', content: 'Tab content for team members' },
41+
{ name: 'Billing', content: 'Tab content for billing' },
42+
]
43+
</script>

0 commit comments

Comments
 (0)