Skip to content

Commit 4ee5eac

Browse files
authored
fix(react): force invalidation on entry disposal (#559)
1 parent 2b93dff commit 4ee5eac

File tree

5 files changed

+260
-13
lines changed

5 files changed

+260
-13
lines changed

packages/react/src/composables.ts

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import type {
99
UseScriptReturn,
1010
UseSeoMetaInput,
1111
} from 'unhead/types'
12-
import { useContext, useEffect, useState } from 'react'
12+
import { useContext, useEffect, useRef } from 'react'
1313
import { useHead as baseHead, useHeadSafe as baseHeadSafe, useSeoMeta as baseSeoMeta, useScript as baseUseScript } from 'unhead'
1414
import { UnheadContext } from './context'
1515

@@ -24,17 +24,30 @@ export function useUnhead(): Unhead {
2424

2525
function withSideEffects<T extends ActiveHeadEntry<any>>(input: any, options: any, fn: any): T {
2626
const unhead = options.head || useUnhead()
27-
const [entry] = useState<T>(() => fn(unhead, input, options))
27+
const entryRef = useRef<T | null>(null)
28+
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
2837
useEffect(() => {
29-
entry.patch(input)
30-
}, [input])
38+
entry?.patch(input)
39+
}, [input, entry])
40+
41+
// Cleanup on unmount
3142
useEffect(() => {
3243
return () => {
33-
// unmount
34-
entry.dispose()
44+
entry?.dispose()
45+
// Clear ref so new entry is created on remount
46+
entryRef.current = null
3547
}
36-
}, [])
37-
return entry
48+
}, [entry])
49+
50+
return entry as T
3851
}
3952

4053
export function useHead(input: UseHeadInput = {}, options: HeadEntryOptions = {}): ActiveHeadEntry<UseHeadInput> {

packages/unhead/src/types/head.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,10 @@ export interface Unhead<Input = ResolvableHead> {
175175
* Resolve tags from head entries.
176176
*/
177177
resolveTags: () => Promise<HeadTag[]>
178+
/**
179+
* Invalidate all entries and re-queue them for normalization.
180+
*/
181+
invalidate: () => void
178182
/**
179183
* Exposed hooks for easier extension.
180184
*/

packages/unhead/src/unhead.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ export function createUnhead<T = ResolvableHead>(resolvedOptions: CreateHeadOpti
4747

4848
const entries: Map<number, HeadEntry<T>> = new Map()
4949
const plugins: Map<string, HeadPlugin> = new Map()
50-
const normalizeQueue: number[] = []
50+
const normalizeQueue = new Set<number>()
5151
const head: Unhead<T> = {
5252
_entryCount: 1, // 0 is reserved for internal use
5353
plugins,
@@ -68,12 +68,13 @@ export function createUnhead<T = ResolvableHead>(resolvedOptions: CreateHeadOpti
6868
const _: ActiveHeadEntry<T> = {
6969
_poll(rm = false) {
7070
head.dirty = true
71-
!rm && normalizeQueue.push(_i)
71+
!rm && normalizeQueue.add(_i)
7272
hooks.callHook('entries:updated', head)
7373
},
7474
dispose() {
7575
if (entries.delete(_i)) {
76-
_._poll(true)
76+
// Re-queue remaining entries for normalization after disposal
77+
head.invalidate()
7778
}
7879
},
7980
// a patch is the same as creating a new entry, just a nice DX
@@ -95,8 +96,9 @@ export function createUnhead<T = ResolvableHead>(resolvedOptions: CreateHeadOpti
9596
entries: [...head.entries.values()],
9697
}
9798
await hooks.callHook('entries:resolve', ctx)
98-
while (normalizeQueue.length) {
99-
const i = normalizeQueue.shift()!
99+
while (normalizeQueue.size) {
100+
const i = normalizeQueue.values().next().value!
101+
normalizeQueue.delete(i)
100102
const e = entries.get(i)
101103
if (e) {
102104
const normalizeCtx = {
@@ -213,6 +215,14 @@ export function createUnhead<T = ResolvableHead>(resolvedOptions: CreateHeadOpti
213215
}
214216
return finalTags
215217
},
218+
invalidate() {
219+
// Re-queue all current entries for normalization
220+
for (const entry of entries.values()) {
221+
normalizeQueue.add(entry._i)
222+
}
223+
head.dirty = true
224+
hooks.callHook('entries:updated', head)
225+
},
216226
}
217227
;(resolvedOptions?.plugins || []).forEach(p => registerPlugin(head, p))
218228
head.hooks.callHook('init', head)
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { createUnhead } from '../src'
3+
4+
describe('break Normalize Queue - Replicate Bug', () => {
5+
it('replicates the bug by following React pattern exactly', async () => {
6+
// First, let me revert the fix to test the original buggy behavior
7+
const head = createUnhead({
8+
init: [{ title: 'Init Title', meta: [{ name: 'description', content: 'Init description' }] }],
9+
})
10+
11+
// Step 1: Initial state - init values should be normalized
12+
let tags = await head.resolveTags()
13+
expect(tags.find(t => t.tag === 'title')?.textContent).toBe('Init Title')
14+
15+
// Step 2: Component mounts - React creates entry via push()
16+
const componentEntry = head.push({
17+
title: 'Component Title',
18+
meta: [{ name: 'description', content: 'Component description' }],
19+
})
20+
21+
// Step 3: Component patches entry (React does this on every render/prop change)
22+
componentEntry.patch({
23+
title: 'Component Title',
24+
meta: [{ name: 'description', content: 'Component description' }],
25+
})
26+
27+
// Step 4: Resolve tags - component values should be active
28+
tags = await head.resolveTags()
29+
expect(tags.find(t => t.tag === 'title')?.textContent).toBe('Component Title')
30+
31+
// Step 5: Component unmounts - React calls dispose()
32+
// This is where the bug happens: dispose() calls _._poll(true)
33+
// which means !rm is false, so remaining entries don't get added to normalizeQueue
34+
componentEntry.dispose()
35+
36+
// Step 6: The bug manifests here
37+
// Without the fix, init entries won't be in normalizeQueue
38+
// so they won't be normalized and their _tags might be stale
39+
tags = await head.resolveTags()
40+
41+
// This should pass but may fail with the original buggy code
42+
expect(tags.find(t => t.tag === 'title')?.textContent).toBe('Init Title')
43+
expect(tags.find(t => t.tag === 'meta' && t.props.name === 'description')?.props.content).toBe('Init description')
44+
})
45+
46+
it('breaks the normalize queue by examining internal state', async () => {
47+
const head = createUnhead({
48+
init: [{ title: 'Init Title' }],
49+
})
50+
51+
// Get reference to the init entry to examine its internal state
52+
const initEntry = Array.from(head.entries.values())[0]
53+
54+
// First resolution - init entry gets normalized
55+
await head.resolveTags()
56+
const originalTags = initEntry._tags
57+
expect(originalTags).toBeDefined()
58+
expect(originalTags?.[0]?.textContent).toBe('Init Title')
59+
60+
// Add component entry
61+
const componentEntry = head.push({ title: 'Component Title' })
62+
63+
// Resolve - both entries get normalized
64+
await head.resolveTags()
65+
66+
// Dispose component entry
67+
componentEntry.dispose()
68+
69+
// The bug: after disposal, if normalizeQueue is empty,
70+
// init entry won't be re-normalized even if its _tags are stale
71+
72+
// Let's check if init entry is in the normalize queue after disposal
73+
// We can't access normalizeQueue directly, but we can infer by checking
74+
// if the init entry gets re-normalized
75+
76+
// Force a scenario where init entry needs re-normalization
77+
// by modifying its input directly (simulating what might happen in complex scenarios)
78+
const tags = await head.resolveTags()
79+
80+
// If the bug exists, this might fail because init entry didn't get normalized
81+
expect(tags.find(t => t.tag === 'title')?.textContent).toBe('Init Title')
82+
})
83+
84+
it('exposes the bug through normalize queue manipulation', async () => {
85+
const head = createUnhead({
86+
init: [{ title: 'Init Title' }],
87+
})
88+
89+
// Create a scenario where the normalize queue state matters
90+
91+
// Step 1: Add component entry and resolve
92+
const componentEntry = head.push({ title: 'Component Title' })
93+
await head.resolveTags() // This processes and clears the normalize queue
94+
95+
// Step 2: Manually trigger a state that requires re-normalization
96+
// In a real scenario, this might happen due to plugins or other factors
97+
const initEntry = Array.from(head.entries.values())[0]
98+
99+
// Clear the init entry's _tags to simulate needing re-normalization
100+
delete initEntry._tags
101+
102+
// Step 3: Dispose component entry
103+
componentEntry.dispose()
104+
105+
// Step 4: The bug - if dispose() doesn't add remaining entries to normalizeQueue,
106+
// the init entry won't be normalized because its _tags is undefined
107+
const tags = await head.resolveTags()
108+
109+
// This should work but might fail if normalize queue bug exists
110+
expect(tags.find(t => t.tag === 'title')?.textContent).toBe('Init Title')
111+
})
112+
113+
it('replicates React strict mode double disposal pattern', async () => {
114+
const head = createUnhead({
115+
init: [{ title: 'Init Title' }],
116+
})
117+
118+
// React Strict Mode pattern: mount → unmount → mount → unmount
119+
120+
// First mount
121+
const entry1 = head.push({ title: 'Component Title 1' })
122+
await head.resolveTags()
123+
124+
// First unmount (Strict Mode)
125+
entry1.dispose()
126+
127+
// Second mount (Strict Mode remount)
128+
const entry2 = head.push({ title: 'Component Title 2' })
129+
await head.resolveTags()
130+
131+
// Second unmount (actual unmount)
132+
entry2.dispose()
133+
134+
// Multiple disposals might expose the normalize queue bug
135+
const tags = await head.resolveTags()
136+
expect(tags.find(t => t.tag === 'title')?.textContent).toBe('Init Title')
137+
})
138+
})
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { createUnhead } from '../src'
3+
4+
describe('invalidate Function', () => {
5+
it('should re-queue all entries for normalization', async () => {
6+
const head = createUnhead({
7+
init: [{ title: 'Init Title' }],
8+
})
9+
10+
// Add some entries
11+
head.push({ title: 'Entry 1' })
12+
head.push({ title: 'Entry 2' })
13+
14+
// Resolve tags initially
15+
let tags = await head.resolveTags()
16+
expect(tags.find(t => t.tag === 'title')?.textContent).toBe('Entry 2')
17+
18+
// Get references to entries to examine their state
19+
const initEntry = Array.from(head.entries.values()).find(e => e.input.title === 'Init Title')!
20+
const entry1Ref = Array.from(head.entries.values()).find(e => e.input.title === 'Entry 1')!
21+
const entry2Ref = Array.from(head.entries.values()).find(e => e.input.title === 'Entry 2')!
22+
23+
// Clear all _tags to simulate a state that needs re-normalization
24+
delete initEntry._tags
25+
delete entry1Ref._tags
26+
delete entry2Ref._tags
27+
28+
// Call invalidate to re-queue all entries
29+
head.invalidate()
30+
31+
// Resolve tags - all entries should be re-normalized
32+
tags = await head.resolveTags()
33+
34+
// Should show Entry 2 (highest priority) and all entries should have their _tags restored
35+
expect(tags.find(t => t.tag === 'title')?.textContent).toBe('Entry 2')
36+
expect(initEntry._tags).toBeDefined()
37+
expect(entry1Ref._tags).toBeDefined()
38+
expect(entry2Ref._tags).toBeDefined()
39+
})
40+
41+
it('should work with Set-based normalize queue without duplicates', async () => {
42+
const head = createUnhead({
43+
init: [{ title: 'Init Title' }],
44+
})
45+
46+
const entry = head.push({ title: 'Test Entry' })
47+
48+
// Call invalidate multiple times
49+
head.invalidate()
50+
head.invalidate()
51+
head.invalidate()
52+
53+
// Even with multiple invalidate calls, each entry should only be processed once
54+
// (this tests the Set deduplication behavior)
55+
const tags = await head.resolveTags()
56+
expect(tags.find(t => t.tag === 'title')?.textContent).toBe('Test Entry')
57+
})
58+
59+
it('should be useful after dispose operations', async () => {
60+
const head = createUnhead({
61+
init: [{ title: 'Init Title' }],
62+
})
63+
64+
const entry = head.push({ title: 'Component Title' })
65+
66+
// Resolve initially
67+
let tags = await head.resolveTags()
68+
expect(tags.find(t => t.tag === 'title')?.textContent).toBe('Component Title')
69+
70+
// Dispose entry (this internally calls invalidate)
71+
entry.dispose()
72+
73+
// Should restore init values
74+
tags = await head.resolveTags()
75+
expect(tags.find(t => t.tag === 'title')?.textContent).toBe('Init Title')
76+
77+
// Manual invalidate should still work
78+
head.invalidate()
79+
tags = await head.resolveTags()
80+
expect(tags.find(t => t.tag === 'title')?.textContent).toBe('Init Title')
81+
})
82+
})

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy