This commit is contained in:
edison 2025-07-01 08:51:46 +00:00 committed by GitHub
commit 8bb02a4dc1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
41 changed files with 3454 additions and 379 deletions

View File

@ -39,6 +39,7 @@ describe('ssr: components', () => {
return function ssrRender(_ctx, _push, _parent, _attrs) {
_ssrRenderVNode(_push, _createVNode(_resolveDynamicComponent("foo"), _mergeProps({ prop: "b" }, _attrs), null), _parent)
_push(\`<!--dynamic-component-->\`)
}"
`)
@ -49,6 +50,7 @@ describe('ssr: components', () => {
return function ssrRender(_ctx, _push, _parent, _attrs) {
_ssrRenderVNode(_push, _createVNode(_resolveDynamicComponent(_ctx.foo), _mergeProps({ prop: "b" }, _attrs), null), _parent)
_push(\`<!--dynamic-component-->\`)
}"
`)
})
@ -244,7 +246,8 @@ describe('ssr: components', () => {
_ssrRenderList(list, (i) => {
_push(\`<span\${_scopeId}></span>\`)
})
_push(\`<!--]--></div>\`)
_push(\`<!--]--><!--for--></div>\`)
_push(\`<!--if-->\`)
} else {
_push(\`<!---->\`)
}
@ -267,7 +270,8 @@ describe('ssr: components', () => {
_ssrRenderList(_ctx.list, (i) => {
_push(\`<span\${_scopeId}></span>\`)
})
_push(\`<!--]--></div>\`)
_push(\`<!--]--><!--for--></div>\`)
_push(\`<!--if-->\`)
} else {
_push(\`<!---->\`)
}
@ -361,6 +365,7 @@ describe('ssr: components', () => {
_push(\`\`)
if (false) {
_push(\`<div\${_scopeId}></div>\`)
_push(\`<!--if-->\`)
} else {
_push(\`<!---->\`)
}

View File

@ -396,4 +396,50 @@ describe('ssr: element', () => {
`)
})
})
describe('dynamic anchor', () => {
test('two consecutive components', () => {
expect(
getCompiledString(`
<div>
<div/>
<Comp1/>
<Comp2/>
<div/>
</div>
`),
).toMatchInlineSnapshot(`
"\`<div><div></div>\`)
_push(_ssrRenderComponent(_component_Comp1, null, null, _parent))
_push(\`<!--[[-->\`)
_push(_ssrRenderComponent(_component_Comp2, null, null, _parent))
_push(\`<!--]]--><div></div></div>\`"
`)
})
test('multiple consecutive components', () => {
expect(
getCompiledString(`
<div>
<div/>
<Comp1/>
<Comp2/>
<Comp3/>
<Comp4/>
<div/>
</div>
`),
).toMatchInlineSnapshot(`
"\`<div><div></div>\`)
_push(_ssrRenderComponent(_component_Comp1, null, null, _parent))
_push(\`<!--[[-->\`)
_push(_ssrRenderComponent(_component_Comp2, null, null, _parent))
_push(\`<!--]]--><!--[[-->\`)
_push(_ssrRenderComponent(_component_Comp3, null, null, _parent))
_push(\`<!--]]-->\`)
_push(_ssrRenderComponent(_component_Comp4, null, null, _parent))
_push(\`<div></div></div>\`"
`)
})
})
})

View File

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

View File

@ -15,7 +15,7 @@ describe('transition-group', () => {
_ssrRenderList(_ctx.list, (i) => {
_push(\`<div></div>\`)
})
_push(\`<!--]-->\`)
_push(\`<!--for--><!--]-->\`)
}"
`)
})
@ -33,7 +33,7 @@ describe('transition-group', () => {
_ssrRenderList(_ctx.list, (i) => {
_push(\`<div></div>\`)
})
_push(\`</ul>\`)
_push(\`<!--for--></ul>\`)
}"
`)
})
@ -52,8 +52,10 @@ describe('transition-group', () => {
_ssrRenderList(_ctx.list, (i) => {
_push(\`<div></div>\`)
})
_push(\`<!--for-->\`)
if (false) {
_push(\`<div></div>\`)
_push(\`<!--if-->\`)
}
_push(\`</ul>\`)
}"
@ -74,7 +76,7 @@ describe('transition-group', () => {
_ssrRenderList(_ctx.list, (i) => {
_push(\`<div></div>\`)
})
_push(\`</ul>\`)
_push(\`<!--for--></ul>\`)
}"
`)
})
@ -96,7 +98,7 @@ describe('transition-group', () => {
_ssrRenderList(_ctx.list, (i) => {
_push(\`<div></div>\`)
})
_push(\`</\${_ctx.someTag}>\`)
_push(\`<!--for--></\${_ctx.someTag}>\`)
}"
`)
})
@ -118,11 +120,14 @@ describe('transition-group', () => {
_ssrRenderList(10, (i) => {
_push(\`<div></div>\`)
})
_push(\`<!--for-->\`)
_ssrRenderList(10, (i) => {
_push(\`<div></div>\`)
})
_push(\`<!--for-->\`)
if (_ctx.ok) {
_push(\`<div>ok</div>\`)
_push(\`<!--if-->\`)
}
_push(\`<!--]-->\`)
}"

View File

@ -10,7 +10,7 @@ describe('ssr: v-for', () => {
_ssrRenderList(_ctx.list, (i) => {
_push(\`<div></div>\`)
})
_push(\`<!--]-->\`)
_push(\`<!--]--><!--for-->\`)
}"
`)
})
@ -25,7 +25,7 @@ describe('ssr: v-for', () => {
_ssrRenderList(_ctx.list, (i) => {
_push(\`<div>foo<span>bar</span></div>\`)
})
_push(\`<!--]-->\`)
_push(\`<!--]--><!--for-->\`)
}"
`)
})
@ -51,9 +51,9 @@ describe('ssr: v-for', () => {
_ssrInterpolate(j)
}</div>\`)
})
_push(\`<!--]--></div>\`)
_push(\`<!--]--><!--for--></div>\`)
})
_push(\`<!--]-->\`)
_push(\`<!--]--><!--for-->\`)
}"
`)
})
@ -68,7 +68,7 @@ describe('ssr: v-for', () => {
_ssrRenderList(_ctx.list, (i) => {
_push(\`<!--[-->\${_ssrInterpolate(i)}<!--]-->\`)
})
_push(\`<!--]-->\`)
_push(\`<!--]--><!--for-->\`)
}"
`)
})
@ -85,7 +85,7 @@ describe('ssr: v-for', () => {
_ssrRenderList(_ctx.list, (i) => {
_push(\`<span>\${_ssrInterpolate(i)}</span>\`)
})
_push(\`<!--]-->\`)
_push(\`<!--]--><!--for-->\`)
}"
`)
})
@ -107,7 +107,7 @@ describe('ssr: v-for', () => {
_ssrInterpolate(i + 1)
}</span><!--]-->\`)
})
_push(\`<!--]-->\`)
_push(\`<!--]--><!--for-->\`)
}"
`)
})
@ -127,7 +127,7 @@ describe('ssr: v-for', () => {
_ssrRenderList(_ctx.list, ({ foo }, index) => {
_push(\`<div>\${_ssrInterpolate(foo + _ctx.bar + index)}</div>\`)
})
_push(\`<!--]-->\`)
_push(\`<!--]--><!--for-->\`)
}"
`)
})

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(\`<!--if-->\`)
} 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(\`<!--if-->\`)
} 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(\`<!--if-->\`)
} 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(\`<!--if-->\`)
} else if (_ctx.bar) {
_push(\`<span\${_ssrRenderAttrs(_attrs)}></span>\`)
_push(\`<!--if-->\`)
} 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(\`<!--if-->\`)
} else if (_ctx.bar) {
_push(\`<span\${_ssrRenderAttrs(_attrs)}></span>\`)
_push(\`<!--if-->\`)
} else {
_push(\`<p\${_ssrRenderAttrs(_attrs)}></p>\`)
}
@ -82,15 +89,16 @@ describe('ssr: v-if', () => {
test('<template v-if> (text)', () => {
expect(compile(`<template v-if="foo">hello</template>`).code)
.toMatchInlineSnapshot(`
"
return function ssrRender(_ctx, _push, _parent, _attrs) {
if (_ctx.foo) {
_push(\`<!--[-->hello<!--]-->\`)
} else {
_push(\`<!---->\`)
}
}"
`)
"
return function ssrRender(_ctx, _push, _parent, _attrs) {
if (_ctx.foo) {
_push(\`<!--[-->hello<!--]-->\`)
_push(\`<!--if-->\`)
} else {
_push(\`<!---->\`)
}
}"
`)
})
test('<template v-if> (single element)', () => {
@ -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(\`<!--if-->\`)
} 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(\`<!--if-->\`)
} else {
_push(\`<!---->\`)
}
@ -137,7 +147,8 @@ describe('ssr: v-if', () => {
_ssrRenderList(_ctx.list, (i) => {
_push(\`<div></div>\`)
})
_push(\`<!--]-->\`)
_push(\`<!--]--><!--for-->\`)
_push(\`<!--if-->\`)
} 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(\`<!--if-->\`)
} else {
_push(\`<div\${_ssrRenderAttrs(_attrs)}></div>\`)
}

View File

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

View File

@ -7,6 +7,7 @@ import {
type IfStatement,
type JSChildNode,
NodeTypes,
type PlainElementNode,
type RootNode,
type TemplateChildNode,
type TemplateLiteral,
@ -20,7 +21,12 @@ import {
isText,
processExpression,
} from '@vue/compiler-dom'
import { escapeHtml, isString } from '@vue/shared'
import {
DYNAMIC_END_ANCHOR_LABEL,
DYNAMIC_START_ANCHOR_LABEL,
escapeHtml,
isString,
} from '@vue/shared'
import { SSR_INTERPOLATE, ssrHelpers } from './runtimeHelpers'
import { ssrProcessIf } from './transforms/ssrVIf'
import { ssrProcessFor } from './transforms/ssrVFor'
@ -157,13 +163,33 @@ export function processChildren(
asFragment = false,
disableNestedFragments = false,
disableComment = false,
asDynamic = false,
): void {
if (asDynamic) {
context.pushStringPart(`<!--${DYNAMIC_START_ANCHOR_LABEL}-->`)
}
if (asFragment) {
context.pushStringPart(`<!--[-->`)
}
const { children } = parent
const { children, type, tagType } = parent as PlainElementNode
const inElement =
type === NodeTypes.ELEMENT && tagType === ElementTypes.ELEMENT
if (inElement) processChildrenDynamicInfo(children)
for (let i = 0; i < children.length; i++) {
const child = children[i]
if (inElement && shouldProcessChildAsDynamic(parent, child)) {
processChildren(
{ children: [child] },
context,
asFragment,
disableNestedFragments,
disableComment,
true,
)
continue
}
switch (child.type) {
case NodeTypes.ELEMENT:
switch (child.tagType) {
@ -237,6 +263,9 @@ export function processChildren(
if (asFragment) {
context.pushStringPart(`<!--]-->`)
}
if (asDynamic) {
context.pushStringPart(`<!--${DYNAMIC_END_ANCHOR_LABEL}-->`)
}
}
export function processChildrenAsStatement(
@ -249,3 +278,147 @@ export function processChildrenAsStatement(
processChildren(parent, childContext, asFragment)
return createBlockStatement(childContext.body)
}
const isStaticChildNode = (c: TemplateChildNode): boolean =>
(c.type === NodeTypes.ELEMENT && c.tagType !== ElementTypes.COMPONENT) ||
c.type === NodeTypes.TEXT ||
c.type === NodeTypes.COMMENT
interface DynamicInfo {
hasStaticPrevious: boolean
hasStaticNext: boolean
prevDynamicCount: number
nextDynamicCount: number
}
function processChildrenDynamicInfo(
children: (TemplateChildNode & { _ssrDynamicInfo?: DynamicInfo })[],
): void {
const filteredChildren = children.filter(
child => !(child.type === NodeTypes.TEXT && !child.content.trim()),
)
for (let i = 0; i < filteredChildren.length; i++) {
const child = filteredChildren[i]
if (
isStaticChildNode(child) ||
// fragment has it's own anchor, which can be used to distinguish the boundary
isFragmentChild(child)
) {
continue
}
child._ssrDynamicInfo = {
hasStaticPrevious: false,
hasStaticNext: false,
prevDynamicCount: 0,
nextDynamicCount: 0,
}
const info = child._ssrDynamicInfo
// Calculate the previous static and dynamic node counts
let foundStaticPrev = false
let dynamicCountPrev = 0
for (let j = i - 1; j >= 0; j--) {
const prevChild = filteredChildren[j]
if (isStaticChildNode(prevChild)) {
foundStaticPrev = true
break
}
// if the previous child has dynamic info, use it
else if (prevChild._ssrDynamicInfo) {
foundStaticPrev = prevChild._ssrDynamicInfo.hasStaticPrevious
dynamicCountPrev = prevChild._ssrDynamicInfo.prevDynamicCount + 1
break
}
dynamicCountPrev++
}
info.hasStaticPrevious = foundStaticPrev
info.prevDynamicCount = dynamicCountPrev
// Calculate the number of static and dynamic nodes afterwards
let foundStaticNext = false
let dynamicCountNext = 0
for (let j = i + 1; j < filteredChildren.length; j++) {
const nextChild = filteredChildren[j]
if (isStaticChildNode(nextChild)) {
foundStaticNext = true
break
}
// if the next child has dynamic info, use it
else if (nextChild._ssrDynamicInfo) {
foundStaticNext = nextChild._ssrDynamicInfo.hasStaticNext
dynamicCountNext = nextChild._ssrDynamicInfo.nextDynamicCount + 1
break
}
dynamicCountNext++
}
info.hasStaticNext = foundStaticNext
info.nextDynamicCount = dynamicCountNext
}
}
/**
* Check if a node should be processed as dynamic child.
* This is primarily used in Vapor mode hydration to wrap dynamic parts
* with markers (`<!--[[-->` and `<!--]]-->`).
* The purpose is to distinguish the boundaries of nodes during vapor hydration
*
* 1. two consecutive dynamic nodes should only wrap the second one
* <element>
* <element/> // Static node
* <Comp/> // Dynamic node -> should NOT be wrapped
* <Comp/> // Dynamic node -> should be wrapped
* <element/> // Static node
* </element>
*
* 2. three or more consecutive dynamic nodes should only wrap the
* middle nodes, leaving the first and last static.
* <element>
* <element/> // Static node
* <Comp/> // Dynamic node -> should NOT be wrapped
* <Comp/> // Dynamic node -> should be wrapped
* <Comp/> // Dynamic node -> should be wrapped
* <Comp/> // Dynamic node -> should NOT be wrapped
* <element/> // Static node
* </element>
*/
function shouldProcessChildAsDynamic(
parent: { tag?: string; children: TemplateChildNode[] },
node: TemplateChildNode & { _ssrDynamicInfo?: DynamicInfo },
): boolean {
// must be inside a parent element
if (!parent.tag) return false
// must has dynamic info
const { _ssrDynamicInfo: info } = node
if (!info) return false
const {
hasStaticPrevious,
hasStaticNext,
prevDynamicCount,
nextDynamicCount,
} = info
// must have static nodes on both sides
if (!hasStaticPrevious || !hasStaticNext) return false
const dynamicNodeCount = 1 + prevDynamicCount + nextDynamicCount
// For two consecutive dynamic nodes, mark the second one as dynamic
if (dynamicNodeCount === 2) {
return prevDynamicCount > 0
}
// For three or more dynamic nodes, mark the middle nodes as dynamic
else if (dynamicNodeCount >= 3) {
return prevDynamicCount > 0 && nextDynamicCount > 0
}
return false
}
function isFragmentChild(child: TemplateChildNode): boolean {
const { type } = child
return type === NodeTypes.IF || type === NodeTypes.FOR
}

View File

@ -55,7 +55,14 @@ import {
ssrProcessTransitionGroup,
ssrTransformTransitionGroup,
} from './ssrTransformTransitionGroup'
import { extend, isArray, isObject, isPlainObject, isSymbol } from '@vue/shared'
import {
DYNAMIC_COMPONENT_ANCHOR_LABEL,
extend,
isArray,
isObject,
isPlainObject,
isSymbol,
} from '@vue/shared'
import { buildSSRProps } from './ssrTransformElement'
import {
ssrProcessTransition,
@ -264,6 +271,8 @@ export function ssrProcessComponent(
// dynamic component (`resolveDynamicComponent` call)
// the codegen node is a `renderVNode` call
context.pushStatement(node.ssrCodegenNode)
// anchor for dynamic component for vapor hydration
context.pushStringPart(`<!--${DYNAMIC_COMPONENT_ANCHOR_LABEL}-->`)
}
}
}

View File

@ -13,6 +13,7 @@ import {
processChildrenAsStatement,
} from '../ssrCodegenTransform'
import { SSR_RENDER_LIST } from '../runtimeHelpers'
import { FOR_ANCHOR_LABEL } from '@vue/shared'
// Plugin for the first transform pass, which simply constructs the AST node
export const ssrTransformFor: NodeTransform =
@ -49,4 +50,6 @@ export function ssrProcessFor(
if (!disableNestedFragments) {
context.pushStringPart(`<!--]-->`)
}
// v-for anchor for vapor hydration
context.pushStringPart(`<!--${FOR_ANCHOR_LABEL}-->`)
}

View File

@ -14,6 +14,7 @@ import {
type SSRTransformContext,
processChildrenAsStatement,
} from '../ssrCodegenTransform'
import { IF_ANCHOR_LABEL } from '@vue/shared'
// Plugin for the first transform pass, which simply constructs the AST node
export const ssrTransformIf: NodeTransform = createStructuralDirectiveTransform(
@ -74,5 +75,16 @@ 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) {
// v-if/v-else-if anchor for vapor hydration
statement.body.push(
createCallExpression(`_push`, [`\`<!--${IF_ANCHOR_LABEL}-->\``]),
)
}
return statement
}

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

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

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,22 +69,36 @@ function processDynamicChildren(context: TransformContext<ElementNode>) {
if (!(child.flags & DynamicFlag.NON_TEMPLATE)) {
if (prevDynamics.length) {
if (hasStaticTemplate) {
context.childrenTemplate[index - prevDynamics.length] = `<!>`
prevDynamics[0].flags -= DynamicFlag.NON_TEMPLATE
const anchor = (prevDynamics[0].anchor = context.increaseId())
registerInsertion(prevDynamics, context, anchor)
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++) {
const idx = index - prevDynamics.length + i
context.childrenTemplate[idx] = `<!>`
const dynamicChild = prevDynamics[i]
dynamicChild.flags -= DynamicFlag.NON_TEMPLATE
const anchor = (dynamicChild.anchor = context.increaseId())
if (
dynamicChild.operation &&
isBlockOperation(dynamicChild.operation)
) {
// block types
dynamicChild.operation.parent = context.reference()
dynamicChild.operation.anchor = anchor
}
}
} else {
registerInsertion(prevDynamics, context, -1 /* prepend */)
}
prevDynamics = []
}
hasStaticTemplate = true
staticCount++
}
}
if (prevDynamics.length) {
registerInsertion(prevDynamics, context)
context.dynamic.dynamicChildOffset = staticCount
}
}

View File

@ -601,14 +601,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--><!--if--></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--><!--if--></div>',
)
expect(teleportContainer.innerHTML).toBe(
'<!--teleport start anchor--><span>Teleported Comp1</span><!--teleport anchor-->',
@ -617,7 +617,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><!--if--></div>')
expect(teleportContainer.innerHTML).toBe('')
})
@ -660,21 +660,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--><!--if--></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--><!--if--></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><!--if--></div>')
expect(teleportContainer.innerHTML).toBe('')
})
@ -1846,6 +1846,36 @@ describe('SSR hydration', () => {
}
})
describe('dynamic anchor', () => {
test('two 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()
})
test('multiple consecutive components', () => {
const Comp = {
render() {
return createTextVNode('foo')
},
}
const { vnode, container } = mountWithHydration(
`<div><span></span>foo<!--[[-->foo<!--]]-->foo<span></span></div>`,
() => h('div', null, [h('span'), h(Comp), h(Comp), h(Comp), h('span')]),
)
expect(vnode.el).toBe(container.firstChild)
expect(`Hydration children mismatch`).not.toHaveBeenWarned()
})
})
test('hmr reload child wrapped in KeepAlive', async () => {
const id = 'child-reload'
const Child = {

View File

@ -187,6 +187,8 @@ export interface VaporInteropInterface {
unmount(vnode: VNode, doRemove?: boolean): void
move(vnode: VNode, container: any, anchor: any): void
slot(n1: VNode | null, n2: VNode, container: any, anchor: any): void
hydrate(node: Node, fn: () => void): void
hydrateSlot(vnode: VNode, container: any): void
vdomMount: (component: ConcreteComponent, props?: any, slots?: any) => any
vdomUnmount: UnmountComponentFn

View File

@ -5,6 +5,7 @@ import {
Comment as VComment,
type VNode,
type VNodeHook,
VaporSlot,
createTextVNode,
createVNode,
invokeVNodeHook,
@ -31,11 +32,16 @@ import {
isRenderableAttrValue,
isReservedProp,
isString,
isVaporAnchors,
normalizeClass,
normalizeStyle,
stringifyStyle,
} from '@vue/shared'
import { type RendererInternals, needTransition } from './renderer'
import {
type RendererInternals,
getVaporInterface,
needTransition,
} from './renderer'
import { setRef } from './rendererTemplateRef'
import {
type SuspenseBoundary,
@ -111,7 +117,7 @@ export function createHydrationFunctions(
o: {
patchProp,
createText,
nextSibling,
nextSibling: next,
parentNode,
remove,
insert,
@ -119,6 +125,15 @@ export function createHydrationFunctions(
},
} = rendererInternals
function nextSibling(node: Node) {
let n = next(node)
// skip vapor mode specific anchors
if (n && isVaporAnchors(n)) {
n = next(n)
}
return n
}
const hydrate: RootHydrateFunction = (vnode, container) => {
if (!container.hasChildNodes()) {
;(__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) &&
@ -145,6 +160,10 @@ export function createHydrationFunctions(
slotScopeIds: string[] | null,
optimized = false,
): Node | null => {
// skip vapor mode specific anchors
if (isVaporAnchors(node)) {
node = nextSibling(node)!
}
optimized = optimized || !!vnode.dynamicChildren
const isFragmentStart = isComment(node) && node.data === '['
const onMismatch = () =>
@ -258,6 +277,12 @@ export function createHydrationFunctions(
)
}
break
case VaporSlot:
getVaporInterface(parentComponent, vnode).hydrateSlot(
vnode,
parentNode(node)!,
)
break
default:
if (shapeFlag & ShapeFlags.ELEMENT) {
if (
@ -278,10 +303,6 @@ export function createHydrationFunctions(
)
}
} else if (shapeFlag & ShapeFlags.COMPONENT) {
if ((vnode.type as ConcreteComponent).__vapor) {
throw new Error('Vapor component hydration is not supported yet.')
}
// when setting up the render effect, if the initial vnode already
// has .el set, the component will perform hydration instead of mount
// on its sub-tree.
@ -302,15 +323,23 @@ export function createHydrationFunctions(
nextNode = nextSibling(node)
}
mountComponent(
vnode,
container,
null,
parentComponent,
parentSuspense,
getContainerType(container),
optimized,
)
// hydrate vapor component
if ((vnode.type as ConcreteComponent).__vapor) {
const vaporInterface = getVaporInterface(parentComponent, vnode)
vaporInterface.hydrate(node, () => {
vaporInterface.mount(vnode, container, null, parentComponent)
})
} else {
mountComponent(
vnode,
container,
null,
parentComponent,
parentSuspense,
getContainerType(container),
optimized,
)
}
// #3787
// if component is async, it may get moved / unmounted before its
@ -451,7 +480,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 +582,7 @@ export function createHydrationFunctions(
}
}
return el.nextSibling
return nextSibling(el)
}
const hydrateChildren = (

View File

@ -107,6 +107,7 @@ export interface Renderer<HostElement = RendererElement> {
export interface HydrationRenderer extends Renderer<Element | ShadowRoot> {
hydrate: RootHydrateFunction
hydrateNode: ReturnType<typeof createHydrationFunctions>[1]
}
export type ElementNamespace = 'svg' | 'mathml' | undefined
@ -2546,6 +2547,7 @@ function baseCreateRenderer(
return {
render,
hydrate,
hydrateNode,
internals,
createApp: createAppAPI(
mountApp,
@ -2665,7 +2667,10 @@ export function invalidateMount(hooks: LifecycleHook | undefined): void {
}
}
function getVaporInterface(
/**
* @internal
*/
export function getVaporInterface(
instance: ComponentInternalInstance | null,
vnode: VNode,
): VaporInteropInterface {

View File

@ -319,7 +319,7 @@ export * from './jsx'
/**
* @internal
*/
export { ensureRenderer, normalizeContainer }
export { ensureRenderer, ensureHydrationRenderer, normalizeContainer }
/**
* @internal
*/

File diff suppressed because it is too large Load Diff

View File

@ -9,7 +9,8 @@ import {
insertionParent,
resetInsertionState,
} from './insertionState'
import { isHydrating, locateHydrationNode } from './dom/hydration'
import { DYNAMIC_COMPONENT_ANCHOR_LABEL } from '@vue/shared'
import { isHydrating } from './dom/hydration'
export function createDynamicComponent(
getter: () => any,
@ -19,15 +20,12 @@ export function createDynamicComponent(
): VaporFragment {
const _insertionParent = insertionParent
const _insertionAnchor = insertionAnchor
if (isHydrating) {
locateHydrationNode()
} else {
resetInsertionState()
}
if (!isHydrating) resetInsertionState()
const frag = __DEV__
? new DynamicFragment('dynamic-component')
: new DynamicFragment()
const frag =
isHydrating || __DEV__
? new DynamicFragment(DYNAMIC_COMPONENT_ANCHOR_LABEL)
: new DynamicFragment()
renderEffect(() => {
const value = getter()
@ -46,6 +44,5 @@ export function createDynamicComponent(
if (!isHydrating && _insertionParent) {
insert(frag, _insertionParent, _insertionAnchor)
}
return frag
}

View File

@ -11,7 +11,13 @@ import {
toReactive,
toReadonly,
} from '@vue/reactivity'
import { getSequence, isArray, isObject, isString } from '@vue/shared'
import {
FOR_ANCHOR_LABEL,
getSequence,
isArray,
isObject,
isString,
} from '@vue/shared'
import { createComment, createTextNode } from './dom/node'
import {
type Block,
@ -24,7 +30,12 @@ import { currentInstance, isVaporComponent } from './component'
import type { DynamicSlot } from './componentSlots'
import { renderEffect } from './renderEffect'
import { VaporVForFlags } from '../../shared/src/vaporFlags'
import { isHydrating, locateHydrationNode } from './dom/hydration'
import {
currentHydrationNode,
isHydrating,
locateHydrationNode,
locateVaporFragmentAnchor,
} from './dom/hydration'
import {
insertionAnchor,
insertionParent,
@ -87,8 +98,20 @@ export const createFor = (
let oldBlocks: ForBlock[] = []
let newBlocks: ForBlock[]
let parent: ParentNode | undefined | null
// TODO handle this in hydration
const parentAnchor = __DEV__ ? createComment('for') : createTextNode()
let parentAnchor: Node
if (isHydrating) {
parentAnchor = locateVaporFragmentAnchor(
currentHydrationNode!,
FOR_ANCHOR_LABEL,
)!
if (__DEV__ && !parentAnchor) {
// this should not happen
throw new Error(`v-for fragment anchor node was not found.`)
}
} else {
parentAnchor = __DEV__ ? createComment('for') : createTextNode()
}
const frag = new VaporFragment(oldBlocks)
const instance = currentInstance!
const canUseFastRemove = flags & VaporVForFlags.FAST_REMOVE

View File

@ -1,5 +1,6 @@
import { IF_ANCHOR_LABEL } from '@vue/shared'
import { type Block, type BlockFn, DynamicFragment, insert } from './block'
import { isHydrating, locateHydrationNode } from './dom/hydration'
import { isHydrating } from './dom/hydration'
import {
insertionAnchor,
insertionParent,
@ -15,17 +16,16 @@ export function createIf(
): Block {
const _insertionParent = insertionParent
const _insertionAnchor = insertionAnchor
if (isHydrating) {
locateHydrationNode()
} else {
resetInsertionState()
}
if (!isHydrating) resetInsertionState()
let frag: Block
if (once) {
frag = condition() ? b1() : b2 ? b2() : []
} else {
frag = __DEV__ ? new DynamicFragment('if') : new DynamicFragment()
frag =
isHydrating || __DEV__
? new DynamicFragment(IF_ANCHOR_LABEL)
: new DynamicFragment()
renderEffect(() => (frag as DynamicFragment).update(condition() ? b1 : b2))
}

View File

@ -7,7 +7,13 @@ import {
} from './component'
import { createComment, createTextNode } from './dom/node'
import { EffectScope, pauseTracking, resetTracking } from '@vue/reactivity'
import { isHydrating } from './dom/hydration'
import {
currentHydrationNode,
isComment,
isHydrating,
locateHydrationNode,
locateVaporFragmentAnchor,
} from './dom/hydration'
export type Block =
| Node
@ -30,15 +36,20 @@ export class VaporFragment {
}
export class DynamicFragment extends VaporFragment {
anchor: Node
anchor!: Node
scope: EffectScope | undefined
current?: BlockFn
fallback?: BlockFn
constructor(anchorLabel?: string) {
super([])
this.anchor =
__DEV__ && anchorLabel ? createComment(anchorLabel) : createTextNode()
if (isHydrating) {
locateHydrationNode()
this.hydrate(anchorLabel!)
} else {
this.anchor =
__DEV__ && anchorLabel ? createComment(anchorLabel) : createTextNode()
}
}
update(render?: BlockFn, key: any = render): void {
@ -75,6 +86,22 @@ export class DynamicFragment extends VaporFragment {
resetTracking()
}
hydrate(label: string): void {
// for `v-if="false"` the node will be an empty comment, use it as the anchor.
// otherwise, find next sibling vapor fragment anchor
if (isComment(currentHydrationNode!, '')) {
this.anchor = currentHydrationNode
} else {
const anchor = locateVaporFragmentAnchor(currentHydrationNode!, label)!
if (anchor) {
this.anchor = anchor
} else if (__DEV__) {
// this should not happen
throw new Error(`${label} fragment anchor node was not found.`)
}
}
}
}
export function isFragment(val: NonNullable<unknown>): val is VaporFragment {
@ -126,7 +153,6 @@ export function insert(
} else {
// fragment
if (block.insert) {
// TODO handle hydration for vdom interop
block.insert(parent, anchor)
} else {
insert(block.nodes, parent, anchor)

View File

@ -58,7 +58,13 @@ import {
getSlot,
} from './componentSlots'
import { hmrReload, hmrRerender } from './hmr'
import { isHydrating, locateHydrationNode } from './dom/hydration'
import {
adoptTemplate,
currentHydrationNode,
isHydrating,
locateHydrationNode,
setCurrentHydrationNode,
} from './dom/hydration'
import {
insertionAnchor,
insertionParent,
@ -157,7 +163,9 @@ export function createComponent(
rawProps,
rawSlots,
)
if (!isHydrating && _insertionParent) {
// `frag.insert` handles both hydration and mounting
if (_insertionParent) {
insert(frag, _insertionParent, _insertionAnchor)
}
return frag
@ -486,7 +494,9 @@ export function createComponentWithFallback(
resetInsertionState()
}
const el = document.createElement(comp)
const el = isHydrating
? (adoptTemplate(currentHydrationNode!, `<${comp}/>`) as HTMLElement)
: document.createElement(comp)
// mark single root
;(el as any).$root = isSingleRoot
@ -497,6 +507,7 @@ export function createComponentWithFallback(
}
if (rawSlots) {
isHydrating && setCurrentHydrationNode(el.firstChild)
if (rawSlots.$) {
// TODO dynamic slot fragment
} else {

View File

@ -1,4 +1,11 @@
import { EMPTY_OBJ, NO, hasOwn, isArray, isFunction } from '@vue/shared'
import {
EMPTY_OBJ,
NO,
SLOT_ANCHOR_LABEL,
hasOwn,
isArray,
isFunction,
} from '@vue/shared'
import { type Block, type BlockFn, DynamicFragment, insert } from './block'
import { rawPropsProxyHandlers } from './componentProps'
import { currentInstance, isRef } from '@vue/runtime-dom'
@ -9,7 +16,7 @@ import {
insertionParent,
resetInsertionState,
} from './insertionState'
import { isHydrating, locateHydrationNode } from './dom/hydration'
import { isHydrating } from './dom/hydration'
export type RawSlots = Record<string, VaporSlot> & {
$?: DynamicSlotSource[]
@ -98,11 +105,7 @@ export function createSlot(
): Block {
const _insertionParent = insertionParent
const _insertionAnchor = insertionAnchor
if (isHydrating) {
locateHydrationNode()
} else {
resetInsertionState()
}
if (!isHydrating) resetInsertionState()
const instance = currentInstance as VaporComponentInstance
const rawSlots = instance.rawSlots
@ -111,7 +114,6 @@ export function createSlot(
: EMPTY_OBJ
let fragment: DynamicFragment
if (isRef(rawSlots._)) {
fragment = instance.appContext.vapor!.vdomSlot(
rawSlots._,
@ -121,7 +123,10 @@ export function createSlot(
fallback,
)
} else {
fragment = __DEV__ ? new DynamicFragment('slot') : new DynamicFragment()
fragment =
isHydrating || __DEV__
? new DynamicFragment(SLOT_ANCHOR_LABEL)
: new DynamicFragment()
const isDynamicName = isFunction(name)
const renderSlot = () => {
const slot = getSlot(rawSlots, isFunction(name) ? name() : name)
@ -151,7 +156,12 @@ export function createSlot(
}
}
if (!isHydrating && _insertionParent) {
if (
_insertionParent &&
(!isHydrating ||
// for vdom interop fragment, `fragment.insert` handles both hydration and mounting
fragment.insert)
) {
insert(fragment, _insertionParent, _insertionAnchor)
}

View File

@ -5,7 +5,12 @@ import {
resetInsertionState,
setInsertionState,
} from '../insertionState'
import { child, next } from './node'
import {
_nthChild,
disableHydrationNodeLookup,
enableHydrationNodeLookup,
} from './node'
import { isVaporAnchors } from '@vue/shared'
export let isHydrating = false
export let currentHydrationNode: Node | null = null
@ -16,33 +21,53 @@ export function setCurrentHydrationNode(node: Node | null): void {
let isOptimized = false
export function withHydration(container: ParentNode, fn: () => void): void {
adoptTemplate = adoptTemplateImpl
locateHydrationNode = locateHydrationNodeImpl
function performHydration<T>(
fn: () => T,
setup: () => void,
cleanup: () => void,
): 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()
isHydrating = true
setInsertionState(container, 0)
setup()
const res = fn()
resetInsertionState()
cleanup()
currentHydrationNode = null
isHydrating = false
disableHydrationNodeLookup()
return res
}
export function withHydration(container: ParentNode, fn: () => void): void {
const setup = () => setInsertionState(container, 0)
const cleanup = () => resetInsertionState()
return performHydration(fn, setup, cleanup)
}
export function hydrateNode(node: Node, fn: () => void): void {
const setup = () => (currentHydrationNode = node)
const cleanup = () => {}
return performHydration(fn, setup, cleanup)
}
export let adoptTemplate: (node: Node, template: string) => Node | null
export let locateHydrationNode: () => void
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
}
const isComment = (node: Node, data: string): node is Anchor =>
export const isComment = (node: Node, data: string): node is Anchor =>
node.nodeType === 8 && (node as Comment).data === data
/**
@ -51,7 +76,7 @@ const isComment = (node: Node, data: string): node is Anchor =>
*/
function adoptTemplateImpl(node: Node, template: string): Node | null {
if (!(template[0] === '<' && template[1] === '!')) {
while (node.nodeType === 8) node = next(node)
while (node.nodeType === 8) node = node.nextSibling!
}
if (__DEV__) {
@ -71,51 +96,27 @@ function adoptTemplateImpl(node: Node, template: string): Node | null {
}
}
currentHydrationNode = next(node)
currentHydrationNode = node.nextSibling
return node
}
function locateHydrationNodeImpl() {
let node: Node | null
// prepend / firstChild
if (insertionAnchor === 0) {
node = child(insertionParent!)
} else {
node = insertionParent!.firstChild
} else if (insertionAnchor) {
// `insertionAnchor` is a Node, it is the DOM node to hydrate
// Template: `...<span/><!----><span/>...`// `insertionAnchor` is the placeholder
// SSR Output: `...<span/>Content<span/>...`// `insertionAnchor` is the actual node
node = insertionAnchor
? insertionAnchor.previousSibling
: insertionParent
? insertionParent.lastChild
: currentHydrationNode
} else {
node = 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++
}
}
}
}
// if current hydration node is not under the current parent, or no
// current node, find node by dynamic position or use the first child
if (insertionParent && (!node || node.parentNode !== insertionParent)) {
node = _nthChild(insertionParent, insertionParent.$dp || 0)
}
}
@ -127,3 +128,55 @@ function locateHydrationNodeImpl() {
resetInsertionState()
currentHydrationNode = node
}
export function locateEndAnchor(
node: Anchor,
open = '[',
close = ']',
): Node | null {
// 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
}
export function isNonHydrationNode(node: Node): boolean {
return (
// empty text node
isEmptyTextNode(node) ||
// vdom fragment end anchor (`<!--]-->`)
isComment(node, ']') ||
// vapor mode specific anchors
isVaporAnchors(node)
)
}
export function locateVaporFragmentAnchor(
node: Node,
anchorLabel: string,
): Comment | undefined {
let n = node.nextSibling
while (n) {
if (isComment(n, anchorLabel)) return n
n = n.nextSibling
}
}
export function isEmptyTextNode(node: Node): node is Text {
return node.nodeType === 3 && !(node as Text).data.trim()
}

View File

@ -1,3 +1,10 @@
import { isComment, isNonHydrationNode, locateEndAnchor } from './hydration'
import {
DYNAMIC_END_ANCHOR_LABEL,
DYNAMIC_START_ANCHOR_LABEL,
isVaporAnchors,
} from '@vue/shared'
/*! #__NO_SIDE_EFFECTS__ */
export function createTextNode(value = ''): Text {
return document.createTextNode(value)
@ -14,16 +21,175 @@ export function querySelector(selectors: string): Element | null {
}
/*! #__NO_SIDE_EFFECTS__ */
export function child(node: ParentNode): Node {
export function _child(node: ParentNode): Node {
return node.firstChild!
}
/**
* Hydration-specific version of `child`.
*
* This function skips leading fragment anchors to find the first node relevant
* for hydration matching against the client-side template structure.
*
* Problem:
* Template: `<div><slot />{{ msg }}</div>`
*
* Client Compiled Code (Simplified):
* const n2 = t0() // n2 = `<div> </div>`
* const n1 = _child(n2) // n1 = text node
* // ... slot creation ...
* _renderEffect(() => _setText(n1, _ctx.msg))
*
* SSR Output: `<div><!--[-->slot content<!--]-->Actual Text Node</div>`
*
* Hydration Mismatch:
* - During hydration, `n2` refers to the SSR `<div>`.
* - `_child(n2)` would return `<!--[-->`.
* - The client code expects `n1` to be the text node, but gets the comment.
* The subsequent `_setText(n1, ...)` would fail or target the wrong node.
*
* Solution (`__child`):
* - `__child(n2)` is used during hydration. It skips the SSR fragment anchors
* (`<!--[-->...<!--]-->`) and any other non-content nodes to find the
* "Actual Text Node", correctly matching the client's expectation for `n1`.
*/
/*! #__NO_SIDE_EFFECTS__ */
export function nthChild(node: Node, i: number): Node {
return node.childNodes[i]
export function __child(node: ParentNode): Node {
let n = node.firstChild!
if (isComment(n, '[')) {
n = locateEndAnchor(n)!.nextSibling!
}
while (n && isVaporAnchors(n)) {
n = n.nextSibling!
}
return n
}
/*! #__NO_SIDE_EFFECTS__ */
export function next(node: Node): Node {
export function _nthChild(node: Node, i: number): Node {
return node.childNodes[i]
}
/**
* Hydration-specific version of `nthChild`.
*/
/*! #__NO_SIDE_EFFECTS__ */
export function __nthChild(node: Node, i: number): Node {
let n = node.firstChild!
for (let start = 0; start < i; start++) {
n = __next(n) as ChildNode
}
return n
}
/*! #__NO_SIDE_EFFECTS__ */
function _next(node: Node): Node {
return node.nextSibling!
}
/**
* Hydration-specific version of `next`.
*
* SSR comment anchors (fragments `<!--[-->...<!--]-->`, dynamic `<!--[[-->...<!--]]-->`)
* disrupt standard `node.nextSibling` traversal during hydration. `_next` might
* return a comment node or an internal node of a fragment instead of skipping
* the entire fragment block.
*
* Example:
* Template: `<div>Node1<!>Node2</div>` (where <!> is a dynamic component placeholder)
*
* Client Compiled Code (Simplified):
* const n2 = t0() // n2 = `<div>Node1<!---->Node2</div>`
* const n1 = _next(_child(n2)) // n1 = _next(Node1) returns `<!---->`
* _setInsertionState(n2, n1) // insertion anchor is `<!---->`
* const n0 = _createComponent(_ctx.Comp) // inserted before `<!---->`
*
* SSR Output: `<div>Node1<!--[-->Node3 Node4<!--]-->Node2</div>`
*
* Hydration Mismatch:
* - During hydration, `n2` refers to the SSR `<div>`.
* - `_child(n2)` returns `Node1`.
* - `_next(Node1)` would return `<!--[-->`.
* - The client logic expects `n1` to be the node *after* `Node1` in its structure
* (the placeholder), but gets the fragment start anchor `<!--[-->` from SSR.
* - Using `<!--[-->` as the insertion anchor for hydrating the component is incorrect.
*
* Solution (`__next`):
* - During hydration, `next.impl` is `__next`.
* - `n1 = __next(Node1)` is called.
* - `__next` recognizes that the immediate sibling `<!--[-->` is a fragment start anchor.
* - It skips the entire fragment block (`<!--[-->Node3 Node4<!--]-->`).
* - It returns the node immediately *after* the fragment's end anchor, which is `Node2`.
* - This correctly identifies the logical "next sibling" anchor (`Node2`) in the SSR structure,
* allowing the component to be hydrated correctly relative to `Node1` and `Node2`.
*
* This function ensures traversal correctly skips over non-hydration nodes and
* treats entire fragment/dynamic blocks (when starting *from* their beginning anchor)
* as single logical units to find the next actual sibling node for hydration matching.
*/
/*! #__NO_SIDE_EFFECTS__ */
export function __next(node: Node): Node {
// process dynamic node (<!--[[-->...<!--]]-->) as a single node
if (isComment(node, DYNAMIC_START_ANCHOR_LABEL)) {
node = locateEndAnchor(
node,
DYNAMIC_START_ANCHOR_LABEL,
DYNAMIC_END_ANCHOR_LABEL,
)!
}
// process fragment (<!--[-->...<!--]-->) as a single node
else if (isComment(node, '[')) {
node = locateEndAnchor(node)!
}
let n = node.nextSibling!
while (n && isNonHydrationNode(n)) {
n = n.nextSibling!
}
return n
}
type DelegatedFunction<T extends (...args: any[]) => any> = T & {
impl: T
}
/*! #__NO_SIDE_EFFECTS__ */
export const child: DelegatedFunction<typeof _child> = node => {
return child.impl(node)
}
child.impl = _child
/*! #__NO_SIDE_EFFECTS__ */
export const next: DelegatedFunction<typeof _next> = node => {
return next.impl(node)
}
next.impl = _next
/*! #__NO_SIDE_EFFECTS__ */
export const nthChild: DelegatedFunction<typeof _nthChild> = (node, i) => {
return nthChild.impl(node, i)
}
nthChild.impl = _nthChild
/**
* Enables hydration-specific node lookup behavior.
*
* Temporarily switches the implementations of the exported
* `child`, `next`, and `nthChild` functions to their hydration-specific
* versions (`__child`, `__next`, `__nthChild`). This allows traversal
* logic to correctly handle SSR comment anchors during hydration.
*/
export function enableHydrationNodeLookup(): void {
child.impl = __child
next.impl = __next
nthChild.impl = __nthChild
}
export function disableHydrationNodeLookup(): void {
child.impl = _child
next.impl = _next
nthChild.impl = _nthChild
}

View File

@ -6,13 +6,17 @@ 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
if (root) (node as any).$root = true
return node
}
// fast path for text nodes
if (html[0] !== '<') {

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

@ -2,6 +2,7 @@ import {
type App,
type ComponentInternalInstance,
type ConcreteComponent,
type HydrationRenderer,
MoveType,
type Plugin,
type RendererInternals,
@ -12,6 +13,7 @@ import {
createInternalObject,
createVNode,
currentInstance,
ensureHydrationRenderer,
ensureRenderer,
isEmitListener,
onScopeDispose,
@ -36,6 +38,14 @@ import type { RawSlots, VaporSlot } from './componentSlots'
import { renderEffect } from './renderEffect'
import { createTextNode } from './dom/node'
import { optimizePropertyLookup } from './dom/prop'
import {
currentHydrationNode,
isHydrating,
locateHydrationNode,
locateVaporFragmentAnchor,
setCurrentHydrationNode,
hydrateNode as vaporHydrateNode,
} from './dom/hydration'
// mounting vapor components and slots in vdom
const vaporInteropImpl: Omit<
@ -116,6 +126,18 @@ const vaporInteropImpl: Omit<
insert(vnode.vb || (vnode.component as any), container, anchor)
insert(vnode.anchor as any, container, anchor)
},
hydrate: vaporHydrateNode,
hydrateSlot(vnode, container) {
const { slot } = vnode.vs!
const propsRef = (vnode.vs!.ref = shallowRef(vnode.props))
const slotBlock = slot(new Proxy(propsRef, vaporSlotPropsProxyHandler))
vaporHydrateNode(slotBlock, () => {
const anchor = locateVaporFragmentAnchor(currentHydrationNode!, 'slot')!
vnode.el = vnode.anchor = anchor
insert((vnode.vb = slotBlock), container, anchor)
})
},
}
const vaporSlotPropsProxyHandler: ProxyHandler<
@ -142,6 +164,8 @@ const vaporSlotsProxyHandler: ProxyHandler<any> = {
},
}
let vdomHydrateNode: HydrationRenderer['hydrateNode'] | undefined
/**
* Mount vdom component in vapor
*/
@ -187,16 +211,20 @@ function createVDOMComponent(
}
frag.insert = (parentNode, anchor) => {
if (!isMounted) {
internals.mt(
vnode,
parentNode,
anchor,
parentInstance as any,
null,
undefined,
false,
)
if (!isMounted || isHydrating) {
if (isHydrating) {
hydrateVNode(vnode, parentInstance as any)
} else {
internals.mt(
vnode,
parentNode,
anchor,
parentInstance as any,
null,
undefined,
false,
)
}
onScopeDispose(unmount, true)
isMounted = true
} else {
@ -241,28 +269,32 @@ function renderVDOMSlot(
isFunction(name) ? name() : name,
props,
)
if ((vnode.children as any[]).length) {
if (fallbackNodes) {
remove(fallbackNodes, parentNode)
fallbackNodes = undefined
}
internals.p(
oldVNode,
vnode,
parentNode,
anchor,
parentComponent as any,
)
oldVNode = vnode
if (isHydrating) {
hydrateVNode(vnode!, parentComponent as any)
} else {
if (fallback && !fallbackNodes) {
// mount fallback
if (oldVNode) {
internals.um(oldVNode, parentComponent as any, null, true)
if ((vnode.children as any[]).length) {
if (fallbackNodes) {
remove(fallbackNodes, parentNode)
fallbackNodes = undefined
}
insert((fallbackNodes = fallback(props)), parentNode, anchor)
internals.p(
oldVNode,
vnode,
parentNode,
anchor,
parentComponent as any,
)
oldVNode = vnode
} else {
if (fallback && !fallbackNodes) {
// mount fallback
if (oldVNode) {
internals.um(oldVNode, parentComponent as any, null, true)
}
insert((fallbackNodes = fallback(props)), parentNode, anchor)
}
oldVNode = null
}
oldVNode = null
}
})
isMounted = true
@ -289,6 +321,23 @@ function renderVDOMSlot(
return frag
}
function hydrateVNode(
vnode: VNode,
parentComponent: ComponentInternalInstance | null,
) {
locateHydrationNode()
if (!vdomHydrateNode) vdomHydrateNode = ensureHydrationRenderer().hydrateNode!
const nextNode = vdomHydrateNode(
currentHydrationNode!,
vnode,
parentComponent,
null,
null,
false,
)
setCurrentHydrationNode(nextNode)
}
export const vaporInteropPlugin: Plugin = app => {
const internals = ensureRenderer().internals
app._context.vapor = extend(vaporInteropImpl, {

View File

@ -446,7 +446,7 @@ function testRender(type: string, render: typeof renderToString) {
).toBe(
`<div>parent<div class="child">` +
`<!--[--><span>from slot</span><!--]-->` +
`</div></div>`,
`<!--slot--></div></div>`,
)
// test fallback
@ -461,7 +461,7 @@ function testRender(type: string, render: typeof renderToString) {
}),
),
).toBe(
`<div>parent<div class="child"><!--[-->fallback<!--]--></div></div>`,
`<div>parent<div class="child"><!--[-->fallback<!--]--><!--slot--></div></div>`,
)
})
@ -507,7 +507,7 @@ function testRender(type: string, render: typeof renderToString) {
).toBe(
`<div>parent<div class="child">` +
`<!--[--><span>from slot</span><!--]-->` +
`</div></div>`,
`<!--slot--></div></div>`,
)
})
@ -525,7 +525,7 @@ function testRender(type: string, render: typeof renderToString) {
expect(await render(app)).toBe(
`<div>parent<div class="child">` +
`<!--[--><span>from slot</span><!--]-->` +
`</div></div>`,
`<!--slot--></div></div>`,
)
})
@ -572,7 +572,7 @@ function testRender(type: string, render: typeof renderToString) {
})
expect(await render(app)).toBe(
`<div><!--[--><!--[-->hello<!--]--><!--]--></div>`,
`<div><!--[--><!--[-->hello<!--]--><!--slot--><!--]--><!--slot--></div>`,
)
})
@ -593,7 +593,7 @@ function testRender(type: string, render: typeof renderToString) {
expect(await render(app)).toBe(
// should only have a single fragment
`<div><!--[--><!--]--></div>`,
`<div><!--[--><!--]--><!--slot--></div>`,
)
})
@ -614,7 +614,7 @@ function testRender(type: string, render: typeof renderToString) {
expect(await render(app)).toBe(
// should only have a single fragment
`<div><!--[-->fallback<!--]--></div>`,
`<div><!--[-->fallback<!--]--><!--slot--></div>`,
)
})
})

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><!--if-->`,
)
expect(await renderToString(createApp(Parent, { ok: false }))).toBe(
`<span class="bar"></span>`,

View File

@ -14,7 +14,9 @@ describe('ssr: dynamic component', () => {
template: `<component :is="'one'"><span>slot</span></component>`,
}),
),
).toBe(`<div><!--[--><span>slot</span><!--]--></div>`)
).toBe(
`<div><!--[--><span>slot</span><!--]--><!--slot--></div><!--dynamic-component-->`,
)
})
test('resolved to component with v-show', async () => {
@ -30,7 +32,7 @@ describe('ssr: dynamic component', () => {
}),
),
).toBe(
`<div><!--[--><div style=\"display:none;\"><!--[-->hi<!--]--></div><!--]--></div>`,
`<div><!--[--><div style="display:none;"><!--[-->hi<!--]--></div><!--dynamic-component--><!--]--></div><!--dynamic-component-->`,
)
})
@ -41,7 +43,7 @@ describe('ssr: dynamic component', () => {
template: `<component :is="'p'"><span>slot</span></component>`,
}),
),
).toBe(`<p><span>slot</span></p>`)
).toBe(`<p><span>slot</span></p><!--dynamic-component-->`)
})
test('resolve to component vnode', async () => {
@ -60,7 +62,9 @@ describe('ssr: dynamic component', () => {
template: `<component :is="vnode"><span>slot</span></component>`,
}),
),
).toBe(`<div>test<!--[--><span>slot</span><!--]--></div>`)
).toBe(
`<div>test<!--[--><span>slot</span><!--]--><!--slot--></div><!--dynamic-component-->`,
)
})
test('resolve to element vnode', async () => {
@ -75,6 +79,6 @@ describe('ssr: dynamic component', () => {
template: `<component :is="vnode"><span>slot</span></component>`,
}),
),
).toBe(`<div id="test"><span>slot</span></div>`)
).toBe(`<div id="test"><span>slot</span></div><!--dynamic-component-->`)
})
})

View File

@ -68,7 +68,9 @@ describe('ssr: scopedId runtime behavior', () => {
}
const result = await renderToString(createApp(Comp))
expect(result).toBe(`<!--[--><div parent wrapper-s child></div><!--]-->`)
expect(result).toBe(
`<!--[--><div parent wrapper-s child></div><!--]--><!--slot-->`,
)
})
// #2892
@ -150,8 +152,8 @@ describe('ssr: scopedId runtime behavior', () => {
const result = await renderToString(createApp(Root))
expect(result).toBe(
`<div class="wrapper" root slotted wrapper>` +
`<!--[--><!--[--><div root slotted-s wrapper-s></div><!--]--><!--]-->` +
`</div>`,
`<!--[--><!--[--><div root slotted-s wrapper-s></div><!--]--><!--slot--><!--]-->` +
`<!--slot--></div>`,
)
})
@ -265,8 +267,8 @@ describe('ssr: scopedId runtime behavior', () => {
const result = await renderToString(createApp(Root))
expect(result).toBe(
`<div class="wrapper" root slotted wrapper>` +
`<!--[--><!--[--><div root slotted-s wrapper-s></div><!--]--><!--]-->` +
`</div>`,
`<!--[--><!--[--><div root slotted-s wrapper-s></div><!--]--><!--slot--><!--]-->` +
`<!--slot--></div>`,
)
})
})

View File

@ -16,7 +16,7 @@ describe('ssr: slot', () => {
template: `<one>hello</one>`,
}),
),
).toBe(`<div><!--[-->hello<!--]--></div>`)
).toBe(`<div><!--[-->hello<!--]--><!--slot--></div>`)
})
test('element slot', async () => {
@ -27,7 +27,7 @@ describe('ssr: slot', () => {
template: `<one><div>hi</div></one>`,
}),
),
).toBe(`<div><!--[--><div>hi</div><!--]--></div>`)
).toBe(`<div><!--[--><div>hi</div><!--]--><!--slot--></div>`)
})
test('empty slot', async () => {
@ -42,7 +42,7 @@ describe('ssr: slot', () => {
template: `<one><template v-if="false"/></one>`,
}),
),
).toBe(`<div><!--[--><!--]--></div>`)
).toBe(`<div><!--[--><!--]--><!--slot--></div>`)
})
test('empty slot (manual comments)', async () => {
@ -57,7 +57,7 @@ describe('ssr: slot', () => {
template: `<one><!--hello--></one>`,
}),
),
).toBe(`<div><!--[--><!--]--></div>`)
).toBe(`<div><!--[--><!--]--><!--slot--></div>`)
})
test('empty slot (multi-line comments)', async () => {
@ -72,7 +72,7 @@ describe('ssr: slot', () => {
template: `<one><!--he\nllo--></one>`,
}),
),
).toBe(`<div><!--[--><!--]--></div>`)
).toBe(`<div><!--[--><!--]--><!--slot--></div>`)
})
test('multiple elements', async () => {
@ -83,7 +83,7 @@ describe('ssr: slot', () => {
template: `<one><div>one</div><div>two</div></one>`,
}),
),
).toBe(`<div><!--[--><div>one</div><div>two</div><!--]--></div>`)
).toBe(`<div><!--[--><div>one</div><div>two</div><!--]--><!--slot--></div>`)
})
test('fragment slot (template v-if)', async () => {
@ -94,7 +94,9 @@ describe('ssr: slot', () => {
template: `<one><template v-if="true">hello</template></one>`,
}),
),
).toBe(`<div><!--[--><!--[-->hello<!--]--><!--]--></div>`)
).toBe(
`<div><!--[--><!--[-->hello<!--]--><!--if--><!--]--><!--slot--></div>`,
)
})
test('fragment slot (template v-if + multiple elements)', async () => {
@ -106,7 +108,7 @@ describe('ssr: slot', () => {
}),
),
).toBe(
`<div><!--[--><!--[--><div>one</div><div>two</div><!--]--><!--]--></div>`,
`<div><!--[--><!--[--><div>one</div><div>two</div><!--]--><!--if--><!--]--><!--slot--></div>`,
)
})
@ -135,7 +137,7 @@ describe('ssr: slot', () => {
template: `<one><div v-if="true">foo</div></one>`,
}),
),
).toBe(`<div>foo</div>`)
).toBe(`<div>foo</div><!--if-->`)
})
// #9933
@ -183,7 +185,7 @@ describe('ssr: slot', () => {
`,
}),
),
).toBe(`<div><!--[--> new header <!--]--></div>`)
).toBe(`<div><!--[--> new header <!--]--><!--slot--></div>`)
})
// #11326
@ -202,7 +204,9 @@ describe('ssr: slot', () => {
template: `<ButtonComp><Wrap><div v-if="false">hello</div></Wrap></ButtonComp>`,
}),
),
).toBe(`<button><!--[--><div><!--[--><!--]--></div><!--]--></button>`)
).toBe(
`<button><!--[--><div><!--[--><!--]--><!--slot--></div><!--]--></button><!--dynamic-component-->`,
)
expect(
await renderToString(
@ -219,7 +223,7 @@ describe('ssr: slot', () => {
}),
),
).toBe(
`<button><!--[--><div><!--[--><div>hello</div><!--]--></div><!--]--></button>`,
`<button><!--[--><div><!--[--><div>hello</div><!--]--><!--slot--></div><!--]--></button><!--dynamic-component-->`,
)
expect(
@ -233,6 +237,6 @@ describe('ssr: slot', () => {
template: `<ButtonComp><template v-if="false">hello</template></ButtonComp>`,
}),
),
).toBe(`<button><!--[--><!--]--></button>`)
).toBe(`<button><!--[--><!--]--></button><!--dynamic-component-->`)
})
})

View File

@ -5,7 +5,7 @@ import {
type SSRBufferItem,
renderVNodeChildren,
} from '../render'
import { isArray } from '@vue/shared'
import { SLOT_ANCHOR_LABEL, isArray } from '@vue/shared'
const { ensureValidVNode } = ssrUtils
@ -37,7 +37,7 @@ export function ssrRenderSlot(
parentComponent,
slotScopeId,
)
push(`<!--]-->`)
push(`<!--]--><!--${SLOT_ANCHOR_LABEL}-->`)
}
export function ssrRenderSlotInner(
@ -104,7 +104,7 @@ export function ssrRenderSlotInner(
if (
transition &&
slotBuffer[0] === '<!--[-->' &&
slotBuffer[end - 1] === '<!--]-->'
(slotBuffer[end - 1] as string).startsWith('<!--]-->')
) {
start++
end--

View File

@ -0,0 +1,32 @@
export const DYNAMIC_START_ANCHOR_LABEL = '[['
export const DYNAMIC_END_ANCHOR_LABEL = ']]'
export const IF_ANCHOR_LABEL: string = 'if'
export const DYNAMIC_COMPONENT_ANCHOR_LABEL: string = 'dynamic-component'
export const FOR_ANCHOR_LABEL: string = 'for'
export const SLOT_ANCHOR_LABEL: string = 'slot'
export function isDynamicAnchor(node: Node): node is Comment {
if (node.nodeType !== 8) return false
const data = (node as Comment).data
return (
data === DYNAMIC_START_ANCHOR_LABEL || data === DYNAMIC_END_ANCHOR_LABEL
)
}
export function isVaporFragmentAnchor(node: Node): node is Comment {
if (node.nodeType !== 8) return false
const data = (node as Comment).data
return (
data === IF_ANCHOR_LABEL ||
data === FOR_ANCHOR_LABEL ||
data === SLOT_ANCHOR_LABEL ||
data === DYNAMIC_COMPONENT_ANCHOR_LABEL
)
}
export function isVaporAnchors(node: Node): node is Comment {
return isDynamicAnchor(node) || isVaporFragmentAnchor(node)
}

View File

@ -13,3 +13,4 @@ export * from './looseEqual'
export * from './toDisplayString'
export * from './typeUtils'
export * from './subSequence'
export * from './domAnchors'