wip: refactor hydration for v-if

This commit is contained in:
daiwei 2025-04-24 16:58:18 +08:00
parent 38d4889de7
commit e9c9e4903d
17 changed files with 274 additions and 67 deletions

View File

@ -245,6 +245,7 @@ describe('ssr: components', () => {
_push(\`<span\${_scopeId}></span>\`)
})
_push(\`<!--]--></div>\`)
_push(\`<!--$-->\`)
} else {
_push(\`<!---->\`)
}
@ -268,6 +269,7 @@ describe('ssr: components', () => {
_push(\`<span\${_scopeId}></span>\`)
})
_push(\`<!--]--></div>\`)
_push(\`<!--$-->\`)
} else {
_push(\`<!---->\`)
}
@ -361,6 +363,7 @@ describe('ssr: components', () => {
_push(\`\`)
if (false) {
_push(\`<div\${_scopeId}></div>\`)
_push(\`<!--$-->\`)
} else {
_push(\`<!---->\`)
}

View File

@ -29,6 +29,7 @@ describe('ssr: attrs fallthrough', () => {
_push(\`<!--[-->\`)
if (true) {
_push(\`<div></div>\`)
_push(\`<!--$-->\`)
} else {
_push(\`<!---->\`)
}

View File

@ -70,6 +70,7 @@ describe('ssr: inject <style vars>', () => {
const _cssVars = { style: { color: _ctx.color }}
if (_ctx.ok) {
_push(\`<div\${_ssrRenderAttrs(_mergeProps(_attrs, _cssVars))}></div>\`)
_push(\`<!--$-->\`)
} else {
_push(\`<!--[--><div\${
_ssrRenderAttrs(_cssVars)

View File

@ -153,6 +153,7 @@ describe('ssr: <slot>', () => {
return function ssrRender(_ctx, _push, _parent, _attrs) {
if (true) {
_ssrRenderSlotInner(_ctx.$slots, "default", {}, null, _push, _parent, null, true)
_push(\`<!--$-->\`)
} else {
_push(\`<!---->\`)
}

View File

@ -54,6 +54,7 @@ describe('transition-group', () => {
})
if (false) {
_push(\`<div></div>\`)
_push(\`<!--$-->\`)
}
_push(\`</ul>\`)
}"
@ -123,6 +124,7 @@ describe('transition-group', () => {
})
if (_ctx.ok) {
_push(\`<div>ok</div>\`)
_push(\`<!--$-->\`)
}
_push(\`<!--]-->\`)
}"

View File

@ -8,6 +8,7 @@ describe('ssr: v-if', () => {
return function ssrRender(_ctx, _push, _parent, _attrs) {
if (_ctx.foo) {
_push(\`<div\${_ssrRenderAttrs(_attrs)}></div>\`)
_push(\`<!--$-->\`)
} else {
_push(\`<!---->\`)
}
@ -23,6 +24,7 @@ describe('ssr: v-if', () => {
return function ssrRender(_ctx, _push, _parent, _attrs) {
if (_ctx.foo) {
_push(\`<div\${_ssrRenderAttrs(_attrs)}>hello<span>ok</span></div>\`)
_push(\`<!--$-->\`)
} else {
_push(\`<!---->\`)
}
@ -38,6 +40,7 @@ describe('ssr: v-if', () => {
return function ssrRender(_ctx, _push, _parent, _attrs) {
if (_ctx.foo) {
_push(\`<div\${_ssrRenderAttrs(_attrs)}></div>\`)
_push(\`<!--$-->\`)
} else {
_push(\`<span\${_ssrRenderAttrs(_attrs)}></span>\`)
}
@ -53,8 +56,10 @@ describe('ssr: v-if', () => {
return function ssrRender(_ctx, _push, _parent, _attrs) {
if (_ctx.foo) {
_push(\`<div\${_ssrRenderAttrs(_attrs)}></div>\`)
_push(\`<!--$-->\`)
} else if (_ctx.bar) {
_push(\`<span\${_ssrRenderAttrs(_attrs)}></span>\`)
_push(\`<!--$-->\`)
} else {
_push(\`<!---->\`)
}
@ -70,8 +75,10 @@ describe('ssr: v-if', () => {
return function ssrRender(_ctx, _push, _parent, _attrs) {
if (_ctx.foo) {
_push(\`<div\${_ssrRenderAttrs(_attrs)}></div>\`)
_push(\`<!--$-->\`)
} else if (_ctx.bar) {
_push(\`<span\${_ssrRenderAttrs(_attrs)}></span>\`)
_push(\`<!--$-->\`)
} else {
_push(\`<p\${_ssrRenderAttrs(_attrs)}></p>\`)
}
@ -86,6 +93,7 @@ describe('ssr: v-if', () => {
return function ssrRender(_ctx, _push, _parent, _attrs) {
if (_ctx.foo) {
_push(\`<!--[-->hello<!--]-->\`)
_push(\`<!--$-->\`)
} else {
_push(\`<!---->\`)
}
@ -102,6 +110,7 @@ describe('ssr: v-if', () => {
return function ssrRender(_ctx, _push, _parent, _attrs) {
if (_ctx.foo) {
_push(\`<div\${_ssrRenderAttrs(_attrs)}>hi</div>\`)
_push(\`<!--$-->\`)
} else {
_push(\`<!---->\`)
}
@ -118,6 +127,7 @@ describe('ssr: v-if', () => {
return function ssrRender(_ctx, _push, _parent, _attrs) {
if (_ctx.foo) {
_push(\`<!--[--><div>hi</div><div>ho</div><!--]-->\`)
_push(\`<!--$-->\`)
} else {
_push(\`<!---->\`)
}
@ -138,6 +148,7 @@ describe('ssr: v-if', () => {
_push(\`<div></div>\`)
})
_push(\`<!--]-->\`)
_push(\`<!--$-->\`)
} else {
_push(\`<!---->\`)
}
@ -156,6 +167,7 @@ describe('ssr: v-if', () => {
return function ssrRender(_ctx, _push, _parent, _attrs) {
if (_ctx.foo) {
_push(\`<!--[--><div>hi</div><div>ho</div><!--]-->\`)
_push(\`<!--$-->\`)
} else {
_push(\`<div\${_ssrRenderAttrs(_attrs)}></div>\`)
}

View File

@ -91,6 +91,7 @@ describe('ssr: v-model', () => {
? _ssrLooseContain(_ctx.model, _ctx.i)
: _ssrLooseEqual(_ctx.model, _ctx.i))) ? " selected" : ""
}></option>\`)
_push(\`<!--$-->\`)
} else {
_push(\`<!---->\`)
}

View File

@ -295,8 +295,13 @@ function processChildrenDynamicInfo(
for (let i = 0; i < filteredChildren.length; i++) {
const child = filteredChildren[i]
if (isStaticChildNode(child)) continue
if (
isStaticChildNode(child) ||
// v-if has an anchor, which can be used to distinguish the boundary
child.type === NodeTypes.IF
) {
continue
}
child._ssrDynamicInfo = {
hasStaticPrevious: false,
hasStaticNext: false,

View File

@ -74,5 +74,13 @@ function processIfBranch(
(children.length !== 1 || children[0].type !== NodeTypes.ELEMENT) &&
// optimize away nested fragments when the only child is a ForNode
!(children.length === 1 && children[0].type === NodeTypes.FOR)
return processChildrenAsStatement(branch, context, needFragmentWrapper)
const statement = processChildrenAsStatement(
branch,
context,
needFragmentWrapper,
)
if (branch.condition) {
statement.body.push(createCallExpression(`_push`, ['`<!--$-->`']))
}
return statement
}

View File

@ -598,14 +598,14 @@ describe('SSR hydration', () => {
const ctx: SSRContext = {}
container.innerHTML = await renderToString(h(App), ctx)
expect(container.innerHTML).toBe(
'<div><!--teleport start--><!--teleport end--></div>',
'<div><!--teleport start--><!--teleport end--><!--$--></div>',
)
teleportContainer.innerHTML = ctx.teleports!['#target']
// hydrate
createSSRApp(App).mount(container)
expect(container.innerHTML).toBe(
'<div><!--teleport start--><!--teleport end--></div>',
'<div><!--teleport start--><!--teleport end--><!--$--></div>',
)
expect(teleportContainer.innerHTML).toBe(
'<!--teleport start anchor--><span>Teleported Comp1</span><!--teleport anchor-->',
@ -614,7 +614,7 @@ describe('SSR hydration', () => {
toggle.value = false
await nextTick()
expect(container.innerHTML).toBe('<div><div>Comp2</div></div>')
expect(container.innerHTML).toBe('<div><div>Comp2</div><!--$--></div>')
expect(teleportContainer.innerHTML).toBe('')
})
@ -657,21 +657,21 @@ describe('SSR hydration', () => {
// server render
container.innerHTML = await renderToString(h(App))
expect(container.innerHTML).toBe(
'<div><!--teleport start--><!--teleport end--></div>',
'<div><!--teleport start--><!--teleport end--><!--$--></div>',
)
expect(teleportContainer.innerHTML).toBe('')
// hydrate
createSSRApp(App).mount(container)
expect(container.innerHTML).toBe(
'<div><!--teleport start--><!--teleport end--></div>',
'<div><!--teleport start--><!--teleport end--><!--$--></div>',
)
expect(teleportContainer.innerHTML).toBe('<span>Teleported Comp1</span>')
expect(`Hydration children mismatch`).toHaveBeenWarned()
toggle.value = false
await nextTick()
expect(container.innerHTML).toBe('<div><div>Comp2</div></div>')
expect(container.innerHTML).toBe('<div><div>Comp2</div><!--$--></div>')
expect(teleportContainer.innerHTML).toBe('')
})

View File

@ -84,10 +84,14 @@ const getContainerType = (
return undefined
}
export function isDynamicAnchor(node: Node): boolean {
export function isDynamicAnchor(node: Node): node is Comment {
return isComment(node) && (node.data === '[[' || node.data === ']]')
}
export function isDynamicFragmentEndAnchor(node: Node): node is Comment {
return isComment(node) && node.data === '$'
}
export const isComment = (node: Node): node is Comment =>
node.nodeType === DOMNodeTypes.COMMENT
@ -125,8 +129,10 @@ export function createHydrationFunctions(
function nextSibling(node: Node) {
let n = next(node)
// skip dynamic anchors
if (n && isDynamicAnchor(n)) {
// skip if:
// - dynamic anchors (`<!--[-->`, `<!--]-->`)
// - dynamic fragment end anchors (`<!--$-->`)
if (n && (isDynamicAnchor(n) || isDynamicFragmentEndAnchor(n))) {
n = next(n)
}
return n
@ -158,7 +164,9 @@ export function createHydrationFunctions(
slotScopeIds: string[] | null,
optimized = false,
): Node | null => {
if (isDynamicAnchor(node)) node = nextSibling(node)!
if (isDynamicAnchor(node) || isDynamicFragmentEndAnchor(node)) {
node = nextSibling(node)!
}
optimized = optimized || !!vnode.dynamicChildren
const isFragmentStart = isComment(node) && node.data === '['
const onMismatch = () =>

View File

@ -560,4 +560,4 @@ export { initFeatureFlags } from './featureFlags'
/**
* @internal
*/
export { isDynamicAnchor } from './hydration'
export { isDynamicAnchor, isDynamicFragmentEndAnchor } from './hydration'

View File

@ -730,7 +730,97 @@ describe('Vapor Mode hydration', () => {
expect(container.innerHTML).toMatchInlineSnapshot(`"<!--if-->"`)
})
test.todo('on component', async () => {})
test('on component', async () => {
const data = ref(true)
const { container } = await testHydration(
`<template>
<components.Child v-if="data"/>
</template>`,
{ Child: `<template>foo</template>` },
data,
)
expect(container.innerHTML).toMatchInlineSnapshot(`"foo<!--if-->"`)
data.value = false
await nextTick()
expect(container.innerHTML).toMatchInlineSnapshot(`"<!--if-->"`)
})
test('on component with anchor insertion', async () => {
const data = ref(true)
const { container } = await testHydration(
`<template>
<div>
<span/>
<components.Child v-if="data"/>
<span/>
</div>
</template>`,
{ Child: `<template>foo</template>` },
data,
)
expect(container.innerHTML).toMatchInlineSnapshot(
`"<div><span></span>foo<!--if--><span></span></div>"`,
)
data.value = false
await nextTick()
expect(container.innerHTML).toMatchInlineSnapshot(
`"<div><span></span><!--if--><span></span></div>"`,
)
})
test('consecutive v-if on component with anchor insertion', async () => {
const data = ref(true)
const { container } = await testHydration(
`<template>
<div>
<span/>
<components.Child v-if="data"/>
<components.Child v-if="data"/>
<span/>
</div>
</template>`,
{ Child: `<template>foo</template>` },
data,
)
expect(container.innerHTML).toMatchInlineSnapshot(
`"<div><span></span>foo<!--if-->foo<!--if--><span></span></div>"`,
)
data.value = false
await nextTick()
expect(container.innerHTML).toMatchInlineSnapshot(
`"<div><span></span><!--if--><!--if--><span></span></div>"`,
)
})
test('consecutive v-if on fragment component with anchor insertion', async () => {
const data = ref(true)
const { container } = await testHydration(
`<template>
<div>
<span/>
<components.Child v-if="data"/>
<components.Child v-if="data"/>
<span/>
</div>
</template>`,
{
Child: `<template><div>{{ data }}</div>-{{ data }}-</template>`,
},
data,
)
expect(container.innerHTML).toMatchInlineSnapshot(
`"<div><span></span><!--[--><div>true</div>-true-<!--]--><!--if--><!--[--><div>true</div>-true-<!--]--><!--if--><span></span></div>"`,
)
data.value = false
await nextTick()
expect(container.innerHTML).toMatchInlineSnapshot(
`"<div><span></span><!--[--><!--]--><!--if--><!--[--><!--]--><!--if--><span></span></div>"`,
)
})
})
test.todo('for')

View File

@ -1,10 +1,5 @@
import { type Block, type BlockFn, DynamicFragment, insert } from './block'
import {
currentHydrationNode,
isComment,
isHydrating,
locateHydrationNode,
} from './dom/hydration'
import { isHydrating, locateHydrationNode } from './dom/hydration'
import { insertionAnchor, insertionParent } from './insertionState'
import { renderEffect } from './renderEffect'
@ -16,10 +11,8 @@ export function createIf(
): Block {
const _insertionParent = insertionParent
const _insertionAnchor = insertionAnchor
let _currentHydrationNode
if (isHydrating) {
locateHydrationNode()
_currentHydrationNode = currentHydrationNode
locateHydrationNode(true)
}
let frag: Block
@ -34,23 +27,5 @@ export function createIf(
insert(frag, _insertionParent, _insertionAnchor)
}
// if the current hydration node is a comment, use it as an anchor
// otherwise need to insert the anchor node
// OR adjust ssr output to add anchor for v-if
else if (isHydrating && _currentHydrationNode) {
const parentNode = _currentHydrationNode.parentNode
if (parentNode) {
if (isComment(_currentHydrationNode, '')) {
if (__DEV__) _currentHydrationNode.data = 'if'
;(frag as DynamicFragment).anchor = _currentHydrationNode
} else {
parentNode.insertBefore(
(frag as DynamicFragment).anchor,
_currentHydrationNode.nextSibling,
)
}
}
}
return frag
}

View File

@ -5,9 +5,10 @@ import {
mountComponent,
unmountComponent,
} from './component'
import { createComment, createTextNode } from './dom/node'
import { createComment, createTextNode, next } from './dom/node'
import { EffectScope, pauseTracking, resetTracking } from '@vue/reactivity'
import { isHydrating } from './dom/hydration'
import { currentHydrationNode, isComment, isHydrating } from './dom/hydration'
import { isDynamicFragmentEndAnchor, warn } from '@vue/runtime-dom'
export type Block =
| Node
@ -30,16 +31,20 @@ export class VaporFragment {
}
export class DynamicFragment extends VaporFragment {
anchor: Node
anchor!: Node
scope: EffectScope | undefined
current?: BlockFn
fallback?: BlockFn
constructor(anchorLabel?: string) {
super([])
if (isHydrating) {
this.hydrate(anchorLabel)
} else {
this.anchor =
__DEV__ && anchorLabel ? createComment(anchorLabel) : createTextNode()
}
}
update(render?: BlockFn, key: any = render): void {
if (key === this.current) {
@ -75,6 +80,24 @@ export class DynamicFragment extends VaporFragment {
resetTracking()
}
hydrate(label?: string): void {
// for v-if="false" the hydrationNode will be a empty comment node
// use it as anchor.
// otherwise, use the next sibling comment node as anchor
if (isComment(currentHydrationNode!, '')) {
this.anchor = currentHydrationNode
} else {
const anchor = next(currentHydrationNode!)
if (isDynamicFragmentEndAnchor(anchor)) {
this.anchor = anchor
} else if (__DEV__) {
// TODO warning
warn(`DynamicFragment anchor not found...`)
}
}
if (__DEV__ && label) (this.anchor as Comment).data = label
}
}
export function isFragment(val: NonNullable<unknown>): val is VaporFragment {

View File

@ -1,4 +1,4 @@
import { warn } from '@vue/runtime-dom'
import { isDynamicFragmentEndAnchor, warn } from '@vue/runtime-dom'
import {
insertionAnchor,
insertionParent,
@ -10,6 +10,7 @@ import {
disableHydrationNodeLookup,
enableHydrationNodeLookup,
next,
prev,
} from './node'
export let isHydrating = false
@ -41,7 +42,7 @@ export function withHydration(container: ParentNode, fn: () => void): void {
}
export let adoptTemplate: (node: Node, template: string) => Node | null
export let locateHydrationNode: () => void
export let locateHydrationNode: (isFragment?: boolean) => void
type Anchor = Comment & {
// cached matching fragment start to avoid repeated traversal
@ -82,7 +83,7 @@ function adoptTemplateImpl(node: Node, template: string): Node | null {
return node
}
function locateHydrationNodeImpl() {
function locateHydrationNodeImpl(isFragment?: boolean) {
let node: Node | null
// prepend / firstChild
if (insertionAnchor === 0) {
@ -92,6 +93,14 @@ function locateHydrationNodeImpl() {
node = insertionAnchor
} else {
node = insertionParent ? insertionParent.lastChild : currentHydrationNode
// if the last child is a comment, it is the anchor for the fragment
// so it need to find the previous node
if (isFragment && node && isDynamicFragmentEndAnchor(node)) {
let previous = prev(node)
if (previous) node = previous
}
if (node && isComment(node, ']')) {
// fragment backward search
if (node.$fs) {
@ -157,3 +166,25 @@ export function locateEndAnchor(
}
return null
}
export function locateStartAnchor(
node: Node | null,
open = '[',
close = ']',
): Node | null {
let match = 0
while (node) {
if (node.nodeType === 8) {
if ((node as Comment).data === close) match++
if ((node as Comment).data === open) {
if (match === 0) {
return node
} else {
match--
}
}
}
node = node.previousSibling
}
return null
}

View File

@ -1,5 +1,10 @@
import { isDynamicAnchor } from '@vue/runtime-dom'
import { isComment, isEmptyText, locateEndAnchor } from './hydration'
import {
isComment,
isEmptyText,
locateEndAnchor,
locateStartAnchor,
} from './hydration'
/*! #__NO_SIDE_EFFECTS__ */
export function createTextNode(value = ''): Text {
@ -43,21 +48,17 @@ function _next(node: Node): Node {
/*! #__NO_SIDE_EFFECTS__ */
function __next(node: Node): Node {
// treat dynamic node (<!--[[-->...<!--]]-->) as a single node
if (node && isComment(node, '[[')) {
if (isComment(node, '[[')) {
node = locateEndAnchor(node, '[[', ']]')!
}
// treat dynamic node (<!--[-->...<!--]-->) as a single node
else if (node && isComment(node, '[')) {
else if (isComment(node, '[')) {
node = locateEndAnchor(node)!
}
let n = node.nextSibling!
// skip if:
// - dynamic anchors (<!--[[-->, <!--]]-->)
// - fragment end anchor (`<!--]-->`)
// - empty text nodes
while (n && (isDynamicAnchor(n) || isComment(n, ']') || isEmptyText(n))) {
while (n && isNonHydrationNode(n)) {
n = n.nextSibling!
}
return n
@ -105,3 +106,48 @@ export function disableHydrationNodeLookup(): void {
next.impl = _next
nthChild.impl = _nthChild
}
/*! #__NO_SIDE_EFFECTS__ */
export function prev(node: Node): Node | null {
// treat dynamic node (<!--[[-->...<!--]]-->) as a single node
if (isComment(node, ']]')) {
node = locateStartAnchor(node, '[[', ']]')!
}
// treat dynamic node (<!--[-->...<!--]-->) as a single node
else if (isComment(node, ']')) {
node = locateStartAnchor(node)!
}
let n = node.previousSibling
while (n && isNonHydrationNode(n)) {
n = n.previousSibling
}
return n
}
function isNonHydrationNode(node: Node) {
return (
// empty text nodes, no need to hydrate
isEmptyText(node) ||
// dynamic anchors (<!--[[-->, <!--]]-->)
isDynamicAnchor(node) ||
// fragment end anchor (`<!--]-->`)
isComment(node, ']') ||
isDynamicFragmentAnchor(node)
)
}
function isDynamicFragmentAnchor(node: Node) {
return __DEV__
? // v-if anchor (`<!--if-->`)
isComment(node, 'if') ||
// v-for anchor (`<!--for-->`)
isComment(node, 'for') ||
// v-slot anchor (`<!--slot-->`)
isComment(node, 'slot') ||
// dynamic-component anchor (`<!--dynamic-component-->`)
isComment(node, 'dynamic-component')
: // TODO ?
isComment(node, '$')
}