fix(hydration): handle v-if on insertion parent

This commit is contained in:
daiwei 2025-07-29 18:21:12 +08:00
parent 6505a8f155
commit 9d3ee8e2ec
4 changed files with 74 additions and 0 deletions

View File

@ -1134,6 +1134,28 @@ describe('Vapor Mode hydration', () => {
expect(container.innerHTML).toBe(`<div>foo</div><!---->`)
})
test('v-if on insertion parent', async () => {
const data = ref(true)
const { container } = await testHydration(
`<template>
<div v-if="data">
<components.Child/>
</div>
</template>`,
{ Child: `<template>foo</template>` },
data,
)
expect(container.innerHTML).toBe(`<div>foo</div><!--${anchorLabel}-->`)
data.value = false
await nextTick()
expect(container.innerHTML).toBe(`<!--${anchorLabel}-->`)
data.value = true
await nextTick()
expect(container.innerHTML).toBe(`<div>foo</div><!--${anchorLabel}-->`)
})
test('v-if/else-if/else chain - switch branches', async () => {
const data = ref('a')
const { container } = await testHydration(

View File

@ -9,6 +9,37 @@ import {
import { renderEffect } from './renderEffect'
import { DynamicFragment } from './fragment'
const ifStack = [] as DynamicFragment[]
const insertionParents = new WeakMap<DynamicFragment, Node[]>()
/**
* Collects insertionParents inside an if block during hydration
* When the if condition becomes false on the client, clears the
* HTML of these insertionParents to prevent duplicate rendering
* results when the condition becomes true again
*
* Example:
* const t2 = _template("<div></div>")
* const n2 = _createIf(() => show.value, () => {
* const n5 = t2()
* _setInsertionState(n5)
* const n4 = _createComponent(Comp) // renders `<span></span>`
* return n5
* })
*
* After hydration, the HTML of `n5` is `<div><span></span></div>` instead of `<div></div>`.
* When `show.value` becomes false, the HTML of `n5` needs to be cleared,
* to avoid duplicated rendering when `show.value` becomes true again.
*/
export function collectInsertionParents(insertionParent: ParentNode): void {
const currentIf = ifStack[ifStack.length - 1]
if (currentIf) {
let nodes = insertionParents.get(currentIf)
if (!nodes) insertionParents.set(currentIf, (nodes = []))
nodes.push(insertionParent)
}
}
export function createIf(
condition: () => any,
b1: BlockFn,
@ -27,7 +58,19 @@ export function createIf(
isHydrating || __DEV__
? new DynamicFragment(IF_ANCHOR_LABEL)
: new DynamicFragment()
if (isHydrating) {
;(frag as DynamicFragment).teardown = () => {
const nodes = insertionParents.get(frag as DynamicFragment)
if (nodes) {
nodes.forEach(p => ((p as Element).innerHTML = ''))
insertionParents.delete(frag as DynamicFragment)
}
;(frag as DynamicFragment).teardown = undefined
}
ifStack.push(frag as DynamicFragment)
}
renderEffect(() => (frag as DynamicFragment).update(condition() ? b1 : b2))
isHydrating && ifStack.pop()
}
if (!isHydrating && _insertionParent) {

View File

@ -59,6 +59,7 @@ export class DynamicFragment extends VaporFragment {
* indicates forwarded slot
*/
forwarded?: boolean
teardown?: () => void
constructor(anchorLabel?: string) {
super([])
@ -97,6 +98,7 @@ export class DynamicFragment extends VaporFragment {
// teardown previous branch
if (this.scope) {
this.scope.stop()
if (parent) this.teardown && this.teardown()
const mode = transition && transition.mode
if (mode) {
applyTransitionLeaveHooks(this.nodes, transition, renderBranch)

View File

@ -1,3 +1,6 @@
import { collectInsertionParents } from './apiCreateIf'
import { isHydrating } from './dom/hydration'
export let insertionParent:
| (ParentNode & {
// dynamic node position - hydration only
@ -21,6 +24,10 @@ export let insertionAnchor: Node | 0 | undefined
export function setInsertionState(parent: ParentNode, anchor?: Node | 0): void {
insertionParent = parent
insertionAnchor = anchor
if (isHydrating) {
collectInsertionParents(parent)
}
}
export function resetInsertionState(): void {