Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 50ffe37

Browse files
authored
fix(react): dispose head entries on unmount in React 18 StrictMode (#664)
Moves entry creation from render-time (useRef init) into useEffect to avoid orphaned entries caused by React 18 StrictMode resetting refs between double-render invocations. Returns a stable proxy so callers get a consistent reference before the effect runs. Closes #558
1 parent e8f5b4b commit 50ffe37

File tree

2 files changed

+221
-17
lines changed

2 files changed

+221
-17
lines changed

packages/react/src/composables.ts

Lines changed: 27 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -25,29 +25,39 @@ export function useUnhead(): Unhead {
2525
function withSideEffects<T extends ActiveHeadEntry<any>>(input: any, options: any, fn: any): T {
2626
const unhead = options.head || useUnhead()
2727
const entryRef = useRef<T | null>(null)
28+
const inputRef = useRef(input)
29+
inputRef.current = input
2830

29-
// Create entry only once, even in Strict Mode
30-
if (!entryRef.current) {
31-
entryRef.current = fn(unhead, input, options)
32-
}
33-
34-
const entry = entryRef.current
35-
36-
// Patch entry when input changes
37-
useEffect(() => {
38-
entry?.patch(input)
39-
}, [input, entry])
40-
41-
// Cleanup on unmount
31+
// Create entry in effect to avoid orphaned entries in React 18 StrictMode.
32+
// React 18 StrictMode resets useRef between its double-render invocations,
33+
// so creating entries during render causes an orphaned entry that never gets disposed.
4234
useEffect(() => {
35+
const entry = fn(unhead, inputRef.current, options) as T
36+
entryRef.current = entry
4337
return () => {
44-
entry?.dispose()
45-
// Clear ref so new entry is created on remount
38+
entry.dispose()
4639
entryRef.current = null
4740
}
48-
}, [entry])
41+
}, [unhead])
42+
43+
// Patch when input changes
44+
useEffect(() => {
45+
entryRef.current?.patch(input)
46+
}, [input])
4947

50-
return entry as T
48+
// Return a stable proxy that delegates to the real entry once created
49+
const proxyRef = useRef<T | null>(null)
50+
if (!proxyRef.current) {
51+
proxyRef.current = {
52+
patch: (newInput: any) => { entryRef.current?.patch(newInput) },
53+
dispose: () => {
54+
entryRef.current?.dispose()
55+
entryRef.current = null
56+
},
57+
_poll: (rm?: boolean) => { entryRef.current?._poll(rm) },
58+
} as T
59+
}
60+
return proxyRef.current
5161
}
5262

5363
export function useHead(input: UseHeadInput = {}, options: HeadEntryOptions = {}): ActiveHeadEntry<UseHeadInput> {
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
// @vitest-environment jsdom
2+
import { act, fireEvent, render } from '@testing-library/react'
3+
import React, { useState } from 'react'
4+
import { describe, expect, it } from 'vitest'
5+
import { useHead } from '../src'
6+
import { createHead, renderDOMHead, UnheadProvider } from '../src/client'
7+
8+
/**
9+
* Reproduction of https://github.com/unjs/unhead/issues/558
10+
* React 18 reporter says metadata persists after component unmount
11+
*/
12+
13+
function Page1() {
14+
useHead({
15+
title: 'Page 1 title',
16+
meta: [{ name: 'description', content: 'page 1 description' }],
17+
})
18+
return <div>Page 1</div>
19+
}
20+
21+
function Page2() {
22+
return <div>Page 2</div>
23+
}
24+
25+
function App({ head }: { head: ReturnType<typeof createHead> }) {
26+
const [currPage, setCurrPage] = useState<'Page 1' | 'Page 2'>('Page 2')
27+
return (
28+
<UnheadProvider head={head}>
29+
<button onClick={() => setCurrPage('Page 1')}>Page 1</button>
30+
<button onClick={() => setCurrPage('Page 2')}>Page 2</button>
31+
{currPage === 'Page 1' ? <Page1 /> : <Page2 />}
32+
</UnheadProvider>
33+
)
34+
}
35+
36+
function wait(ms = 10) {
37+
return new Promise<void>(resolve => setTimeout(resolve, ms))
38+
}
39+
40+
describe('issue #558 - unmount cleanup', () => {
41+
it('restores init values after component unmount (DOM rendering)', async () => {
42+
const head = createHead({
43+
init: [{
44+
title: 'Example fallback',
45+
meta: [{ name: 'description', content: 'some description' }],
46+
}],
47+
})
48+
49+
const { getByText } = render(<App head={head} />)
50+
51+
// Initial DOM render
52+
await act(async () => {
53+
await renderDOMHead(head)
54+
await wait()
55+
})
56+
57+
expect(document.title).toBe('Example fallback')
58+
59+
// Switch to Page 1
60+
await act(async () => {
61+
fireEvent.click(getByText('Page 1'))
62+
await wait()
63+
})
64+
await act(async () => {
65+
await renderDOMHead(head)
66+
await wait()
67+
})
68+
69+
expect(document.title).toBe('Page 1 title')
70+
71+
// Switch back to Page 2
72+
await act(async () => {
73+
fireEvent.click(getByText('Page 2'))
74+
await wait()
75+
})
76+
await act(async () => {
77+
await renderDOMHead(head)
78+
await wait()
79+
})
80+
81+
expect(document.title).toBe('Example fallback')
82+
})
83+
84+
it('handles multiple page switches correctly (DOM)', async () => {
85+
const head = createHead({
86+
init: [{
87+
title: 'Fallback',
88+
meta: [{ name: 'description', content: 'fallback desc' }],
89+
}],
90+
})
91+
92+
const { getByText } = render(<App head={head} />)
93+
94+
for (let i = 0; i < 3; i++) {
95+
await act(async () => {
96+
fireEvent.click(getByText('Page 1'))
97+
await wait()
98+
})
99+
await act(async () => {
100+
await renderDOMHead(head)
101+
await wait()
102+
})
103+
expect(document.title).toBe('Page 1 title')
104+
105+
await act(async () => {
106+
fireEvent.click(getByText('Page 2'))
107+
await wait()
108+
})
109+
await act(async () => {
110+
await renderDOMHead(head)
111+
await wait()
112+
})
113+
expect(document.title).toBe('Fallback')
114+
}
115+
})
116+
117+
it('direct unmount restores init values (DOM)', async () => {
118+
const head = createHead({
119+
init: [{
120+
title: 'Init Title',
121+
meta: [{ name: 'description', content: 'init description' }],
122+
}],
123+
})
124+
125+
function PageWithHead() {
126+
useHead({
127+
title: 'Component Title',
128+
meta: [{ name: 'description', content: 'component description' }],
129+
})
130+
return <div>Has Head</div>
131+
}
132+
133+
const { unmount } = render(
134+
<UnheadProvider head={head}>
135+
<PageWithHead />
136+
</UnheadProvider>,
137+
)
138+
139+
await act(async () => {
140+
await renderDOMHead(head)
141+
await wait()
142+
})
143+
144+
expect(document.title).toBe('Component Title')
145+
146+
await act(async () => {
147+
unmount()
148+
await wait()
149+
})
150+
await act(async () => {
151+
await renderDOMHead(head)
152+
await wait()
153+
})
154+
155+
expect(document.title).toBe('Init Title')
156+
})
157+
158+
it('entries state is correct through mount/unmount cycle', async () => {
159+
const head = createHead({
160+
init: [{
161+
title: 'Init',
162+
}],
163+
})
164+
165+
function PageWithHead() {
166+
useHead({ title: 'Component' })
167+
return <div>Has Head</div>
168+
}
169+
170+
// Initially, only init entry
171+
expect(head.entries.size).toBe(1)
172+
173+
const { unmount } = render(
174+
<UnheadProvider head={head}>
175+
<PageWithHead />
176+
</UnheadProvider>,
177+
)
178+
179+
await act(async () => {
180+
await wait()
181+
})
182+
183+
// After mount, should have 2 entries
184+
expect(head.entries.size).toBe(2)
185+
186+
await act(async () => {
187+
unmount()
188+
await wait()
189+
})
190+
191+
// After unmount, back to 1 entry (init only)
192+
expect(head.entries.size).toBe(1)
193+
})
194+
})

0 commit comments

Comments
 (0)