wip: hydration

This commit is contained in:
daiwei 2025-06-25 22:55:37 +08:00
parent bba71becba
commit 196d551437
10 changed files with 285 additions and 103 deletions

View File

@ -24,10 +24,10 @@ export function genSelf(
context: CodegenContext,
): CodeFragment[] {
const [frag, push] = buildCodeFragment()
const { id, template, operation } = dynamic
const { id, template, operation, dynamicChildOffset } = dynamic
if (id !== undefined && template !== undefined) {
push(NEWLINE, `const n${id} = t${template}()`)
push(NEWLINE, `const n${id} = t${template}(${dynamicChildOffset || ''})`)
push(...genDirectivesForElement(id, context))
}

View File

@ -266,6 +266,7 @@ export interface IRDynamicInfo {
children: IRDynamicInfo[]
template?: number
hasDynamicChild?: boolean
dynamicChildOffset?: number
operation?: OperationNode
needsKey?: boolean
}

View File

@ -59,7 +59,7 @@ export const transformChildren: NodeTransform = (node, context) => {
function processDynamicChildren(context: TransformContext<ElementNode>) {
let prevDynamics: IRDynamicInfo[] = []
let hasStaticTemplate = false
let staticCount = 0
const children = context.dynamic.children
for (const [index, child] of children.entries()) {
@ -69,7 +69,7 @@ function processDynamicChildren(context: TransformContext<ElementNode>) {
if (!(child.flags & DynamicFlag.NON_TEMPLATE)) {
if (prevDynamics.length) {
if (hasStaticTemplate) {
if (staticCount) {
// each dynamic child gets its own placeholder node.
// this makes it easier to locate the corresponding node during hydration.
for (let i = 0; i < prevDynamics.length; i++) {
@ -92,12 +92,13 @@ function processDynamicChildren(context: TransformContext<ElementNode>) {
}
prevDynamics = []
}
hasStaticTemplate = true
staticCount++
}
}
if (prevDynamics.length) {
registerInsertion(prevDynamics, context)
registerInsertion(prevDynamics, context, undefined)
context.dynamic.dynamicChildOffset = staticCount
}
}

View File

@ -476,6 +476,34 @@ describe('Vapor Mode hydration', () => {
)
})
test('consecutive components with insertion parent', async () => {
const data = reactive({ foo: 'foo', bar: 'bar' })
const { container } = await testHydration(
`<template>
<div>
<components.Child1/>
<components.Child2/>
</div>
</template>
`,
{
Child1: `<template><span>{{ data.foo }}</span></template>`,
Child2: `<template><span>{{ data.bar }}</span></template>`,
},
data,
)
expect(container.innerHTML).toBe(
`<div><span>foo</span><span>bar</span></div>`,
)
data.foo = 'foo1'
data.bar = 'bar1'
await nextTick()
expect(container.innerHTML).toBe(
`<div><span>foo1</span><span>bar1</span></div>`,
)
})
test('nested consecutive components with anchor insertion', async () => {
const { container, data } = await testHydration(
`
@ -1314,6 +1342,38 @@ describe('Vapor Mode hydration', () => {
)
})
test('consecutive component with insertion parent', async () => {
const data = reactive({
show: true,
foo: 'foo',
bar: 'bar',
})
const { container } = await testHydration(
`<template>
<div v-if="data.show">
<components.Child/>
<components.Child2/>
</div>
</template>`,
{
Child: `<template><span>{{data.foo}}</span></template>`,
Child2: `<template><span>{{data.bar}}</span></template>`,
},
data,
)
expect(container.innerHTML).toBe(
`<div>` +
`<span>foo</span>` +
`<span>bar</span>` +
`</div>` +
`<!--${anchorLabel}-->`,
)
data.show = false
await nextTick()
expect(container.innerHTML).toBe(`<!--${anchorLabel}-->`)
})
test('consecutive v-if on component with anchor insertion', async () => {
const data = ref(true)
const { container } = await testHydration(
@ -2354,6 +2414,31 @@ describe('Vapor Mode hydration', () => {
`</div>`,
)
})
test('slot fallback', async () => {
const data = reactive({
foo: 'foo',
})
const { container } = await testHydration(
`<template>
<components.Child>
</components.Child>
</template>`,
{
Child: `<template><slot><span>{{data.foo}}</span></slot></template>`,
},
data,
)
expect(container.innerHTML).toBe(
`<!--[--><span>foo</span><!--]--><!--${slotAnchorLabel}-->`,
)
data.foo = 'bar'
await nextTick()
expect(container.innerHTML).toBe(
`<!--[--><span>bar</span><!--]--><!--${slotAnchorLabel}-->`,
)
})
})
describe.todo('transition', async () => {
@ -3912,6 +3997,79 @@ describe('VDOM hydration interop', () => {
expect(container.innerHTML).toMatchInlineSnapshot(`"false"`)
})
test('nested components (VDOM -> Vapor -> VDOM (with slot fallback))', async () => {
const data = ref(true)
const { container } = await testHydrationInterop(
`<script setup>const data = _data; const components = _components;</script>
<template>
<components.VaporChild/>
</template>`,
{
VaporChild: {
code: `<template><components.VdomChild/></template>`,
vapor: true,
},
VdomChild: {
code: `<script setup>const data = _data;</script>
<template><slot><span>{{data}}</span></slot></template>`,
vapor: false,
},
},
data,
)
expect(container.innerHTML).toMatchInlineSnapshot(
`"<!--[--><span>true</span><!--]--><!--slot-->"`,
)
data.value = false
await nextTick()
expect(container.innerHTML).toMatchInlineSnapshot(
`"<!--[--><span>false</span><!--]--><!--slot-->"`,
)
})
test.todo(
'nested components (VDOM -> Vapor(with slot fallback) -> VDOM)',
async () => {
const data = ref(true)
const { container } = await testHydrationInterop(
`<script setup>const data = _data; const components = _components;</script>
<template>
<components.VaporChild/>
</template>`,
{
VaporChild: {
code: `<template>
<components.VdomChild>
<template #default>
<span>{{data}} vapor fallback</span>
</template>
</components.VdomChild>
</template>`,
vapor: true,
},
VdomChild: {
code: `<script setup>const data = _data;</script>
<template><slot><span>vdom fallback</span></slot></template>`,
vapor: false,
},
},
data,
)
expect(container.innerHTML).toMatchInlineSnapshot(
`"<!--[--><span>true vapor fallback</span><!--]--><!--slot-->"`,
)
data.value = false
await nextTick()
expect(container.innerHTML).toMatchInlineSnapshot(
`"<!--[--><span>false vapor fallback</span><!--]--><!--slot-->"`,
)
},
)
test('vapor slot render vdom component', async () => {
const data = ref(true)
const { container } = await testHydrationInterop(

View File

@ -86,7 +86,7 @@ export const createFor = (
const _insertionParent = insertionParent
const _insertionAnchor = insertionAnchor
if (isHydrating) {
locateHydrationNode(true)
locateHydrationNode()
} else {
resetInsertionState()
}

View File

@ -6,11 +6,12 @@ import {
setInsertionState,
} from '../insertionState'
import {
_nthChild,
disableHydrationNodeLookup,
enableHydrationNodeLookup,
next,
} from './node'
import { isVaporAnchors, isVaporFragmentAnchor } from '@vue/shared'
import { isVaporAnchors } from '@vue/shared'
export let isHydrating = false
export let currentHydrationNode: Node | null = null
@ -29,9 +30,9 @@ function performHydration<T>(
if (!isOptimized) {
adoptTemplate = adoptTemplateImpl
locateHydrationNode = locateHydrationNodeImpl
// optimize anchor cache lookup
;(Comment.prototype as any).$fs = undefined
;(Comment.prototype as any).$fe = undefined
;(Node.prototype as any).$dp = undefined
isOptimized = true
}
enableHydrationNodeLookup()
@ -58,12 +59,20 @@ export function hydrateNode(node: Node, fn: () => void): void {
}
export let adoptTemplate: (node: Node, template: string) => Node | null
export let locateHydrationNode: (hasFragmentAnchor?: boolean) => void
export let locateHydrationNode: () => void
let inVNodeHydration = 0
export function setInVNodeHydration(): void {
inVNodeHydration++
}
export function unsetInVNodeHydration(): void {
inVNodeHydration--
}
type Anchor = Comment & {
// cached matching fragment start to avoid repeated traversal
// cached matching fragment end to avoid repeated traversal
// on nested fragments
$fs?: Anchor
$fe?: Anchor
}
export const isComment = (node: Node, data: string): node is Anchor =>
@ -99,9 +108,9 @@ function adoptTemplateImpl(node: Node, template: string): Node | null {
return node
}
const hydrationPositionMap = new WeakMap<ParentNode, Node>()
const nextHydrationNodeMap = new WeakMap<ParentNode, Node>()
function locateHydrationNodeImpl(hasFragmentAnchor?: boolean) {
function locateHydrationNodeImpl() {
let node: Node | null
// prepend / firstChild
if (insertionAnchor === 0) {
@ -113,51 +122,31 @@ function locateHydrationNodeImpl(hasFragmentAnchor?: boolean) {
node = insertionAnchor
} else {
node = insertionParent
? hydrationPositionMap.get(insertionParent) || insertionParent.lastChild
? nextHydrationNodeMap.get(insertionParent) ||
(insertionParent.$dp !== undefined
? _nthChild(insertionParent, insertionParent.$dp)
: currentHydrationNode)
: currentHydrationNode
// if node is a vapor fragment anchor, find the previous one
if (hasFragmentAnchor && node && isVaporFragmentAnchor(node)) {
node = node.previousSibling
if (__DEV__ && !node) {
// this should not happen
throw new Error(`vapor fragment anchor previous node was not found.`)
}
}
if (node && isComment(node, ']')) {
// fragment backward search
if (node.$fs) {
// already cached matching fragment start
node = node.$fs
let nextNode: Node | null = null
while (node) {
const isFragStart = isComment(node, '[')
if (isFragStart)
nextNode = locateEndAnchor(node as Anchor, '[', ']')!.nextSibling
if (
isNonHydrationNode(node) ||
// don't skip fragment start for vnode hydration
(!inVNodeHydration && isFragStart)
) {
node = node.nextSibling!
} 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++
}
}
}
break
}
}
if (insertionParent && node) {
const prev = node.previousSibling
if (prev) hydrationPositionMap.set(insertionParent, prev)
const next = nextNode || node.nextSibling
if (next) nextHydrationNodeMap.set(insertionParent, next)
}
}
@ -171,24 +160,28 @@ function locateHydrationNodeImpl(hasFragmentAnchor?: boolean) {
}
export function locateEndAnchor(
node: Node | null,
node: Anchor,
open = '[',
close = ']',
): Node | null {
let match = 0
while (node) {
node = node.nextSibling
if (node && node.nodeType === 8) {
if ((node as Comment).data === open) match++
if ((node as Comment).data === close) {
if (match === 0) {
return node
} else {
match--
}
// already cached matching end
if (node.$fe) {
return node.$fe
}
const stack: Anchor[] = [node]
while ((node = node.nextSibling as Anchor) && stack.length > 0) {
if (node.nodeType === 8) {
if (node.data === open) {
stack.push(node)
} else if (node.data === close) {
const matchingOpen = stack.pop()!
matchingOpen.$fe = node
if (stack.length === 0) return node
}
}
}
return null
}

View File

@ -6,13 +6,16 @@ let t: HTMLTemplateElement
/*! #__NO_SIDE_EFFECTS__ */
export function template(html: string, root?: boolean) {
let node: Node
return (): Node & { $root?: true } => {
return (n?: number): Node & { $root?: true } => {
if (isHydrating) {
if (__DEV__ && !currentHydrationNode) {
// TODO this should not happen
throw new Error('No current hydration node')
}
return adoptTemplate(currentHydrationNode!, html)!
node = adoptTemplate(currentHydrationNode!, html)!
// dynamic node position, default is 0
;(node as any).$dp = n || 0
return node
}
// fast path for text nodes
if (html[0] !== '<') {

View File

@ -60,7 +60,7 @@ export class DynamicFragment extends VaporFragment {
constructor(anchorLabel?: string) {
super([])
if (isHydrating) {
locateHydrationNode(true)
locateHydrationNode()
this.hydrate(anchorLabel!)
} else {
this.anchor =

View File

@ -1,4 +1,16 @@
export let insertionParent: ParentNode | undefined
export let insertionParent:
| (ParentNode & {
// dynamic node position - hydration only
// indicates the position where dynamic nodes begin within the parent
// during hydration, static nodes before this index are skipped
//
// Example:
// const t0 = _template("<div><span></span><span></span></div>", true)
// const n4 = t0(2) // n4.$dp = 2
// The first 2 nodes are static, dynamic nodes start from index 2
$dp?: number
})
| undefined
export let insertionAnchor: Node | 0 | undefined
/**

View File

@ -55,6 +55,9 @@ import {
currentHydrationNode,
isHydrating,
locateHydrationNode,
setCurrentHydrationNode,
setInVNodeHydration,
unsetInVNodeHydration,
hydrateNode as vaporHydrateNode,
} from './dom/hydration'
import { DynamicFragment, VaporFragment, isFragment } from './fragment'
@ -170,7 +173,11 @@ const vaporInteropImpl: Omit<
setVaporTransitionHooks(component as any, hooks as VaporTransitionHooks)
},
hydrate: vaporHydrateNode,
hydrate(node: Node, fn: () => void) {
setInVNodeHydration()
vaporHydrateNode(node, fn)
unsetInVNodeHydration()
},
}
const vaporSlotPropsProxyHandler: ProxyHandler<
@ -261,17 +268,7 @@ function createVDOMComponent(
if (transition) setVNodeTransitionHooks(vnode, transition)
if (isHydrating) {
;(
vdomHydrateNode ||
(vdomHydrateNode = ensureHydrationRenderer().hydrateNode!)
)(
currentHydrationNode!,
vnode,
parentInstance as any,
null,
null,
false,
)
hydrateVNode(vnode, parentInstance as any)
} else {
internals.mt(
vnode,
@ -344,42 +341,33 @@ function renderVDOMSlot(
ensureVaporSlotFallback(children, fallback as any)
isValidSlot = children.length > 0
}
if (isValidSlot) {
if (isHydrating) {
locateHydrationNode(true)
;(
vdomHydrateNode ||
(vdomHydrateNode = ensureHydrationRenderer().hydrateNode!)
)(
currentHydrationNode!,
hydrateVNode(vnode!, parentComponent as any)
} else {
if (fallbackNodes) {
remove(fallbackNodes, parentNode)
fallbackNodes = undefined
}
internals.p(
oldVNode,
vnode!,
parentNode,
anchor,
parentComponent as any,
null,
undefined,
null,
false,
)
} else if (fallbackNodes) {
remove(fallbackNodes, parentNode)
fallbackNodes = undefined
}
internals.p(
oldVNode,
vnode!,
parentNode,
anchor,
parentComponent as any,
null,
undefined,
null,
false,
)
oldVNode = vnode!
} else {
// for forwarded slot without its own fallback, use the fallback
// provided by the slot outlet.
// re-fetch `frag.fallback` as it may have been updated at `createSlot`
fallback = frag.fallback
if (fallback && !fallbackNodes) {
// mount fallback
if (oldVNode) {
@ -451,7 +439,12 @@ const createFallback =
const frag = new VaporFragment([])
frag.insert = (parentNode, anchor) => {
fallbackNodes.forEach(vnode => {
internals.p(null, vnode, parentNode, anchor, parentComponent)
// hydrate fallback
if (isHydrating) {
hydrateVNode(vnode, parentComponent as any)
} else {
internals.p(null, vnode, parentNode, anchor, parentComponent)
}
})
}
frag.remove = parentNode => {
@ -465,3 +458,24 @@ const createFallback =
// vapor slot
return fallbackNodes as Block
}
function hydrateVNode(
vnode: VNode,
parentComponent: ComponentInternalInstance | null,
) {
// keep fragment start anchor, hydrateNode uses it to
// determine if node is a fragmentStart
setInVNodeHydration()
locateHydrationNode()
if (!vdomHydrateNode) vdomHydrateNode = ensureHydrationRenderer().hydrateNode!
const nextNode = vdomHydrateNode(
currentHydrationNode!,
vnode,
parentComponent,
null,
null,
false,
)
unsetInVNodeHydration()
setCurrentHydrationNode(nextNode)
}