feat(vapor/hydration): handle component with anchor insertion

This commit is contained in:
daiwei 2025-04-21 15:38:50 +08:00
parent 9ab8e4c0c9
commit e5dd701291
7 changed files with 233 additions and 48 deletions

View File

@ -396,4 +396,46 @@ describe('ssr: element', () => {
`)
})
})
describe('dynamic child anchor', () => {
test('component with element siblings', () => {
expect(
getCompiledString(`
<div>
<div/>
<Comp1/>
<div/>
</div>
`),
).toMatchInlineSnapshot(`
"\`<div><div></div>\`)
_push("<!--[[-->")
_push(_ssrRenderComponent(_component_Comp1, null, null, _parent))
_push("<!--]]-->")
_push(\`<div></div></div>\`"
`)
})
test('with consecutive components', () => {
expect(
getCompiledString(`
<div>
<div/>
<Comp1/>
<Comp2/>
<div/>
</div>
`),
).toMatchInlineSnapshot(`
"\`<div><div></div>\`)
_push("<!--[[-->")
_push(_ssrRenderComponent(_component_Comp1, null, null, _parent))
_push("<!--]]-->")
_push("<!--[[-->")
_push(_ssrRenderComponent(_component_Comp2, null, null, _parent))
_push("<!--]]-->")
_push(\`<div></div></div>\`"
`)
})
})
})

View File

@ -255,6 +255,13 @@ export function ssrProcessComponent(
node.ssrCodegenNode.arguments.push(`_scopeId`)
}
// `<!--[[-->` marks the start of the dynamic children
// Only used in Vapor hydration, VDOM hydration
// skips this marker.
const needDynamicAnchor = shouldAddDynamicAnchor(parent, node)
if (needDynamicAnchor) {
context.pushStatement(createCallExpression(`_push`, [`"<!--[[-->"`]))
}
if (typeof component === 'string') {
// static component
context.pushStatement(
@ -265,6 +272,9 @@ export function ssrProcessComponent(
// the codegen node is a `renderVNode` call
context.pushStatement(node.ssrCodegenNode)
}
if (needDynamicAnchor) {
context.pushStatement(createCallExpression(`_push`, [`"<!--]]-->"`]))
}
}
}
@ -384,3 +394,39 @@ function clone(v: any): any {
return v
}
}
function shouldAddDynamicAnchor(
parent: { tag?: string; children: TemplateChildNode[] },
node: TemplateChildNode,
): boolean {
if (!parent.tag) return false
const children = parent.children
const len = children.length
const index = children.indexOf(node)
const isStaticElement = (c: TemplateChildNode): boolean =>
c.type === NodeTypes.ELEMENT && c.tagType !== ElementTypes.COMPONENT
let hasStaticPreviousSibling = false
if (index > 0) {
for (let i = index - 1; i >= 0; i--) {
if (isStaticElement(children[i])) {
hasStaticPreviousSibling = true
break
}
}
}
let hasStaticNextSibling = false
if (hasStaticPreviousSibling && index > -1 && index < len - 1) {
for (let i = index + 1; i < len; i++) {
if (isStaticElement(children[i])) {
hasStaticNextSibling = true
break
}
}
}
return hasStaticPreviousSibling && hasStaticNextSibling
}

View File

@ -1843,6 +1843,36 @@ describe('SSR hydration', () => {
}
})
describe('dynamic child anchor', () => {
test('component with element siblings', () => {
const Comp = {
render() {
return createTextVNode('foo')
},
}
const { vnode, container } = mountWithHydration(
`<div><span></span><!--[[-->foo<!--]]--><span></span></div>`,
() => h('div', null, [h('span'), h(Comp), h('span')]),
)
expect(vnode.el).toBe(container.firstChild)
expect(`Hydration children mismatch`).not.toHaveBeenWarned()
})
test('with consecutive components', () => {
const Comp = {
render() {
return createTextVNode('foo')
},
}
const { vnode, container } = mountWithHydration(
`<div><span></span><!--[[-->foo<!--]]--><!--[[-->foo<!--]]--><span></span></div>`,
() => h('div', null, [h('span'), h(Comp), h(Comp), h('span')]),
)
expect(vnode.el).toBe(container.firstChild)
expect(`Hydration children mismatch`).not.toHaveBeenWarned()
})
})
describe('mismatch handling', () => {
test('text node', () => {
const { container } = mountWithHydration(`foo`, () => 'bar')

View File

@ -111,7 +111,7 @@ export function createHydrationFunctions(
o: {
patchProp,
createText,
nextSibling,
nextSibling: next,
parentNode,
remove,
insert,
@ -119,6 +119,19 @@ export function createHydrationFunctions(
},
} = rendererInternals
function isDynamicAnchor(node: Node): boolean {
return isComment(node) && (node.data === '[[' || node.data === ']]')
}
function nextSibling(node: Node) {
let n = next(node)
// skip dynamic child anchor
if (n && isDynamicAnchor(n)) {
n = next(n)
}
return n
}
const hydrate: RootHydrateFunction = (vnode, container) => {
if (!container.hasChildNodes()) {
;(__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) &&
@ -145,6 +158,7 @@ export function createHydrationFunctions(
slotScopeIds: string[] | null,
optimized = false,
): Node | null => {
if (isDynamicAnchor(node)) node = nextSibling(node)!
optimized = optimized || !!vnode.dynamicChildren
const isFragmentStart = isComment(node) && node.data === '['
const onMismatch = () =>
@ -451,7 +465,7 @@ export function createHydrationFunctions(
// The SSRed DOM contains more nodes than it should. Remove them.
const cur = next
next = next.nextSibling
next = nextSibling(next)
remove(cur)
}
} else if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
@ -553,7 +567,7 @@ export function createHydrationFunctions(
}
}
return el.nextSibling
return nextSibling(el)
}
const hydrateChildren = (

View File

@ -239,8 +239,7 @@ describe('Vapor Mode hydration', () => {
)
})
// problem is the <!> placeholder does not exist in SSR output
test.todo('component with anchor insertion', async () => {
test('component with anchor insertion', async () => {
const { container, data } = await testHydration(
`
<template>
@ -255,14 +254,18 @@ describe('Vapor Mode hydration', () => {
Child: `<template>{{ data }}</template>`,
},
)
expect(container.innerHTML).toMatchInlineSnapshot()
expect(container.innerHTML).toMatchInlineSnapshot(
`"<div><span></span><!--[[-->foo<!--]]--><span></span></div>"`,
)
data.value = 'bar'
await nextTick()
expect(container.innerHTML).toMatchInlineSnapshot()
expect(container.innerHTML).toMatchInlineSnapshot(
`"<div><span></span><!--[[-->bar<!--]]--><span></span></div>"`,
)
})
test.todo('consecutive component with anchor insertion', async () => {
test('consecutive component with anchor insertion', async () => {
const { container, data } = await testHydration(
`<template>
<div>
@ -277,11 +280,15 @@ describe('Vapor Mode hydration', () => {
Child: `<template>{{ data }}</template>`,
},
)
expect(container.innerHTML).toMatchInlineSnapshot()
expect(container.innerHTML).toMatchInlineSnapshot(
`"<div><span></span><!--[[-->foo<!--]]--><!--[[-->foo<!--]]--><span></span></div>"`,
)
data.value = 'bar'
await nextTick()
expect(container.innerHTML).toMatchInlineSnapshot()
expect(container.innerHTML).toMatchInlineSnapshot(
`"<div><span></span><!--[[-->bar<!--]]--><!--[[-->bar<!--]]--><span></span></div>"`,
)
})
test.todo('if')

View File

@ -1,5 +1,6 @@
import { warn } from '@vue/runtime-dom'
import {
type Anchor,
insertionAnchor,
insertionParent,
resetInsertionState,
@ -36,12 +37,6 @@ export function withHydration(container: ParentNode, fn: () => void): void {
export let adoptTemplate: (node: Node, template: string) => Node | null
export let locateHydrationNode: () => void
type Anchor = Comment & {
// cached matching fragment start to avoid repeated traversal
// on nested fragments
$fs?: Anchor
}
const isComment = (node: Node, data: string): node is Anchor =>
node.nodeType === 8 && (node as Comment).data === data
@ -77,41 +72,48 @@ function adoptTemplateImpl(node: Node, template: string): Node | null {
function locateHydrationNodeImpl() {
let node: Node | null
// prepend / firstChild
if (insertionAnchor === 0) {
node = child(insertionParent!)
} else {
node = insertionAnchor
? insertionAnchor.previousSibling
: insertionParent
? insertionParent.lastChild
: currentHydrationNode
if (node && isComment(node, ']')) {
// fragment backward search
if (node.$fs) {
// already cached matching fragment start
node = node.$fs
} else {
let cur: Node | null = node
let curFragEnd = node
let fragDepth = 0
node = null
while (cur) {
cur = cur.previousSibling
if (cur) {
if (isComment(cur, '[')) {
curFragEnd.$fs = cur
if (!fragDepth) {
node = cur
break
} else {
fragDepth--
// dynamic child anchor `<!--[[-->`
if (insertionAnchor && isDynamicStart(insertionAnchor)) {
const anchor = (insertionParent!.lds = insertionParent!.lds
? // continuous dynamic children, the next dynamic start must exist
locateNextDynamicStart(insertionParent!.lds)!
: insertionAnchor)
node = anchor.nextSibling
} else {
node = insertionAnchor
? insertionAnchor.previousSibling
: insertionParent
? insertionParent.lastChild
: currentHydrationNode
if (node && isComment(node, ']')) {
// fragment backward search
if (node.$fs) {
// already cached matching fragment start
node = node.$fs
} else {
let cur: Node | null = node
let curFragEnd = node
let fragDepth = 0
node = null
while (cur) {
cur = cur.previousSibling
if (cur) {
if (isComment(cur, '[')) {
curFragEnd.$fs = cur
if (!fragDepth) {
node = cur
break
} else {
fragDepth--
}
} else if (isComment(cur, ']')) {
curFragEnd = cur
fragDepth++
}
} else if (isComment(cur, ']')) {
curFragEnd = cur
fragDepth++
}
}
}
@ -127,3 +129,32 @@ function locateHydrationNodeImpl() {
resetInsertionState()
currentHydrationNode = node
}
function isDynamicStart(node: Node): node is Anchor {
return isComment(node, '[[')
}
function locateNextDynamicStart(anchor: Anchor): Anchor | undefined {
let cur: Node | null = anchor
let end = null
let depth = 0
while (cur) {
cur = cur.nextSibling
if (cur) {
if (isComment(cur, '[[')) {
depth++
} else if (isComment(cur, ']]')) {
if (!depth) {
end = cur
break
} else {
depth--
}
}
}
}
if (end) {
return end!.nextSibling as Anchor
}
}

View File

@ -1,5 +1,10 @@
export let insertionParent: ParentNode | undefined
export let insertionAnchor: Node | 0 | undefined
export let insertionParent:
| (ParentNode & {
// cached the last dynamic start anchor
lds?: Anchor
})
| undefined
export let insertionAnchor: Node | 0 | undefined | null
/**
* This function is called before a block type that requires insertion
@ -14,3 +19,13 @@ export function setInsertionState(parent: ParentNode, anchor?: Node | 0): void {
export function resetInsertionState(): void {
insertionParent = insertionAnchor = undefined
}
export function setInsertionAnchor(anchor: Node | null): void {
insertionAnchor = anchor
}
export type Anchor = Comment & {
// cached matching fragment start to avoid repeated traversal
// on nested fragments
$fs?: Anchor
}