wip: test hydrate v-if in PROD

This commit is contained in:
daiwei 2025-04-25 09:43:15 +08:00
parent e9c9e4903d
commit 612cde76ce
5 changed files with 338 additions and 193 deletions

View File

@ -78,6 +78,15 @@ const triggerEvent = (type: string, el: Element) => {
el.dispatchEvent(event)
}
async function runWithEnv(isProd: boolean, fn: () => Promise<void>) {
if (isProd) __DEV__ = false
try {
await fn()
} finally {
if (isProd) __DEV__ = true
}
}
describe('Vapor Mode hydration', () => {
delegateEvents('click')
@ -639,7 +648,19 @@ describe('Vapor Mode hydration', () => {
})
describe('if', () => {
describe('DEV mode', () => {
runTests()
})
describe('PROD mode', () => {
runTests(true)
})
function runTests(isProd: boolean = false) {
const anchorLabel = isProd ? '$' : 'if'
test('basic toggle - true -> false', async () => {
runWithEnv(isProd, async () => {
const data = ref(true)
const { container } = await testHydration(
`<template>
@ -648,16 +669,22 @@ describe('Vapor Mode hydration', () => {
undefined,
data,
)
expect(container.innerHTML).toMatchInlineSnapshot(
`"<div>foo</div><!--if-->"`,
expect(container.innerHTML).toBe(
`<div>foo</div><!--${anchorLabel}-->`,
)
data.value = false
await nextTick()
expect(container.innerHTML).toMatchInlineSnapshot(`"<!--if-->"`)
expect(container.innerHTML).toBe(`<!--${anchorLabel}-->`)
})
})
test('basic toggle - false -> true', async () => {
runWithEnv(isProd, async () => {
// v-if="false" is rendered as <!----> in the server-rendered HTML
// it reused as anchor, so the anchor label is empty in PROD
let anchorLabel = isProd ? '' : 'if'
if (isProd) __DEV__ = false
const data = ref(false)
const { container } = await testHydration(
`<template>
@ -666,16 +693,18 @@ describe('Vapor Mode hydration', () => {
undefined,
data,
)
expect(container.innerHTML).toMatchInlineSnapshot(`"<!--if-->"`)
expect(container.innerHTML).toBe(`<!--${anchorLabel}-->`)
data.value = true
await nextTick()
expect(container.innerHTML).toMatchInlineSnapshot(
`"<div>foo</div><!--if-->"`,
expect(container.innerHTML).toBe(
`<div>foo</div><!--${anchorLabel}-->`,
)
})
})
test('v-if/else-if/else chain - switch branches', async () => {
runWithEnv(isProd, async () => {
const data = ref('a')
const { container } = await testHydration(
`<template>
@ -686,24 +715,35 @@ describe('Vapor Mode hydration', () => {
undefined,
data,
)
expect(container.innerHTML).toMatchInlineSnapshot(
`"<div>foo</div><!--if-->"`,
expect(container.innerHTML).toBe(
`<div>foo</div><!--${anchorLabel}-->`,
)
data.value = 'b'
await nextTick()
expect(container.innerHTML).toMatchInlineSnapshot(
`"<div>bar</div><!--if--><!--if-->"`,
// In PROD, the anchor of v-else-if (DynamicFragment) is an empty text node,
// so it won't be rendered
expect(container.innerHTML).toBe(
`<div>bar</div><!--${anchorLabel}-->${isProd ? '' : `<!--${anchorLabel}-->`}`,
)
data.value = 'c'
await nextTick()
expect(container.innerHTML).toMatchInlineSnapshot(
`"<div>baz</div><!--if--><!--if-->"`,
// same as above
expect(container.innerHTML).toBe(
`<div>baz</div><!--${anchorLabel}-->${isProd ? '' : `<!--${anchorLabel}-->`}`,
)
data.value = 'a'
await nextTick()
expect(container.innerHTML).toBe(
`<div>foo</div><!--${anchorLabel}-->`,
)
})
})
test('nested if', async () => {
runWithEnv(isProd, async () => {
const data = reactive({ outer: true, inner: true })
const { container } = await testHydration(
`<template>
@ -715,22 +755,30 @@ describe('Vapor Mode hydration', () => {
undefined,
data,
)
expect(container.innerHTML).toMatchInlineSnapshot(
`"<div><span>outer</span><div>inner</div><!--if--></div><!--if-->"`,
expect(container.innerHTML).toBe(
`<div>` +
`<span>outer</span>` +
`<div>inner</div><!--${anchorLabel}-->` +
`</div><!--${anchorLabel}-->`,
)
data.inner = false
await nextTick()
expect(container.innerHTML).toMatchInlineSnapshot(
`"<div><span>outer</span><!--if--></div><!--if-->"`,
expect(container.innerHTML).toBe(
`<div>` +
`<span>outer</span>` +
`<!--${anchorLabel}-->` +
`</div><!--${anchorLabel}-->`,
)
data.outer = false
await nextTick()
expect(container.innerHTML).toMatchInlineSnapshot(`"<!--if-->"`)
expect(container.innerHTML).toBe(`<!--${anchorLabel}-->`)
})
})
test('on component', async () => {
runWithEnv(isProd, async () => {
const data = ref(true)
const { container } = await testHydration(
`<template>
@ -739,14 +787,59 @@ describe('Vapor Mode hydration', () => {
{ Child: `<template>foo</template>` },
data,
)
expect(container.innerHTML).toMatchInlineSnapshot(`"foo<!--if-->"`)
expect(container.innerHTML).toBe(`foo<!--${anchorLabel}-->`)
data.value = false
await nextTick()
expect(container.innerHTML).toMatchInlineSnapshot(`"<!--if-->"`)
expect(container.innerHTML).toBe(`<!--${anchorLabel}-->`)
})
})
test('v-if/else-if/else chain on component - switch branches', async () => {
runWithEnv(isProd, async () => {
const data = ref('a')
const { container } = await testHydration(
`<template>
<components.Child1 v-if="data === 'a'"/>
<components.Child2 v-else-if="data === 'b'"/>
<components.Child3 v-else/>
</template>`,
{
Child1: `<template><span>{{data}} child1</span></template>`,
Child2: `<template><span>{{data}} child2</span></template>`,
Child3: `<template><span>{{data}} child3</span></template>`,
},
data,
)
expect(container.innerHTML).toBe(
`<span>a child1</span><!--${anchorLabel}-->`,
)
data.value = 'b'
await nextTick()
// In PROD, the anchor of v-else-if (DynamicFragment) is an empty text node,
// so it won't be rendered
expect(container.innerHTML).toBe(
`<span>b child2</span><!--${anchorLabel}-->${isProd ? '' : `<!--${anchorLabel}-->`}`,
)
data.value = 'c'
await nextTick()
// same as above
expect(container.innerHTML).toBe(
`<span>c child3</span><!--${anchorLabel}-->${isProd ? '' : `<!--${anchorLabel}-->`}`,
)
data.value = 'a'
await nextTick()
expect(container.innerHTML).toBe(
`<span>a child1</span><!--${anchorLabel}-->`,
)
})
})
test('on component with anchor insertion', async () => {
runWithEnv(isProd, async () => {
const data = ref(true)
const { container } = await testHydration(
`<template>
@ -759,18 +852,28 @@ describe('Vapor Mode hydration', () => {
{ Child: `<template>foo</template>` },
data,
)
expect(container.innerHTML).toMatchInlineSnapshot(
`"<div><span></span>foo<!--if--><span></span></div>"`,
expect(container.innerHTML).toBe(
`<div>` +
`<span></span>` +
`foo<!--${anchorLabel}-->` +
`<span></span>` +
`</div>`,
)
data.value = false
await nextTick()
expect(container.innerHTML).toMatchInlineSnapshot(
`"<div><span></span><!--if--><span></span></div>"`,
expect(container.innerHTML).toBe(
`<div>` +
`<span></span>` +
`<!--${anchorLabel}-->` +
`<span></span>` +
`</div>`,
)
})
})
test('consecutive v-if on component with anchor insertion', async () => {
runWithEnv(isProd, async () => {
const data = ref(true)
const { container } = await testHydration(
`<template>
@ -784,18 +887,30 @@ describe('Vapor Mode hydration', () => {
{ Child: `<template>foo</template>` },
data,
)
expect(container.innerHTML).toMatchInlineSnapshot(
`"<div><span></span>foo<!--if-->foo<!--if--><span></span></div>"`,
expect(container.innerHTML).toBe(
`<div>` +
`<span></span>` +
`foo<!--${anchorLabel}-->` +
`foo<!--${anchorLabel}-->` +
`<span></span>` +
`</div>`,
)
data.value = false
await nextTick()
expect(container.innerHTML).toMatchInlineSnapshot(
`"<div><span></span><!--if--><!--if--><span></span></div>"`,
expect(container.innerHTML).toBe(
`<div>` +
`<span></span>` +
`<!--${anchorLabel}-->` +
`<!--${anchorLabel}-->` +
`<span></span>` +
`</div>`,
)
})
})
test('consecutive v-if on fragment component with anchor insertion', async () => {
runWithEnv(isProd, async () => {
const data = ref(true)
const { container } = await testHydration(
`<template>
@ -811,17 +926,29 @@ describe('Vapor Mode hydration', () => {
},
data,
)
expect(container.innerHTML).toMatchInlineSnapshot(
`"<div><span></span><!--[--><div>true</div>-true-<!--]--><!--if--><!--[--><div>true</div>-true-<!--]--><!--if--><span></span></div>"`,
expect(container.innerHTML).toBe(
`<div>` +
`<span></span>` +
`<!--[--><div>true</div>-true-<!--]--><!--${anchorLabel}-->` +
`<!--[--><div>true</div>-true-<!--]--><!--${anchorLabel}-->` +
`<span></span>` +
`</div>`,
)
data.value = false
await nextTick()
expect(container.innerHTML).toMatchInlineSnapshot(
`"<div><span></span><!--[--><!--]--><!--if--><!--[--><!--]--><!--if--><span></span></div>"`,
expect(container.innerHTML).toBe(
`<div>` +
`<span></span>` +
`<!--[--><!--]--><!--${anchorLabel}-->` +
`<!--[--><!--]--><!--${anchorLabel}-->` +
`<span></span>` +
`</div>`,
)
})
})
}
})
test.todo('for')

View File

@ -5,7 +5,7 @@ import {
mountComponent,
unmountComponent,
} from './component'
import { createComment, createTextNode, next } from './dom/node'
import { createComment, createTextNode, nextSiblingAnchor } from './dom/node'
import { EffectScope, pauseTracking, resetTracking } from '@vue/reactivity'
import { currentHydrationNode, isComment, isHydrating } from './dom/hydration'
import { isDynamicFragmentEndAnchor, warn } from '@vue/runtime-dom'
@ -88,8 +88,9 @@ export class DynamicFragment extends VaporFragment {
if (isComment(currentHydrationNode!, '')) {
this.anchor = currentHydrationNode
} else {
const anchor = next(currentHydrationNode!)
if (isDynamicFragmentEndAnchor(anchor)) {
// find next sibling `<!--$-->` as anchor
const anchor = nextSiblingAnchor(currentHydrationNode!, '$')!
if (anchor && isDynamicFragmentEndAnchor(anchor)) {
this.anchor = anchor
} else if (__DEV__) {
// TODO warning

View File

@ -46,16 +46,8 @@ function _next(node: Node): Node {
}
/*! #__NO_SIDE_EFFECTS__ */
function __next(node: Node): Node {
// treat dynamic node (<!--[[-->...<!--]]-->) as a single node
if (isComment(node, '[[')) {
node = locateEndAnchor(node, '[[', ']]')!
}
// treat dynamic node (<!--[-->...<!--]-->) as a single node
else if (isComment(node, '[')) {
node = locateEndAnchor(node)!
}
export function __next(node: Node): Node {
node = handleWrappedNode(node)
let n = node.nextSibling!
while (n && isNonHydrationNode(n)) {
@ -109,12 +101,12 @@ export function disableHydrationNodeLookup(): void {
/*! #__NO_SIDE_EFFECTS__ */
export function prev(node: Node): Node | null {
// treat dynamic node (<!--[[-->...<!--]]-->) as a single node
// process dynamic node (<!--[[-->...<!--]]-->) as a single one
if (isComment(node, ']]')) {
node = locateStartAnchor(node, '[[', ']]')!
}
// treat dynamic node (<!--[-->...<!--]-->) as a single node
// process fragment node (<!--[-->...<!--]-->) as a single one
else if (isComment(node, ']')) {
node = locateStartAnchor(node)!
}
@ -134,12 +126,8 @@ function isNonHydrationNode(node: Node) {
isDynamicAnchor(node) ||
// fragment end anchor (`<!--]-->`)
isComment(node, ']') ||
isDynamicFragmentAnchor(node)
)
}
function isDynamicFragmentAnchor(node: Node) {
return __DEV__
// dynamic fragment anchors
(__DEV__
? // v-if anchor (`<!--if-->`)
isComment(node, 'if') ||
// v-for anchor (`<!--for-->`)
@ -148,6 +136,35 @@ function isDynamicFragmentAnchor(node: Node) {
isComment(node, 'slot') ||
// dynamic-component anchor (`<!--dynamic-component-->`)
isComment(node, 'dynamic-component')
: // TODO ?
isComment(node, '$')
: isComment(node, '$'))
)
}
export function nextSiblingAnchor(
node: Node,
anchorLabel: string,
): Comment | null {
node = handleWrappedNode(node)
let n = node.nextSibling
while (n) {
if (isComment(n, anchorLabel)) return n
n = n.nextSibling
}
return null
}
function handleWrappedNode(node: Node): Node {
// process dynamic node (<!--[[-->...<!--]]-->) as a single one
if (isComment(node, '[[')) {
return locateEndAnchor(node, '[[', ']]')!
}
// process fragment (<!--[-->...<!--]-->) as a single one
else if (isComment(node, '[')) {
return locateEndAnchor(node)!
}
return node
}

View File

@ -25,7 +25,7 @@ describe('ssr: attr fallthrough', () => {
template: `<child :ok="ok" class="bar"/>`,
}
expect(await renderToString(createApp(Parent, { ok: true }))).toBe(
`<div class="foo bar"></div>`,
`<div class="foo bar"></div><!--$-->`,
)
expect(await renderToString(createApp(Parent, { ok: false }))).toBe(
`<span class="bar"></span>`,

View File

@ -94,7 +94,7 @@ describe('ssr: slot', () => {
template: `<one><template v-if="true">hello</template></one>`,
}),
),
).toBe(`<div><!--[--><!--[-->hello<!--]--><!--]--></div>`)
).toBe(`<div><!--[--><!--[-->hello<!--]--><!--$--><!--]--></div>`)
})
test('fragment slot (template v-if + multiple elements)', async () => {
@ -106,7 +106,7 @@ describe('ssr: slot', () => {
}),
),
).toBe(
`<div><!--[--><!--[--><div>one</div><div>two</div><!--]--><!--]--></div>`,
`<div><!--[--><!--[--><div>one</div><div>two</div><!--]--><!--$--><!--]--></div>`,
)
})
@ -135,7 +135,7 @@ describe('ssr: slot', () => {
template: `<one><div v-if="true">foo</div></one>`,
}),
),
).toBe(`<div>foo</div>`)
).toBe(`<div>foo</div><!--$-->`)
})
// #9933