diff --git a/packages/@headlessui-react/CHANGELOG.md b/packages/@headlessui-react/CHANGELOG.md
index 07c20e9243..1ef024bd08 100644
--- a/packages/@headlessui-react/CHANGELOG.md
+++ b/packages/@headlessui-react/CHANGELOG.md
@@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Fix `Tab` key with non focusable elements in `Popover.Panel` ([#2147](https://github.com/tailwindlabs/headlessui/pull/2147))
- Fix false positive warning when using `` in React 17 ([#2163](https://github.com/tailwindlabs/headlessui/pull/2163))
- Fix `failed to removeChild on Node` bug ([#2164](https://github.com/tailwindlabs/headlessui/pull/2164))
+- Don’t overwrite classes during SSR when rendering fragments ([#2173](https://github.com/tailwindlabs/headlessui/pull/2173))
## [1.7.7] - 2022-12-16
diff --git a/packages/@headlessui-react/src/components/tabs/tabs.ssr.test.tsx b/packages/@headlessui-react/src/components/tabs/tabs.ssr.test.tsx
index 31736b6a0b..c28834ee50 100644
--- a/packages/@headlessui-react/src/components/tabs/tabs.ssr.test.tsx
+++ b/packages/@headlessui-react/src/components/tabs/tabs.ssr.test.tsx
@@ -1,9 +1,6 @@
-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 React from 'react'
import { Tab } from './tabs'
-import { env } from '../../utils/env'
+import { renderSSR, renderHydrate } from '../../test-utils/ssr'
beforeAll(() => {
jest.spyOn(window, 'requestAnimationFrame').mockImplementation(setImmediate as any)
@@ -31,7 +28,7 @@ function Example({ defaultIndex = 0 }) {
describe('Rendering', () => {
describe('SSR', () => {
it('should be possible to server side render the first Tab and Panel', async () => {
- let { contents } = await serverRender()
+ let { contents } = await renderSSR()
expect(contents).toContain(`Content 1`)
expect(contents).not.toContain(`Content 2`)
@@ -39,7 +36,7 @@ describe('Rendering', () => {
})
it('should be possible to server side render the defaultIndex Tab and Panel', async () => {
- let { contents } = await serverRender()
+ let { contents } = await renderSSR()
expect(contents).not.toContain(`Content 1`)
expect(contents).toContain(`Content 2`)
@@ -51,7 +48,7 @@ describe('Rendering', () => {
// Skipping for now
xdescribe('Hydration', () => {
it('should be possible to server side render the first Tab and Panel', async () => {
- const { contents } = await hydrateRender()
+ const { contents } = await renderHydrate()
expect(contents).toContain(`Content 1`)
expect(contents).not.toContain(`Content 2`)
@@ -59,7 +56,7 @@ describe('Rendering', () => {
})
it('should be possible to server side render the defaultIndex Tab and Panel', async () => {
- const { contents } = await hydrateRender()
+ const { contents } = await renderHydrate()
expect(contents).not.toContain(`Content 1`)
expect(contents).toContain(`Content 2`)
@@ -67,68 +64,3 @@ describe('Rendering', () => {
})
})
})
-
-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/transitions/transition.ssr.test.tsx b/packages/@headlessui-react/src/components/transitions/transition.ssr.test.tsx
new file mode 100644
index 0000000000..64a6108abd
--- /dev/null
+++ b/packages/@headlessui-react/src/components/transitions/transition.ssr.test.tsx
@@ -0,0 +1,32 @@
+import React, { Fragment } from 'react'
+import { Transition } from './transition'
+import { renderSSR } from '../../test-utils/ssr'
+
+beforeAll(() => {
+ jest.spyOn(window, 'requestAnimationFrame').mockImplementation(setImmediate as any)
+ jest.spyOn(window, 'cancelAnimationFrame').mockImplementation(clearImmediate as any)
+})
+
+describe('Rendering', () => {
+ describe('SSR', () => {
+ it('should not overwrite className of children when as=Fragment', async () => {
+ await renderSSR(
+
+
+
+ )
+
+ let div = document.querySelector('.inner')
+
+ expect(div).not.toBeNull()
+ expect(div?.className).toBe('inner enter enter-from')
+ })
+ })
+})
diff --git a/packages/@headlessui-react/src/test-utils/ssr.tsx b/packages/@headlessui-react/src/test-utils/ssr.tsx
new file mode 100644
index 0000000000..2ec14c98b7
--- /dev/null
+++ b/packages/@headlessui-react/src/test-utils/ssr.tsx
@@ -0,0 +1,70 @@
+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 { env } from '../utils/env'
+
+type ServerRenderOptions = Omit & {
+ strict?: boolean
+}
+
+interface ServerRenderResult {
+ type: 'ssr' | 'hydrate'
+ contents: string
+ result: RenderResult
+ hydrate: () => Promise
+}
+
+export async function renderSSR(
+ 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,
+ }
+}
+
+export async function renderHydrate(el: ReactElement, options: ServerRenderOptions = {}) {
+ return renderSSR(el, options).then((r) => r.hydrate())
+}
diff --git a/packages/@headlessui-react/src/utils/render.ts b/packages/@headlessui-react/src/utils/render.ts
index 2978f46f2e..a0c8663308 100644
--- a/packages/@headlessui-react/src/utils/render.ts
+++ b/packages/@headlessui-react/src/utils/render.ts
@@ -10,6 +10,8 @@ import {
ReactElement,
} from 'react'
import { Props, XOR, __, Expand } from '../types'
+import { classNames } from './class-names'
+import { env } from './env'
import { match } from './match'
export enum Features {
@@ -168,6 +170,10 @@ function _render(
)
}
+ // Merge class name prop in SSR
+ let newClassName = classNames(resolvedChildren.props?.className, rest.className)
+ let classNameProps = newClassName ? { className: newClassName } : {}
+
return cloneElement(
resolvedChildren,
Object.assign(
@@ -176,7 +182,8 @@ function _render(
mergeProps(resolvedChildren.props, compact(omit(rest, ['ref']))),
dataAttributes,
refRelatedProps,
- mergeRefs((resolvedChildren as any).ref, refRelatedProps.ref)
+ mergeRefs((resolvedChildren as any).ref, refRelatedProps.ref),
+ classNameProps
)
)
}
diff --git a/packages/@headlessui-vue/CHANGELOG.md b/packages/@headlessui-vue/CHANGELOG.md
index 95c12df5da..f7078d4704 100644
--- a/packages/@headlessui-vue/CHANGELOG.md
+++ b/packages/@headlessui-vue/CHANGELOG.md
@@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Ensure `disabled="false"` is not incorrectly passed to the underlying DOM Node ([#2138](https://github.com/tailwindlabs/headlessui/pull/2138))
- Fix arrow key handling in `Tab` (after DOM order changes) ([#2145](https://github.com/tailwindlabs/headlessui/pull/2145))
- Fix `Tab` key with non focusable elements in `Popover.Panel` ([#2147](https://github.com/tailwindlabs/headlessui/pull/2147))
+- Don’t overwrite classes during SSR when rendering fragments ([#2173](https://github.com/tailwindlabs/headlessui/pull/2173))
## [1.7.7] - 2022-12-16
diff --git a/packages/@headlessui-vue/src/components/tabs/tabs.ssr.test.ts b/packages/@headlessui-vue/src/components/tabs/tabs.ssr.test.ts
index eaa9648c18..144436364c 100644
--- a/packages/@headlessui-vue/src/components/tabs/tabs.ssr.test.ts
+++ b/packages/@headlessui-vue/src/components/tabs/tabs.ssr.test.ts
@@ -1,9 +1,7 @@
-import { createApp, createSSRApp, defineComponent, h } from 'vue'
-import { renderToString } from 'vue/server-renderer'
+import { defineComponent } from 'vue'
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'
+import { renderHydrate, renderSSR } from '../../test-utils/ssr'
jest.mock('../../hooks/use-id')
@@ -36,7 +34,7 @@ let Example = defineComponent({
describe('Rendering', () => {
describe('SSR', () => {
it('should be possible to server side render the first Tab and Panel', async () => {
- let { contents } = await serverRender(Example)
+ let { contents } = await renderSSR(Example)
expect(contents).toContain(`Content 1`)
expect(contents).not.toContain(`Content 2`)
@@ -44,7 +42,7 @@ describe('Rendering', () => {
})
it('should be possible to server side render the defaultIndex Tab and Panel', async () => {
- let { contents } = await serverRender(Example, { defaultIndex: 1 })
+ let { contents } = await renderSSR(Example, { defaultIndex: 1 })
expect(contents).not.toContain(`Content 1`)
expect(contents).toContain(`Content 2`)
@@ -54,7 +52,7 @@ describe('Rendering', () => {
describe('Hydration', () => {
it('should be possible to server side render the first Tab and Panel', async () => {
- let { contents } = await hydrateRender(Example)
+ let { contents } = await renderHydrate(Example)
expect(contents).toContain(`Content 1`)
expect(contents).not.toContain(`Content 2`)
@@ -62,7 +60,7 @@ describe('Rendering', () => {
})
it('should be possible to server side render the defaultIndex Tab and Panel', async () => {
- let { contents } = await hydrateRender(Example, { defaultIndex: 1 })
+ let { contents } = await renderHydrate(Example, { defaultIndex: 1 })
expect(contents).not.toContain(`Content 1`)
expect(contents).toContain(`Content 2`)
@@ -70,30 +68,3 @@ describe('Rendering', () => {
})
})
})
-
-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/transitions/transition.ssr.test.ts b/packages/@headlessui-vue/src/components/transitions/transition.ssr.test.ts
new file mode 100644
index 0000000000..a43053eb04
--- /dev/null
+++ b/packages/@headlessui-vue/src/components/transitions/transition.ssr.test.ts
@@ -0,0 +1,38 @@
+import * as Transition from './transition'
+import { renderSSR } from '../../test-utils/ssr'
+import { defineComponent } from 'vue'
+import { html } from '../../test-utils/html'
+
+beforeAll(() => {
+ jest.spyOn(window, 'requestAnimationFrame').mockImplementation(setImmediate as any)
+ jest.spyOn(window, 'cancelAnimationFrame').mockImplementation(clearImmediate as any)
+})
+
+describe('Rendering', () => {
+ describe('SSR', () => {
+ it('should not overwrite className of children when as=Fragment', async () => {
+ await renderSSR(
+ defineComponent({
+ components: Transition,
+ template: html`
+
+
+
+ `,
+ })
+ )
+
+ let div = document.querySelector('.inner')
+
+ expect(div).not.toBeNull()
+ expect(div?.className).toBe('inner enter enter-from')
+ })
+ })
+})
diff --git a/packages/@headlessui-vue/src/components/transitions/transition.ts b/packages/@headlessui-vue/src/components/transitions/transition.ts
index b7b7914066..527c250707 100644
--- a/packages/@headlessui-vue/src/components/transitions/transition.ts
+++ b/packages/@headlessui-vue/src/components/transitions/transition.ts
@@ -14,10 +14,12 @@ import {
InjectionKey,
Ref,
ConcreteComponent,
+ normalizeClass,
} from 'vue'
import { useId } from '../../hooks/use-id'
import { match } from '../../utils/match'
+import { env } from '../../utils/env'
import { Features, omit, render, RenderStrategy } from '../../utils/render'
import { Reason, transition } from './utils/transition'
@@ -312,8 +314,8 @@ export let TransitionChild = defineComponent({
return () => {
let {
- appear,
- show,
+ appear: _appear,
+ show: _show,
// Class names
enter,
@@ -327,7 +329,15 @@ export let TransitionChild = defineComponent({
} = props
let ourProps = { ref: container }
- let theirProps = rest
+ let theirProps = {
+ ...rest,
+ ...(appear && show && env.isServer
+ ? {
+ // Already apply the `enter` and `enterFrom` on the server if required
+ class: normalizeClass([rest.class, ...enterClasses, ...enterFromClasses]),
+ }
+ : {}),
+ }
return render({
theirProps,
diff --git a/packages/@headlessui-vue/src/test-utils/ssr.ts b/packages/@headlessui-vue/src/test-utils/ssr.ts
new file mode 100644
index 0000000000..0ec8734aae
--- /dev/null
+++ b/packages/@headlessui-vue/src/test-utils/ssr.ts
@@ -0,0 +1,30 @@
+import { createApp, createSSRApp } from 'vue'
+import { renderToString } from 'vue/server-renderer'
+import { env } from '../utils/env'
+
+export async function renderSSR(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,
+ }
+ },
+ }
+}
+
+export async function renderHydrate(component: any, rootProps: any = {}) {
+ return renderSSR(component, rootProps).then(({ hydrate }) => hydrate())
+}