Skip to content

Commit 6adff88

Browse files
committed
fix(react): ssr regression
1 parent 6c1a8cc commit 6adff88

File tree

5 files changed

+357
-17
lines changed

5 files changed

+357
-17
lines changed

packages/react/src/composables.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,13 @@ function withSideEffects<T extends ActiveHeadEntry<any>>(input: any, options: an
2828
const inputRef = useRef(input)
2929
inputRef.current = input
3030

31-
// Create entry in effect to avoid orphaned entries in React 18 StrictMode.
32-
// React 18 StrictMode resets useRef between its double-render invocations,
31+
// Server: create entry during render since useEffect doesn't run in SSR
32+
if (unhead.ssr && !entryRef.current) {
33+
entryRef.current = fn(unhead, input, options) as T
34+
}
35+
36+
// Client: create entry in effect to avoid orphaned entries in React 18 StrictMode.
37+
// StrictMode resets useRef between its double-render invocations,
3338
// so creating entries during render causes an orphaned entry that never gets disposed.
3439
useEffect(() => {
3540
const entry = fn(unhead, inputRef.current, options) as T
@@ -46,6 +51,9 @@ function withSideEffects<T extends ActiveHeadEntry<any>>(input: any, options: an
4651
}, [input])
4752

4853
// Return a stable proxy that delegates to the real entry once created
54+
if (unhead.ssr) {
55+
return entryRef.current as T
56+
}
4957
const proxyRef = useRef<T | null>(null)
5058
if (!proxyRef.current) {
5159
proxyRef.current = {

packages/react/test/SimpleHead.test.tsx

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,22 +18,22 @@ describe('simpleHead component', () => {
1818

1919
const { headTags } = await renderSSRHead(head)
2020
expect(headTags).toMatchInlineSnapshot(`
21-
"<meta name="viewport" content="width=device-width, initial-scale=1">
22-
<title>Default Title 2</title>
23-
<script async src="https://example.com/async-script.js"></script>
24-
<script noModule src="https://example.com/nomodule.js"></script>
21+
"<title>Default Title 2</title>
22+
<meta name="description" content="Default Description">
23+
<meta name="viewport" content="width=device-width, initial-scale=1">
24+
<meta http-equiv="Content-Security-Policy" content="default-src 'self'">
2525
<link rel="stylesheet" href="default-styles.css">
26-
<style>body { background-color: #f0f0f0; }</style>
26+
<link rel="icon" href="favicon.ico">
2727
<link rel="preload" href="https://example.com/font.woff2" as="font" type="font/woff2">
28-
<script type="module" src="https://example.com/module.js"></script>
29-
<script defer src="https://example.com/defer-script.js"></script>
3028
<link rel="dns-prefetch" href="//example.com">
3129
<link rel="prefetch" href="https://example.com/next-page">
3230
<link rel="prerender" href="https://example.com/next-page">
33-
<meta name="description" content="Default Description">
34-
<meta http-equiv="Content-Security-Policy" content="default-src 'self'">
35-
<link rel="icon" href="favicon.ico">
36-
<script type="application/ld+json">{"@context":"https://schema.org","@type":"WebSite","name":"Example","url":"https://www.example.com"}</script>"
31+
<script type="application/ld+json">{"@context":"https://schema.org","@type":"WebSite","name":"Example","url":"https://www.example.com"}</script>
32+
<script type="module" src="https://example.com/module.js"></script>
33+
<script noModule src="https://example.com/nomodule.js"></script>
34+
<script async src="https://example.com/async-script.js"></script>
35+
<script defer src="https://example.com/defer-script.js"></script>
36+
<style>body { background-color: #f0f0f0; }</style>"
3737
`)
3838
})
3939
it('renders nothing if component is unmounted', async () => {

packages/react/test/SimpleHeadSSR.test.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
import React from 'react'
33
import { renderToString } from 'react-dom/server'
44
import { describe, expect, it } from 'vitest'
5-
import { createHead, UnheadProvider } from '../src/client'
6-
import { renderSSRHead, transformHtmlTemplate } from '../src/server'
5+
import { UnheadProvider } from '../src/client'
6+
import { createHead, renderSSRHead, transformHtmlTemplate } from '../src/server'
77
import { SimpleHead } from './fixtures/SimpleHead'
88

99
describe('simpleHead component in ssr', () => {
@@ -18,7 +18,8 @@ describe('simpleHead component in ssr', () => {
1818

1919
const { headTags } = await renderSSRHead(head)
2020
expect(headTags).toMatchInlineSnapshot(`
21-
"<meta name="viewport" content="width=device-width, initial-scale=1">
21+
"<meta charset="utf-8">
22+
<meta name="viewport" content="width=device-width, initial-scale=1">
2223
<title>Default Title 2</title>
2324
<script async src="https://example.com/async-script.js"></script>
2425
<script noModule src="https://example.com/nomodule.js"></script>
@@ -53,7 +54,8 @@ describe('simpleHead component in ssr', () => {
5354

5455
const headContent = transformed.match(/<head>(.*?)<\/head>/s)?.[1] || ''
5556
expect(headContent).toMatchInlineSnapshot(`
56-
"<meta name="viewport" content="width=device-width, initial-scale=1">
57+
"<meta charset="utf-8">
58+
<meta name="viewport" content="width=device-width, initial-scale=1">
5759
<title>Default Title 2</title>
5860
<script async src="https://example.com/async-script.js"></script>
5961
<script noModule src="https://example.com/nomodule.js"></script>
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
// @vitest-environment jsdom
2+
import { act, render } from '@testing-library/react'
3+
import { useHead } from '@unhead/react'
4+
import { createHead, UnheadProvider } from '@unhead/react/client'
5+
import React, { StrictMode, useEffect, useRef } from 'react'
6+
import { describe, expect, it } from 'vitest'
7+
8+
console.log('React version:', React.version, '(debug tests)')
9+
10+
function wait(ms = 50) {
11+
return new Promise<void>(resolve => setTimeout(resolve, ms))
12+
}
13+
14+
describe('debug React 18 StrictMode entry lifecycle', () => {
15+
it('traces entry creation in StrictMode', async () => {
16+
const head = createHead({
17+
init: [{ title: 'Init' }],
18+
})
19+
20+
const events: string[] = []
21+
22+
function PageWithHead() {
23+
const renderCount = useRef(0)
24+
renderCount.current++
25+
events.push(`render #${renderCount.current}`)
26+
27+
useHead({ title: 'Component' })
28+
29+
events.push(`after useHead, entries: ${head.entries.size}, keys: [${[...head.entries.keys()]}]`)
30+
31+
useEffect(() => {
32+
events.push(`patch effect setup, entries: ${head.entries.size}, keys: [${[...head.entries.keys()]}]`)
33+
return () => {
34+
events.push(`patch effect cleanup`)
35+
}
36+
})
37+
38+
useEffect(() => {
39+
events.push(`dispose effect setup, entries: ${head.entries.size}, keys: [${[...head.entries.keys()]}]`)
40+
return () => {
41+
events.push(`dispose effect cleanup, entries before: ${head.entries.size}`)
42+
}
43+
}, [])
44+
45+
return <div>Has Head</div>
46+
}
47+
48+
events.push(`before mount, entries: ${head.entries.size}`)
49+
50+
render(
51+
<StrictMode>
52+
<UnheadProvider head={head}>
53+
<PageWithHead />
54+
</UnheadProvider>
55+
</StrictMode>,
56+
)
57+
58+
await act(async () => { await wait() })
59+
60+
events.push(`after mount settled, entries: ${head.entries.size}, keys: [${[...head.entries.keys()]}]`)
61+
62+
console.log('\n=== Event Log ===')
63+
events.forEach((e, i) => console.log(`${i}: ${e}`))
64+
console.log('=================\n')
65+
66+
// Verify we only have init + component
67+
expect(head.entries.size).toBe(2)
68+
})
69+
70+
it('traces the actual withSideEffects ref behavior', async () => {
71+
const head = createHead({
72+
init: [{ title: 'Init' }],
73+
})
74+
75+
const events: string[] = []
76+
const entryIds: number[] = []
77+
78+
function PageWithHead() {
79+
// Manually replicate withSideEffects to trace behavior
80+
const entryRef = useRef<any>(null)
81+
82+
if (!entryRef.current) {
83+
events.push(`creating entry (ref was null)`)
84+
entryRef.current = head.push({ title: 'Component' })
85+
entryIds.push(entryRef.current._i)
86+
events.push(`entry created with _i=${entryRef.current._i}, entries: ${head.entries.size}`)
87+
}
88+
else {
89+
events.push(`reusing entry _i=${entryRef.current._i}`)
90+
}
91+
92+
const entry = entryRef.current
93+
94+
useEffect(() => {
95+
events.push(`patch effect: entry._i=${entry._i}, calling patch`)
96+
entry?.patch({ title: 'Component' })
97+
events.push(`after patch, entries: ${head.entries.size}, keys: [${[...head.entries.keys()]}]`)
98+
}, [entry])
99+
100+
useEffect(() => {
101+
events.push(`dispose effect setup: entry._i=${entry._i}`)
102+
return () => {
103+
events.push(`dispose cleanup: entry._i=${entry._i}, entries before dispose: ${head.entries.size}`)
104+
entry?.dispose()
105+
events.push(`after dispose, entries: ${head.entries.size}, keys: [${[...head.entries.keys()]}]`)
106+
entryRef.current = null
107+
events.push(`ref cleared`)
108+
}
109+
}, [entry])
110+
111+
return <div>Has Head</div>
112+
}
113+
114+
events.push(`init, entries: ${head.entries.size}`)
115+
116+
render(
117+
<StrictMode>
118+
<UnheadProvider head={head}>
119+
<PageWithHead />
120+
</UnheadProvider>
121+
</StrictMode>,
122+
)
123+
124+
await act(async () => { await wait() })
125+
126+
events.push(`settled, entries: ${head.entries.size}, keys: [${[...head.entries.keys()]}]`)
127+
128+
console.log('\n=== withSideEffects Trace ===')
129+
events.forEach((e, i) => console.log(`${i}: ${e}`))
130+
console.log(`entry IDs created: [${entryIds}]`)
131+
console.log('=============================\n')
132+
})
133+
})

0 commit comments

Comments
 (0)