Skip to content

Commit aac78d5

Browse files
Don’t overwrite classes during SSR when rendering fragments (#2173)
* Refactor SSR test helpers * Add SSR tests for transition * Don’t overwrite classes during SSR when rendering fragments * Update changelog
1 parent 08c0837 commit aac78d5

File tree

10 files changed

+205
-113
lines changed

10 files changed

+205
-113
lines changed

packages/@headlessui-react/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1515
- Fix `Tab` key with non focusable elements in `Popover.Panel` ([#2147](https://github.com/tailwindlabs/headlessui/pull/2147))
1616
- Fix false positive warning when using `<Popover.Button />` in React 17 ([#2163](https://github.com/tailwindlabs/headlessui/pull/2163))
1717
- Fix `failed to removeChild on Node` bug ([#2164](https://github.com/tailwindlabs/headlessui/pull/2164))
18+
- Don’t overwrite classes during SSR when rendering fragments ([#2173](https://github.com/tailwindlabs/headlessui/pull/2173))
1819

1920
## [1.7.7] - 2022-12-16
2021

Lines changed: 6 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,6 @@
1-
import { RenderResult } from '@testing-library/react'
2-
import { render, RenderOptions } from '@testing-library/react'
3-
import React, { ReactElement } from 'react'
4-
import { renderToString } from 'react-dom/server'
1+
import React from 'react'
52
import { Tab } from './tabs'
6-
import { env } from '../../utils/env'
3+
import { renderSSR, renderHydrate } from '../../test-utils/ssr'
74

85
beforeAll(() => {
96
jest.spyOn(window, 'requestAnimationFrame').mockImplementation(setImmediate as any)
@@ -31,15 +28,15 @@ function Example({ defaultIndex = 0 }) {
3128
describe('Rendering', () => {
3229
describe('SSR', () => {
3330
it('should be possible to server side render the first Tab and Panel', async () => {
34-
let { contents } = await serverRender(<Example />)
31+
let { contents } = await renderSSR(<Example />)
3532

3633
expect(contents).toContain(`Content 1`)
3734
expect(contents).not.toContain(`Content 2`)
3835
expect(contents).not.toContain(`Content 3`)
3936
})
4037

4138
it('should be possible to server side render the defaultIndex Tab and Panel', async () => {
42-
let { contents } = await serverRender(<Example defaultIndex={1} />)
39+
let { contents } = await renderSSR(<Example defaultIndex={1} />)
4340

4441
expect(contents).not.toContain(`Content 1`)
4542
expect(contents).toContain(`Content 2`)
@@ -51,84 +48,19 @@ describe('Rendering', () => {
5148
// Skipping for now
5249
xdescribe('Hydration', () => {
5350
it('should be possible to server side render the first Tab and Panel', async () => {
54-
const { contents } = await hydrateRender(<Example />)
51+
const { contents } = await renderHydrate(<Example />)
5552

5653
expect(contents).toContain(`Content 1`)
5754
expect(contents).not.toContain(`Content 2`)
5855
expect(contents).not.toContain(`Content 3`)
5956
})
6057

6158
it('should be possible to server side render the defaultIndex Tab and Panel', async () => {
62-
const { contents } = await hydrateRender(<Example defaultIndex={1} />)
59+
const { contents } = await renderHydrate(<Example defaultIndex={1} />)
6360

6461
expect(contents).not.toContain(`Content 1`)
6562
expect(contents).toContain(`Content 2`)
6663
expect(contents).not.toContain(`Content 3`)
6764
})
6865
})
6966
})
70-
71-
type ServerRenderOptions = Omit<RenderOptions, 'queries'> & {
72-
strict?: boolean
73-
}
74-
75-
interface ServerRenderResult {
76-
type: 'ssr' | 'hydrate'
77-
contents: string
78-
result: RenderResult
79-
hydrate: () => Promise<ServerRenderResult>
80-
}
81-
82-
async function serverRender(
83-
ui: ReactElement,
84-
options: ServerRenderOptions = {}
85-
): Promise<ServerRenderResult> {
86-
let container = document.createElement('div')
87-
document.body.appendChild(container)
88-
options = { ...options, container }
89-
90-
if (options.strict) {
91-
options = {
92-
...options,
93-
wrapper({ children }) {
94-
return <React.StrictMode>{children}</React.StrictMode>
95-
},
96-
}
97-
}
98-
99-
env.set('server')
100-
let contents = renderToString(ui)
101-
let result = render(<div dangerouslySetInnerHTML={{ __html: contents }} />, options)
102-
103-
async function hydrate(): Promise<ServerRenderResult> {
104-
// This hack-ish way of unmounting the server rendered content is necessary
105-
// otherwise we won't actually end up testing the hydration code path properly.
106-
// Probably because React hangs on to internal references on the DOM nodes
107-
result.unmount()
108-
container.innerHTML = contents
109-
110-
env.set('client')
111-
let newResult = render(ui, {
112-
...options,
113-
hydrate: true,
114-
})
115-
116-
return {
117-
type: 'hydrate',
118-
contents: container.innerHTML,
119-
result: newResult,
120-
hydrate,
121-
}
122-
}
123-
124-
return {
125-
type: 'ssr',
126-
contents,
127-
result,
128-
hydrate,
129-
}
130-
}
131-
132-
async function hydrateRender(el: ReactElement, options: ServerRenderOptions = {}) {
133-
return serverRender(el, options).then((r) => r.hydrate())
134-
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import React, { Fragment } from 'react'
2+
import { Transition } from './transition'
3+
import { renderSSR } from '../../test-utils/ssr'
4+
5+
beforeAll(() => {
6+
jest.spyOn(window, 'requestAnimationFrame').mockImplementation(setImmediate as any)
7+
jest.spyOn(window, 'cancelAnimationFrame').mockImplementation(clearImmediate as any)
8+
})
9+
10+
describe('Rendering', () => {
11+
describe('SSR', () => {
12+
it('should not overwrite className of children when as=Fragment', async () => {
13+
await renderSSR(
14+
<Transition
15+
as={Fragment}
16+
show={true}
17+
appear={true}
18+
enter="enter"
19+
enterFrom="enter-from"
20+
enterTo="enter-to"
21+
>
22+
<div className="inner"></div>
23+
</Transition>
24+
)
25+
26+
let div = document.querySelector('.inner')
27+
28+
expect(div).not.toBeNull()
29+
expect(div?.className).toBe('inner enter enter-from')
30+
})
31+
})
32+
})
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { RenderResult } from '@testing-library/react'
2+
import { render, RenderOptions } from '@testing-library/react'
3+
import React, { ReactElement } from 'react'
4+
import { renderToString } from 'react-dom/server'
5+
import { env } from '../utils/env'
6+
7+
type ServerRenderOptions = Omit<RenderOptions, 'queries'> & {
8+
strict?: boolean
9+
}
10+
11+
interface ServerRenderResult {
12+
type: 'ssr' | 'hydrate'
13+
contents: string
14+
result: RenderResult
15+
hydrate: () => Promise<ServerRenderResult>
16+
}
17+
18+
export async function renderSSR(
19+
ui: ReactElement,
20+
options: ServerRenderOptions = {}
21+
): Promise<ServerRenderResult> {
22+
let container = document.createElement('div')
23+
document.body.appendChild(container)
24+
options = { ...options, container }
25+
26+
if (options.strict) {
27+
options = {
28+
...options,
29+
wrapper({ children }) {
30+
return <React.StrictMode>{children}</React.StrictMode>
31+
},
32+
}
33+
}
34+
35+
env.set('server')
36+
let contents = renderToString(ui)
37+
let result = render(<div dangerouslySetInnerHTML={{ __html: contents }} />, options)
38+
39+
async function hydrate(): Promise<ServerRenderResult> {
40+
// This hack-ish way of unmounting the server rendered content is necessary
41+
// otherwise we won't actually end up testing the hydration code path properly.
42+
// Probably because React hangs on to internal references on the DOM nodes
43+
result.unmount()
44+
container.innerHTML = contents
45+
46+
env.set('client')
47+
let newResult = render(ui, {
48+
...options,
49+
hydrate: true,
50+
})
51+
52+
return {
53+
type: 'hydrate',
54+
contents: container.innerHTML,
55+
result: newResult,
56+
hydrate,
57+
}
58+
}
59+
60+
return {
61+
type: 'ssr',
62+
contents,
63+
result,
64+
hydrate,
65+
}
66+
}
67+
68+
export async function renderHydrate(el: ReactElement, options: ServerRenderOptions = {}) {
69+
return renderSSR(el, options).then((r) => r.hydrate())
70+
}

packages/@headlessui-react/src/utils/render.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import {
1010
ReactElement,
1111
} from 'react'
1212
import { Props, XOR, __, Expand } from '../types'
13+
import { classNames } from './class-names'
14+
import { env } from './env'
1315
import { match } from './match'
1416

1517
export enum Features {
@@ -168,6 +170,10 @@ function _render<TTag extends ElementType, TSlot>(
168170
)
169171
}
170172

173+
// Merge class name prop in SSR
174+
let newClassName = classNames(resolvedChildren.props?.className, rest.className)
175+
let classNameProps = newClassName ? { className: newClassName } : {}
176+
171177
return cloneElement(
172178
resolvedChildren,
173179
Object.assign(
@@ -176,7 +182,8 @@ function _render<TTag extends ElementType, TSlot>(
176182
mergeProps(resolvedChildren.props, compact(omit(rest, ['ref']))),
177183
dataAttributes,
178184
refRelatedProps,
179-
mergeRefs((resolvedChildren as any).ref, refRelatedProps.ref)
185+
mergeRefs((resolvedChildren as any).ref, refRelatedProps.ref),
186+
classNameProps
180187
)
181188
)
182189
}

packages/@headlessui-vue/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1616
- Ensure `disabled="false"` is not incorrectly passed to the underlying DOM Node ([#2138](https://github.com/tailwindlabs/headlessui/pull/2138))
1717
- Fix arrow key handling in `Tab` (after DOM order changes) ([#2145](https://github.com/tailwindlabs/headlessui/pull/2145))
1818
- Fix `Tab` key with non focusable elements in `Popover.Panel` ([#2147](https://github.com/tailwindlabs/headlessui/pull/2147))
19+
- Don’t overwrite classes during SSR when rendering fragments ([#2173](https://github.com/tailwindlabs/headlessui/pull/2173))
1920

2021
## [1.7.7] - 2022-12-16
2122

Lines changed: 6 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
1-
import { createApp, createSSRApp, defineComponent, h } from 'vue'
2-
import { renderToString } from 'vue/server-renderer'
1+
import { defineComponent } from 'vue'
32
import { TabGroup, TabList, Tab, TabPanels, TabPanel } from './tabs'
43
import { html } from '../../test-utils/html'
5-
import { render } from '../../test-utils/vue-testing-library'
6-
import { env } from '../../utils/env'
4+
import { renderHydrate, renderSSR } from '../../test-utils/ssr'
75

86
jest.mock('../../hooks/use-id')
97

@@ -36,15 +34,15 @@ let Example = defineComponent({
3634
describe('Rendering', () => {
3735
describe('SSR', () => {
3836
it('should be possible to server side render the first Tab and Panel', async () => {
39-
let { contents } = await serverRender(Example)
37+
let { contents } = await renderSSR(Example)
4038

4139
expect(contents).toContain(`Content 1`)
4240
expect(contents).not.toContain(`Content 2`)
4341
expect(contents).not.toContain(`Content 3`)
4442
})
4543

4644
it('should be possible to server side render the defaultIndex Tab and Panel', async () => {
47-
let { contents } = await serverRender(Example, { defaultIndex: 1 })
45+
let { contents } = await renderSSR(Example, { defaultIndex: 1 })
4846

4947
expect(contents).not.toContain(`Content 1`)
5048
expect(contents).toContain(`Content 2`)
@@ -54,46 +52,19 @@ describe('Rendering', () => {
5452

5553
describe('Hydration', () => {
5654
it('should be possible to server side render the first Tab and Panel', async () => {
57-
let { contents } = await hydrateRender(Example)
55+
let { contents } = await renderHydrate(Example)
5856

5957
expect(contents).toContain(`Content 1`)
6058
expect(contents).not.toContain(`Content 2`)
6159
expect(contents).not.toContain(`Content 3`)
6260
})
6361

6462
it('should be possible to server side render the defaultIndex Tab and Panel', async () => {
65-
let { contents } = await hydrateRender(Example, { defaultIndex: 1 })
63+
let { contents } = await renderHydrate(Example, { defaultIndex: 1 })
6664

6765
expect(contents).not.toContain(`Content 1`)
6866
expect(contents).toContain(`Content 2`)
6967
expect(contents).not.toContain(`Content 3`)
7068
})
7169
})
7270
})
73-
74-
async function serverRender(component: any, rootProps: any = {}) {
75-
let container = document.createElement('div')
76-
document.body.appendChild(container)
77-
78-
// Render on the server
79-
env.set('server')
80-
let app = createSSRApp(component, rootProps)
81-
let contents = await renderToString(app)
82-
container.innerHTML = contents
83-
84-
return {
85-
contents,
86-
hydrate() {
87-
let app = createApp(component, rootProps)
88-
app.mount(container)
89-
90-
return {
91-
contents: container.innerHTML,
92-
}
93-
},
94-
}
95-
}
96-
97-
async function hydrateRender(component: any, rootProps: any = {}) {
98-
return serverRender(component, rootProps).then(({ hydrate }) => hydrate())
99-
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import * as Transition from './transition'
2+
import { renderSSR } from '../../test-utils/ssr'
3+
import { defineComponent } from 'vue'
4+
import { html } from '../../test-utils/html'
5+
6+
beforeAll(() => {
7+
jest.spyOn(window, 'requestAnimationFrame').mockImplementation(setImmediate as any)
8+
jest.spyOn(window, 'cancelAnimationFrame').mockImplementation(clearImmediate as any)
9+
})
10+
11+
describe('Rendering', () => {
12+
describe('SSR', () => {
13+
it('should not overwrite className of children when as=Fragment', async () => {
14+
await renderSSR(
15+
defineComponent({
16+
components: Transition,
17+
template: html`
18+
<TransitionRoot
19+
as="template"
20+
:show="true"
21+
:appear="true"
22+
enter="enter"
23+
enterFrom="enter-from"
24+
enterTo="enter-to"
25+
>
26+
<div class="inner"></div>
27+
</TransitionRoot>
28+
`,
29+
})
30+
)
31+
32+
let div = document.querySelector('.inner')
33+
34+
expect(div).not.toBeNull()
35+
expect(div?.className).toBe('inner enter enter-from')
36+
})
37+
})
38+
})

0 commit comments

Comments
 (0)