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

Skip to content

Commit 37db290

Browse files
harlan-zwclaude
andauthored
fix(client): process pending patches even when dirty is false (#530) (#636)
When `patch()` is called during or after a render cycle, the render may complete and set `dirty = false` before the debounced render fires. This caused the debounced render to skip processing `_pending` patches. The fix checks for pending patches as a fallback when `dirty` is false, ensuring reactive updates are always rendered. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.5 <[email protected]>
1 parent c27c612 commit 37db290

2 files changed

Lines changed: 147 additions & 1 deletion

File tree

packages/unhead/src/client/renderDOMHead.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ export function renderDOMHead<T extends Unhead<any>>(head: T, options: RenderDom
1616

1717
function _renderDOMHead<T extends Unhead<any>>(head: T, options: RenderDomHeadOptions = {}): boolean {
1818
const dom: Document | undefined = options.document || head.resolvedOptions.document
19-
if (!dom || !head.dirty)
19+
if (!dom || (!head.dirty && ![...head.entries.values()].some(e => e._pending !== undefined)))
2020
return false
2121
const beforeRenderCtx: DomBeforeRenderCtx = { shouldRender: true, tags: [] }
2222
callHook(head, 'dom:beforeRender', beforeRenderCtx)
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { createDebouncedFn, createDomRenderer } from '../../../src/client'
3+
import { createHead } from '../../../src/client/createHead'
4+
import { useDelayedSerializedDom, useDom, useDOMHead } from '../../util'
5+
6+
describe('patch timing', () => {
7+
it('processes pending patches even when dirty is false', async () => {
8+
const head = useDOMHead()
9+
10+
// Initial push with style
11+
const entry = head.push({
12+
style: [{ innerHTML: '.initial { color: red; }' }],
13+
})
14+
15+
await useDelayedSerializedDom()
16+
17+
// Manually set dirty to false to simulate render completion
18+
head.dirty = false
19+
20+
// Patch with new style - this sets _pending but dirty was just cleared
21+
entry.patch({
22+
style: [{ innerHTML: '.updated { color: blue; }' }],
23+
})
24+
25+
// Verify the pending patch was processed
26+
const result = await useDelayedSerializedDom()
27+
expect(result).toContain('.updated { color: blue; }')
28+
expect(result).not.toContain('.initial { color: red; }')
29+
})
30+
31+
it('handles rapid patches during render cycle', async () => {
32+
const head = useDOMHead()
33+
34+
const entry = head.push({
35+
meta: [{ name: 'description', content: 'initial' }],
36+
})
37+
38+
// Rapidly patch multiple times
39+
entry.patch({ meta: [{ name: 'description', content: 'update1' }] })
40+
entry.patch({ meta: [{ name: 'description', content: 'update2' }] })
41+
entry.patch({ meta: [{ name: 'description', content: 'final' }] })
42+
43+
const result = await useDelayedSerializedDom()
44+
expect(result).toContain('content="final"')
45+
expect(result).not.toContain('content="initial"')
46+
})
47+
48+
it('renders new tags after old tags removed', async () => {
49+
const head = useDOMHead()
50+
51+
// Start with a style tag
52+
const entry = head.push({
53+
style: [{ innerHTML: 'body { background: white; }' }],
54+
})
55+
56+
await useDelayedSerializedDom()
57+
58+
// Patch to empty (simulating loading state)
59+
entry.patch({})
60+
61+
await useDelayedSerializedDom()
62+
63+
// Patch with new style (simulating async completion)
64+
entry.patch({
65+
style: [{ innerHTML: 'body { background: black; }' }],
66+
})
67+
68+
const result = await useDelayedSerializedDom()
69+
expect(result).toContain('body { background: black; }')
70+
})
71+
72+
// Issue #530: Reproduces the exact race condition with debounced renderer
73+
it('issue 530: patch during debounced render cycle', async () => {
74+
const dom = useDom()
75+
const domRenderer = createDomRenderer({ document: dom.window.document })
76+
77+
// Create head with debounced renderer (like Vue does)
78+
const head = createHead({
79+
document: dom.window.document,
80+
render: createDebouncedFn(() => domRenderer(head), fn => setTimeout(fn, 0)),
81+
})
82+
83+
// Initial push - simulates SSR hydration
84+
const entry = head.push({
85+
style: [{ innerHTML: '.ssr-style { color: red; }' }],
86+
})
87+
88+
// Wait for initial render
89+
await new Promise(r => setTimeout(r, 10))
90+
expect(dom.serialize()).toContain('.ssr-style { color: red; }')
91+
92+
// Simulate computedAsync: first patch to empty (loading state)
93+
entry.patch({})
94+
await new Promise(r => setTimeout(r, 10))
95+
expect(dom.serialize()).not.toContain('.ssr-style')
96+
97+
// Simulate computedAsync: async completes with new value
98+
// This is where the bug occurred - dirty was false, _pending was set
99+
entry.patch({
100+
style: [{ innerHTML: '.async-style { color: blue; }' }],
101+
})
102+
103+
await new Promise(r => setTimeout(r, 10))
104+
105+
// With fix: new style should be rendered
106+
expect(dom.serialize()).toContain('.async-style { color: blue; }')
107+
})
108+
109+
// Issue #530: Simulates the exact race where render completes mid-patch
110+
it('issue 530: concurrent render clears dirty before debounced patch render', async () => {
111+
const dom = useDom()
112+
const domRenderer = createDomRenderer({ document: dom.window.document })
113+
114+
let renderCount = 0
115+
const head = createHead({
116+
document: dom.window.document,
117+
render: createDebouncedFn(() => {
118+
renderCount++
119+
return domRenderer(head)
120+
}, fn => setTimeout(fn, 0)),
121+
})
122+
123+
const entry = head.push({
124+
meta: [{ name: 'test', content: 'initial' }],
125+
})
126+
127+
await new Promise(r => setTimeout(r, 10))
128+
expect(dom.serialize()).toContain('content="initial"')
129+
const initialRenderCount = renderCount
130+
131+
// Force dirty = false to simulate a concurrent render completing
132+
head.dirty = false
133+
134+
// Patch while dirty is false - sets _pending
135+
entry.patch({
136+
meta: [{ name: 'test', content: 'updated' }],
137+
})
138+
139+
// The entry now has _pending but dirty might be false
140+
// The fix ensures we still render
141+
await new Promise(r => setTimeout(r, 10))
142+
143+
expect(dom.serialize()).toContain('content="updated"')
144+
expect(renderCount).toBeGreaterThan(initialRenderCount)
145+
})
146+
})

0 commit comments

Comments
 (0)