Skip to content

Commit

Permalink
Improve SSR of the Disclosure component (#2645)
Browse files Browse the repository at this point in the history
* pre-calculate the buttonId/panelId on the server

* add simple-tabs example

* update changelog

* make Disclosure IDs stable between server/client
  • Loading branch information
RobinMalfait committed Aug 3, 2023
1 parent c22a8c1 commit cc163ea
Show file tree
Hide file tree
Showing 5 changed files with 150 additions and 16 deletions.
1 change: 1 addition & 0 deletions packages/@headlessui-vue/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Render `<MainTreeNode />` in `PopoverGroup` component only ([#2634](https://github.com/tailwindlabs/headlessui/pull/2634))
- Disable smooth scrolling when opening/closing `Dialog` components on iOS ([#2635](https://github.com/tailwindlabs/headlessui/pull/2635))
- Don't assume `<Tab />` components are available when setting the next index ([#2642](https://github.com/tailwindlabs/headlessui/pull/2642))
- Improve SSR of the `Disclosure` component ([#2645](https://github.com/tailwindlabs/headlessui/pull/2645))

## [1.7.15] - 2023-07-27

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { defineComponent } from 'vue'
import { Disclosure, DisclosureButton, DisclosurePanel } from './disclosure'
import { html } from '../../test-utils/html'
import { renderHydrate, renderSSR } from '../../test-utils/ssr'

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: { Disclosure, DisclosureButton, DisclosurePanel },
template: html`
<Disclosure>
<DisclosureButton>Toggle</DisclosureButton>
<DisclosurePanel>Contents</DisclosurePanel>
</Disclosure>
`,
})

describe('Rendering', () => {
describe('SSR', () => {
it('should be possible to server side render the Disclosure in a closed state', async () => {
let { contents } = await renderSSR(Example)

expect(contents).toContain(`Toggle`)
expect(contents).not.toContain('aria-controls')
expect(contents).not.toContain(`aria-expanded="true"`)
expect(contents).not.toContain(`Contents`)
})

it('should be possible to server side render the Disclosure in an open state', async () => {
let { contents } = await renderSSR(Example, { defaultOpen: true })

let ariaControlsId = contents.match(
/aria-controls="(headlessui-disclosure-panel-[^"]+)"/
)?.[1]
let id = contents.match(/id="(headlessui-disclosure-panel-[^"]+)"/)?.[1]

expect(id).toEqual(ariaControlsId)

expect(contents).toContain(`Toggle`)
expect(contents).toContain('aria-controls')
expect(contents).toContain(`aria-expanded="true"`)
expect(contents).toContain(`Contents`)
})
})

describe('Hydration', () => {
it('should be possible to server side render the Disclosure in a closed state', async () => {
let { contents } = await renderHydrate(Example)

expect(contents).toContain(`Toggle`)
expect(contents).not.toContain('aria-controls')
expect(contents).not.toContain(`aria-expanded="true"`)
expect(contents).not.toContain(`Contents`)
})

it('should be possible to server side render the Disclosure in an open state', async () => {
let { contents } = await renderHydrate(Example, { defaultOpen: true })

let ariaControlsId = contents.match(
/aria-controls="(headlessui-disclosure-panel-[^"]+)"/
)?.[1]
let id = contents.match(/id="(headlessui-disclosure-panel-[^"]+)"/)?.[1]

expect(id).toEqual(ariaControlsId)

expect(contents).toContain(`Toggle`)
expect(contents).toContain('aria-controls')
expect(contents).toContain(`aria-expanded="true"`)
expect(contents).toContain(`Contents`)
})
})
})
37 changes: 23 additions & 14 deletions packages/@headlessui-vue/src/components/disclosure/disclosure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,8 @@ export let Disclosure = defineComponent({
let buttonRef = ref<StateDefinition['button']['value']>(null)

let api = {
buttonId: ref(null),
panelId: ref(null),
buttonId: ref(`headlessui-disclosure-button-${useId()}`),
panelId: ref(`headlessui-disclosure-panel-${useId()}`),
disclosureState,
panel: panelRef,
button: buttonRef,
Expand Down Expand Up @@ -138,23 +138,27 @@ export let DisclosureButton = defineComponent({
props: {
as: { type: [Object, String], default: 'button' },
disabled: { type: [Boolean], default: false },
id: { type: String, default: () => `headlessui-disclosure-button-${useId()}` },
id: { type: String, default: null },
},
setup(props, { attrs, slots, expose }) {
let api = useDisclosureContext('DisclosureButton')

let panelContext = useDisclosurePanelContext()
let isWithinPanel = computed(() =>
panelContext === null ? false : panelContext.value === api.panelId.value
)

onMounted(() => {
api.buttonId.value = props.id
if (isWithinPanel.value) return
if (props.id !== null) {
api.buttonId.value = props.id
}
})
onUnmounted(() => {
if (isWithinPanel.value) return
api.buttonId.value = null
})

let panelContext = useDisclosurePanelContext()
let isWithinPanel = computed(() =>
panelContext === null ? false : panelContext.value === api.panelId.value
)

let internalButtonRef = ref<HTMLButtonElement | null>(null)

expose({ el: internalButtonRef, $el: internalButtonRef })
Expand Down Expand Up @@ -226,11 +230,14 @@ export let DisclosureButton = defineComponent({
onKeydown: handleKeyDown,
}
: {
id,
id: api.buttonId.value ?? id,
ref: internalButtonRef,
type: type.value,
'aria-expanded': api.disclosureState.value === DisclosureStates.Open,
'aria-controls': dom(api.panel) ? api.panelId.value : undefined,
'aria-controls':
api.disclosureState.value === DisclosureStates.Open || dom(api.panel)
? api.panelId.value
: undefined,
disabled: props.disabled ? true : undefined,
onClick: handleClick,
onKeydown: handleKeyDown,
Expand All @@ -257,13 +264,15 @@ export let DisclosurePanel = defineComponent({
as: { type: [Object, String], default: 'div' },
static: { type: Boolean, default: false },
unmount: { type: Boolean, default: true },
id: { type: String, default: () => `headlessui-disclosure-panel-${useId()}` },
id: { type: String, default: null },
},
setup(props, { attrs, slots, expose }) {
let api = useDisclosureContext('DisclosurePanel')

onMounted(() => {
api.panelId.value = props.id
if (props.id !== null) {
api.panelId.value = props.id
}
})
onUnmounted(() => {
api.panelId.value = null
Expand All @@ -285,7 +294,7 @@ export let DisclosurePanel = defineComponent({
return () => {
let slot = { open: api.disclosureState.value === DisclosureStates.Open, close: api.close }
let { id, ...theirProps } = props
let ourProps = { id, ref: api.panel }
let ourProps = { id: api.panelId.value ?? id, ref: api.panel }

return render({
ourProps,
Expand Down
6 changes: 4 additions & 2 deletions packages/@headlessui-vue/src/test-utils/ssr.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createApp, createSSRApp } from 'vue'
import { createApp, createSSRApp, nextTick } from 'vue'
import { renderToString } from 'vue/server-renderer'
import { env } from '../utils/env'

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

return {
contents,
hydrate() {
async hydrate() {
let app = createApp(component, rootProps)
app.mount(container)

await nextTick()

return {
contents: container.innerHTML,
}
Expand Down
43 changes: 43 additions & 0 deletions packages/playground-vue/src/components/tabs/simple-tabs.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<template>
<div class="flex h-full w-screen flex-col items-start space-y-12 bg-gray-50 p-12">
<TabGroup class="flex w-full max-w-3xl flex-col" as="div">
<TabList class="relative z-0 flex divide-x divide-gray-200 rounded-lg shadow">
<Tab
v-for="tab in tabs"
:key="tab.name"
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"
v-slot="{ selected }"
>
<span>{{ tab.name }}</span>
<small v-if="tab.disabled" class="inline-block px-4 text-xs">(disabled)</small>
<span
aria-hidden="true"
class="absolute inset-x-0 bottom-0 h-0.5"
:class="{ 'bg-indigo-500': selected, 'bg-transparent': !selected }"
/>
</Tab>
</TabList>

<TabPanels class="mt-4">
<TabPanel v-for="tab in tabs" class="rounded-lg bg-white p-4 shadow" :key="tab.name">
{{ tab.content }}
</TabPanel>
</TabPanels>
</TabGroup>
</div>
</template>

<script setup>
import { TabGroup, TabList, Tab, TabPanels, TabPanel } from '@headlessui/vue'
function classNames(...classes) {
return classes.filter(Boolean).join(' ')
}
let tabs = [
{ name: 'My Account', content: 'Tab content for my account' },
{ name: 'Company', content: 'Tab content for company' },
{ name: 'Team Members', content: 'Tab content for team members' },
{ name: 'Billing', content: 'Tab content for billing' },
]
</script>

2 comments on commit cc163ea

@vercel
Copy link

@vercel vercel bot commented on cc163ea Aug 3, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

headlessui-vue – ./packages/playground-vue

headlessui-vue.vercel.app
headlessui-vue-git-main-tailwindlabs.vercel.app
headlessui-vue-tailwindlabs.vercel.app

@vercel
Copy link

@vercel vercel bot commented on cc163ea Aug 3, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

headlessui-react – ./packages/playground-react

headlessui-react.vercel.app
headlessui-react-tailwindlabs.vercel.app
headlessui-react-git-main-tailwindlabs.vercel.app

Please sign in to comment.