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

View File

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

View File

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

View File

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

View File

@ -10,7 +10,7 @@ describe('ssr: v-for', () => {
_ssrRenderList(_ctx.list, (i) => { _ssrRenderList(_ctx.list, (i) => {
_push(\`<div></div>\`) _push(\`<div></div>\`)
}) })
_push(\`<!--]-->\`) _push(\`<!--]--><!--for-->\`)
}" }"
`) `)
}) })
@ -25,7 +25,7 @@ describe('ssr: v-for', () => {
_ssrRenderList(_ctx.list, (i) => { _ssrRenderList(_ctx.list, (i) => {
_push(\`<div>foo<span>bar</span></div>\`) _push(\`<div>foo<span>bar</span></div>\`)
}) })
_push(\`<!--]-->\`) _push(\`<!--]--><!--for-->\`)
}" }"
`) `)
}) })
@ -51,9 +51,9 @@ describe('ssr: v-for', () => {
_ssrInterpolate(j) _ssrInterpolate(j)
}</div>\`) }</div>\`)
}) })
_push(\`<!--]--></div>\`) _push(\`<!--]--><!--for--></div>\`)
}) })
_push(\`<!--]-->\`) _push(\`<!--]--><!--for-->\`)
}" }"
`) `)
}) })
@ -68,7 +68,7 @@ describe('ssr: v-for', () => {
_ssrRenderList(_ctx.list, (i) => { _ssrRenderList(_ctx.list, (i) => {
_push(\`<!--[-->\${_ssrInterpolate(i)}<!--]-->\`) _push(\`<!--[-->\${_ssrInterpolate(i)}<!--]-->\`)
}) })
_push(\`<!--]-->\`) _push(\`<!--]--><!--for-->\`)
}" }"
`) `)
}) })
@ -85,7 +85,7 @@ describe('ssr: v-for', () => {
_ssrRenderList(_ctx.list, (i) => { _ssrRenderList(_ctx.list, (i) => {
_push(\`<span>\${_ssrInterpolate(i)}</span>\`) _push(\`<span>\${_ssrInterpolate(i)}</span>\`)
}) })
_push(\`<!--]-->\`) _push(\`<!--]--><!--for-->\`)
}" }"
`) `)
}) })
@ -107,7 +107,7 @@ describe('ssr: v-for', () => {
_ssrInterpolate(i + 1) _ssrInterpolate(i + 1)
}</span><!--]-->\`) }</span><!--]-->\`)
}) })
_push(\`<!--]-->\`) _push(\`<!--]--><!--for-->\`)
}" }"
`) `)
}) })
@ -127,7 +127,7 @@ describe('ssr: v-for', () => {
_ssrRenderList(_ctx.list, ({ foo }, index) => { _ssrRenderList(_ctx.list, ({ foo }, index) => {
_push(\`<div>\${_ssrInterpolate(foo + _ctx.bar + index)}</div>\`) _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) { return function ssrRender(_ctx, _push, _parent, _attrs) {
if (_ctx.foo) { if (_ctx.foo) {
_push(\`<div\${_ssrRenderAttrs(_attrs)}></div>\`) _push(\`<div\${_ssrRenderAttrs(_attrs)}></div>\`)
_push(\`<!--if-->\`)
} else { } else {
_push(\`<!---->\`) _push(\`<!---->\`)
} }
@ -23,6 +24,7 @@ describe('ssr: v-if', () => {
return function ssrRender(_ctx, _push, _parent, _attrs) { return function ssrRender(_ctx, _push, _parent, _attrs) {
if (_ctx.foo) { if (_ctx.foo) {
_push(\`<div\${_ssrRenderAttrs(_attrs)}>hello<span>ok</span></div>\`) _push(\`<div\${_ssrRenderAttrs(_attrs)}>hello<span>ok</span></div>\`)
_push(\`<!--if-->\`)
} else { } else {
_push(\`<!---->\`) _push(\`<!---->\`)
} }
@ -38,6 +40,7 @@ describe('ssr: v-if', () => {
return function ssrRender(_ctx, _push, _parent, _attrs) { return function ssrRender(_ctx, _push, _parent, _attrs) {
if (_ctx.foo) { if (_ctx.foo) {
_push(\`<div\${_ssrRenderAttrs(_attrs)}></div>\`) _push(\`<div\${_ssrRenderAttrs(_attrs)}></div>\`)
_push(\`<!--if-->\`)
} else { } else {
_push(\`<span\${_ssrRenderAttrs(_attrs)}></span>\`) _push(\`<span\${_ssrRenderAttrs(_attrs)}></span>\`)
} }
@ -53,8 +56,10 @@ describe('ssr: v-if', () => {
return function ssrRender(_ctx, _push, _parent, _attrs) { return function ssrRender(_ctx, _push, _parent, _attrs) {
if (_ctx.foo) { if (_ctx.foo) {
_push(\`<div\${_ssrRenderAttrs(_attrs)}></div>\`) _push(\`<div\${_ssrRenderAttrs(_attrs)}></div>\`)
_push(\`<!--if-->\`)
} else if (_ctx.bar) { } else if (_ctx.bar) {
_push(\`<span\${_ssrRenderAttrs(_attrs)}></span>\`) _push(\`<span\${_ssrRenderAttrs(_attrs)}></span>\`)
_push(\`<!--if-->\`)
} else { } else {
_push(\`<!---->\`) _push(\`<!---->\`)
} }
@ -70,8 +75,10 @@ describe('ssr: v-if', () => {
return function ssrRender(_ctx, _push, _parent, _attrs) { return function ssrRender(_ctx, _push, _parent, _attrs) {
if (_ctx.foo) { if (_ctx.foo) {
_push(\`<div\${_ssrRenderAttrs(_attrs)}></div>\`) _push(\`<div\${_ssrRenderAttrs(_attrs)}></div>\`)
_push(\`<!--if-->\`)
} else if (_ctx.bar) { } else if (_ctx.bar) {
_push(\`<span\${_ssrRenderAttrs(_attrs)}></span>\`) _push(\`<span\${_ssrRenderAttrs(_attrs)}></span>\`)
_push(\`<!--if-->\`)
} else { } else {
_push(\`<p\${_ssrRenderAttrs(_attrs)}></p>\`) _push(\`<p\${_ssrRenderAttrs(_attrs)}></p>\`)
} }
@ -86,6 +93,7 @@ describe('ssr: v-if', () => {
return function ssrRender(_ctx, _push, _parent, _attrs) { return function ssrRender(_ctx, _push, _parent, _attrs) {
if (_ctx.foo) { if (_ctx.foo) {
_push(\`<!--[-->hello<!--]-->\`) _push(\`<!--[-->hello<!--]-->\`)
_push(\`<!--if-->\`)
} else { } else {
_push(\`<!---->\`) _push(\`<!---->\`)
} }
@ -102,6 +110,7 @@ describe('ssr: v-if', () => {
return function ssrRender(_ctx, _push, _parent, _attrs) { return function ssrRender(_ctx, _push, _parent, _attrs) {
if (_ctx.foo) { if (_ctx.foo) {
_push(\`<div\${_ssrRenderAttrs(_attrs)}>hi</div>\`) _push(\`<div\${_ssrRenderAttrs(_attrs)}>hi</div>\`)
_push(\`<!--if-->\`)
} else { } else {
_push(\`<!---->\`) _push(\`<!---->\`)
} }
@ -118,6 +127,7 @@ describe('ssr: v-if', () => {
return function ssrRender(_ctx, _push, _parent, _attrs) { return function ssrRender(_ctx, _push, _parent, _attrs) {
if (_ctx.foo) { if (_ctx.foo) {
_push(\`<!--[--><div>hi</div><div>ho</div><!--]-->\`) _push(\`<!--[--><div>hi</div><div>ho</div><!--]-->\`)
_push(\`<!--if-->\`)
} else { } else {
_push(\`<!---->\`) _push(\`<!---->\`)
} }
@ -137,7 +147,8 @@ describe('ssr: v-if', () => {
_ssrRenderList(_ctx.list, (i) => { _ssrRenderList(_ctx.list, (i) => {
_push(\`<div></div>\`) _push(\`<div></div>\`)
}) })
_push(\`<!--]-->\`) _push(\`<!--]--><!--for-->\`)
_push(\`<!--if-->\`)
} else { } else {
_push(\`<!---->\`) _push(\`<!---->\`)
} }
@ -156,6 +167,7 @@ describe('ssr: v-if', () => {
return function ssrRender(_ctx, _push, _parent, _attrs) { return function ssrRender(_ctx, _push, _parent, _attrs) {
if (_ctx.foo) { if (_ctx.foo) {
_push(\`<!--[--><div>hi</div><div>ho</div><!--]-->\`) _push(\`<!--[--><div>hi</div><div>ho</div><!--]-->\`)
_push(\`<!--if-->\`)
} else { } else {
_push(\`<div\${_ssrRenderAttrs(_attrs)}></div>\`) _push(\`<div\${_ssrRenderAttrs(_attrs)}></div>\`)
} }

View File

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

View File

@ -7,6 +7,7 @@ import {
type IfStatement, type IfStatement,
type JSChildNode, type JSChildNode,
NodeTypes, NodeTypes,
type PlainElementNode,
type RootNode, type RootNode,
type TemplateChildNode, type TemplateChildNode,
type TemplateLiteral, type TemplateLiteral,
@ -20,7 +21,12 @@ import {
isText, isText,
processExpression, processExpression,
} from '@vue/compiler-dom' } 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 { SSR_INTERPOLATE, ssrHelpers } from './runtimeHelpers'
import { ssrProcessIf } from './transforms/ssrVIf' import { ssrProcessIf } from './transforms/ssrVIf'
import { ssrProcessFor } from './transforms/ssrVFor' import { ssrProcessFor } from './transforms/ssrVFor'
@ -157,13 +163,33 @@ export function processChildren(
asFragment = false, asFragment = false,
disableNestedFragments = false, disableNestedFragments = false,
disableComment = false, disableComment = false,
asDynamic = false,
): void { ): void {
if (asDynamic) {
context.pushStringPart(`<!--${DYNAMIC_START_ANCHOR_LABEL}-->`)
}
if (asFragment) { if (asFragment) {
context.pushStringPart(`<!--[-->`) 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++) { for (let i = 0; i < children.length; i++) {
const child = children[i] const child = children[i]
if (inElement && shouldProcessChildAsDynamic(parent, child)) {
processChildren(
{ children: [child] },
context,
asFragment,
disableNestedFragments,
disableComment,
true,
)
continue
}
switch (child.type) { switch (child.type) {
case NodeTypes.ELEMENT: case NodeTypes.ELEMENT:
switch (child.tagType) { switch (child.tagType) {
@ -237,6 +263,9 @@ export function processChildren(
if (asFragment) { if (asFragment) {
context.pushStringPart(`<!--]-->`) context.pushStringPart(`<!--]-->`)
} }
if (asDynamic) {
context.pushStringPart(`<!--${DYNAMIC_END_ANCHOR_LABEL}-->`)
}
} }
export function processChildrenAsStatement( export function processChildrenAsStatement(
@ -249,3 +278,147 @@ export function processChildrenAsStatement(
processChildren(parent, childContext, asFragment) processChildren(parent, childContext, asFragment)
return createBlockStatement(childContext.body) 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, ssrProcessTransitionGroup,
ssrTransformTransitionGroup, ssrTransformTransitionGroup,
} from './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 { buildSSRProps } from './ssrTransformElement'
import { import {
ssrProcessTransition, ssrProcessTransition,
@ -264,6 +271,8 @@ export function ssrProcessComponent(
// dynamic component (`resolveDynamicComponent` call) // dynamic component (`resolveDynamicComponent` call)
// the codegen node is a `renderVNode` call // the codegen node is a `renderVNode` call
context.pushStatement(node.ssrCodegenNode) 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, processChildrenAsStatement,
} from '../ssrCodegenTransform' } from '../ssrCodegenTransform'
import { SSR_RENDER_LIST } from '../runtimeHelpers' 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 // Plugin for the first transform pass, which simply constructs the AST node
export const ssrTransformFor: NodeTransform = export const ssrTransformFor: NodeTransform =
@ -49,4 +50,6 @@ export function ssrProcessFor(
if (!disableNestedFragments) { if (!disableNestedFragments) {
context.pushStringPart(`<!--]-->`) context.pushStringPart(`<!--]-->`)
} }
// v-for anchor for vapor hydration
context.pushStringPart(`<!--${FOR_ANCHOR_LABEL}-->`)
} }

View File

@ -14,6 +14,7 @@ import {
type SSRTransformContext, type SSRTransformContext,
processChildrenAsStatement, processChildrenAsStatement,
} from '../ssrCodegenTransform' } from '../ssrCodegenTransform'
import { IF_ANCHOR_LABEL } from '@vue/shared'
// Plugin for the first transform pass, which simply constructs the AST node // Plugin for the first transform pass, which simply constructs the AST node
export const ssrTransformIf: NodeTransform = createStructuralDirectiveTransform( export const ssrTransformIf: NodeTransform = createStructuralDirectiveTransform(
@ -74,5 +75,16 @@ function processIfBranch(
(children.length !== 1 || children[0].type !== NodeTypes.ELEMENT) && (children.length !== 1 || children[0].type !== NodeTypes.ELEMENT) &&
// optimize away nested fragments when the only child is a ForNode // optimize away nested fragments when the only child is a ForNode
!(children.length === 1 && children[0].type === NodeTypes.FOR) !(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, context: CodegenContext,
): CodeFragment[] { ): CodeFragment[] {
const [frag, push] = buildCodeFragment() const [frag, push] = buildCodeFragment()
const { id, template, operation } = dynamic const { id, template, operation, dynamicChildOffset } = dynamic
if (id !== undefined && template !== undefined) { if (id !== undefined && template !== undefined) {
push(NEWLINE, `const n${id} = t${template}()`) push(NEWLINE, `const n${id} = t${template}(${dynamicChildOffset || ''})`)
push(...genDirectivesForElement(id, context)) push(...genDirectivesForElement(id, context))
} }

View File

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

View File

@ -59,7 +59,7 @@ export const transformChildren: NodeTransform = (node, context) => {
function processDynamicChildren(context: TransformContext<ElementNode>) { function processDynamicChildren(context: TransformContext<ElementNode>) {
let prevDynamics: IRDynamicInfo[] = [] let prevDynamics: IRDynamicInfo[] = []
let hasStaticTemplate = false let staticCount = 0
const children = context.dynamic.children const children = context.dynamic.children
for (const [index, child] of children.entries()) { for (const [index, child] of children.entries()) {
@ -69,22 +69,36 @@ function processDynamicChildren(context: TransformContext<ElementNode>) {
if (!(child.flags & DynamicFlag.NON_TEMPLATE)) { if (!(child.flags & DynamicFlag.NON_TEMPLATE)) {
if (prevDynamics.length) { if (prevDynamics.length) {
if (hasStaticTemplate) { if (staticCount) {
context.childrenTemplate[index - prevDynamics.length] = `<!>` // each dynamic child gets its own placeholder node.
prevDynamics[0].flags -= DynamicFlag.NON_TEMPLATE // this makes it easier to locate the corresponding node during hydration.
const anchor = (prevDynamics[0].anchor = context.increaseId()) for (let i = 0; i < prevDynamics.length; i++) {
registerInsertion(prevDynamics, context, anchor) 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 { } else {
registerInsertion(prevDynamics, context, -1 /* prepend */) registerInsertion(prevDynamics, context, -1 /* prepend */)
} }
prevDynamics = [] prevDynamics = []
} }
hasStaticTemplate = true staticCount++
} }
} }
if (prevDynamics.length) { if (prevDynamics.length) {
registerInsertion(prevDynamics, context) registerInsertion(prevDynamics, context)
context.dynamic.dynamicChildOffset = staticCount
} }
} }

View File

@ -601,14 +601,14 @@ describe('SSR hydration', () => {
const ctx: SSRContext = {} const ctx: SSRContext = {}
container.innerHTML = await renderToString(h(App), ctx) container.innerHTML = await renderToString(h(App), ctx)
expect(container.innerHTML).toBe( expect(container.innerHTML).toBe(
'<div><!--teleport start--><!--teleport end--></div>', '<div><!--teleport start--><!--teleport end--><!--if--></div>',
) )
teleportContainer.innerHTML = ctx.teleports!['#target'] teleportContainer.innerHTML = ctx.teleports!['#target']
// hydrate // hydrate
createSSRApp(App).mount(container) createSSRApp(App).mount(container)
expect(container.innerHTML).toBe( expect(container.innerHTML).toBe(
'<div><!--teleport start--><!--teleport end--></div>', '<div><!--teleport start--><!--teleport end--><!--if--></div>',
) )
expect(teleportContainer.innerHTML).toBe( expect(teleportContainer.innerHTML).toBe(
'<!--teleport start anchor--><span>Teleported Comp1</span><!--teleport anchor-->', '<!--teleport start anchor--><span>Teleported Comp1</span><!--teleport anchor-->',
@ -617,7 +617,7 @@ describe('SSR hydration', () => {
toggle.value = false toggle.value = false
await nextTick() 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('') expect(teleportContainer.innerHTML).toBe('')
}) })
@ -660,21 +660,21 @@ describe('SSR hydration', () => {
// server render // server render
container.innerHTML = await renderToString(h(App)) container.innerHTML = await renderToString(h(App))
expect(container.innerHTML).toBe( expect(container.innerHTML).toBe(
'<div><!--teleport start--><!--teleport end--></div>', '<div><!--teleport start--><!--teleport end--><!--if--></div>',
) )
expect(teleportContainer.innerHTML).toBe('') expect(teleportContainer.innerHTML).toBe('')
// hydrate // hydrate
createSSRApp(App).mount(container) createSSRApp(App).mount(container)
expect(container.innerHTML).toBe( 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(teleportContainer.innerHTML).toBe('<span>Teleported Comp1</span>')
expect(`Hydration children mismatch`).toHaveBeenWarned() expect(`Hydration children mismatch`).toHaveBeenWarned()
toggle.value = false toggle.value = false
await nextTick() 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('') 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 () => { test('hmr reload child wrapped in KeepAlive', async () => {
const id = 'child-reload' const id = 'child-reload'
const Child = { const Child = {

View File

@ -187,6 +187,8 @@ export interface VaporInteropInterface {
unmount(vnode: VNode, doRemove?: boolean): void unmount(vnode: VNode, doRemove?: boolean): void
move(vnode: VNode, container: any, anchor: any): void move(vnode: VNode, container: any, anchor: any): void
slot(n1: VNode | null, n2: 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 vdomMount: (component: ConcreteComponent, props?: any, slots?: any) => any
vdomUnmount: UnmountComponentFn vdomUnmount: UnmountComponentFn

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -11,7 +11,13 @@ import {
toReactive, toReactive,
toReadonly, toReadonly,
} from '@vue/reactivity' } 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 { createComment, createTextNode } from './dom/node'
import { import {
type Block, type Block,
@ -24,7 +30,12 @@ import { currentInstance, isVaporComponent } from './component'
import type { DynamicSlot } from './componentSlots' import type { DynamicSlot } from './componentSlots'
import { renderEffect } from './renderEffect' import { renderEffect } from './renderEffect'
import { VaporVForFlags } from '../../shared/src/vaporFlags' import { VaporVForFlags } from '../../shared/src/vaporFlags'
import { isHydrating, locateHydrationNode } from './dom/hydration' import {
currentHydrationNode,
isHydrating,
locateHydrationNode,
locateVaporFragmentAnchor,
} from './dom/hydration'
import { import {
insertionAnchor, insertionAnchor,
insertionParent, insertionParent,
@ -87,8 +98,20 @@ export const createFor = (
let oldBlocks: ForBlock[] = [] let oldBlocks: ForBlock[] = []
let newBlocks: ForBlock[] let newBlocks: ForBlock[]
let parent: ParentNode | undefined | null let parent: ParentNode | undefined | null
// TODO handle this in hydration let parentAnchor: Node
const parentAnchor = __DEV__ ? createComment('for') : createTextNode() 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 frag = new VaporFragment(oldBlocks)
const instance = currentInstance! const instance = currentInstance!
const canUseFastRemove = flags & VaporVForFlags.FAST_REMOVE 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 { type Block, type BlockFn, DynamicFragment, insert } from './block'
import { isHydrating, locateHydrationNode } from './dom/hydration' import { isHydrating } from './dom/hydration'
import { import {
insertionAnchor, insertionAnchor,
insertionParent, insertionParent,
@ -15,17 +16,16 @@ export function createIf(
): Block { ): Block {
const _insertionParent = insertionParent const _insertionParent = insertionParent
const _insertionAnchor = insertionAnchor const _insertionAnchor = insertionAnchor
if (isHydrating) { if (!isHydrating) resetInsertionState()
locateHydrationNode()
} else {
resetInsertionState()
}
let frag: Block let frag: Block
if (once) { if (once) {
frag = condition() ? b1() : b2 ? b2() : [] frag = condition() ? b1() : b2 ? b2() : []
} else { } 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)) renderEffect(() => (frag as DynamicFragment).update(condition() ? b1 : b2))
} }

View File

@ -7,7 +7,13 @@ import {
} from './component' } from './component'
import { createComment, createTextNode } from './dom/node' import { createComment, createTextNode } from './dom/node'
import { EffectScope, pauseTracking, resetTracking } from '@vue/reactivity' import { EffectScope, pauseTracking, resetTracking } from '@vue/reactivity'
import { isHydrating } from './dom/hydration' import {
currentHydrationNode,
isComment,
isHydrating,
locateHydrationNode,
locateVaporFragmentAnchor,
} from './dom/hydration'
export type Block = export type Block =
| Node | Node
@ -30,16 +36,21 @@ export class VaporFragment {
} }
export class DynamicFragment extends VaporFragment { export class DynamicFragment extends VaporFragment {
anchor: Node anchor!: Node
scope: EffectScope | undefined scope: EffectScope | undefined
current?: BlockFn current?: BlockFn
fallback?: BlockFn fallback?: BlockFn
constructor(anchorLabel?: string) { constructor(anchorLabel?: string) {
super([]) super([])
if (isHydrating) {
locateHydrationNode()
this.hydrate(anchorLabel!)
} else {
this.anchor = this.anchor =
__DEV__ && anchorLabel ? createComment(anchorLabel) : createTextNode() __DEV__ && anchorLabel ? createComment(anchorLabel) : createTextNode()
} }
}
update(render?: BlockFn, key: any = render): void { update(render?: BlockFn, key: any = render): void {
if (key === this.current) { if (key === this.current) {
@ -75,6 +86,22 @@ export class DynamicFragment extends VaporFragment {
resetTracking() 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 { export function isFragment(val: NonNullable<unknown>): val is VaporFragment {
@ -126,7 +153,6 @@ export function insert(
} else { } else {
// fragment // fragment
if (block.insert) { if (block.insert) {
// TODO handle hydration for vdom interop
block.insert(parent, anchor) block.insert(parent, anchor)
} else { } else {
insert(block.nodes, parent, anchor) insert(block.nodes, parent, anchor)

View File

@ -58,7 +58,13 @@ import {
getSlot, getSlot,
} from './componentSlots' } from './componentSlots'
import { hmrReload, hmrRerender } from './hmr' import { hmrReload, hmrRerender } from './hmr'
import { isHydrating, locateHydrationNode } from './dom/hydration' import {
adoptTemplate,
currentHydrationNode,
isHydrating,
locateHydrationNode,
setCurrentHydrationNode,
} from './dom/hydration'
import { import {
insertionAnchor, insertionAnchor,
insertionParent, insertionParent,
@ -157,7 +163,9 @@ export function createComponent(
rawProps, rawProps,
rawSlots, rawSlots,
) )
if (!isHydrating && _insertionParent) {
// `frag.insert` handles both hydration and mounting
if (_insertionParent) {
insert(frag, _insertionParent, _insertionAnchor) insert(frag, _insertionParent, _insertionAnchor)
} }
return frag return frag
@ -486,7 +494,9 @@ export function createComponentWithFallback(
resetInsertionState() resetInsertionState()
} }
const el = document.createElement(comp) const el = isHydrating
? (adoptTemplate(currentHydrationNode!, `<${comp}/>`) as HTMLElement)
: document.createElement(comp)
// mark single root // mark single root
;(el as any).$root = isSingleRoot ;(el as any).$root = isSingleRoot
@ -497,6 +507,7 @@ export function createComponentWithFallback(
} }
if (rawSlots) { if (rawSlots) {
isHydrating && setCurrentHydrationNode(el.firstChild)
if (rawSlots.$) { if (rawSlots.$) {
// TODO dynamic slot fragment // TODO dynamic slot fragment
} else { } 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 { type Block, type BlockFn, DynamicFragment, insert } from './block'
import { rawPropsProxyHandlers } from './componentProps' import { rawPropsProxyHandlers } from './componentProps'
import { currentInstance, isRef } from '@vue/runtime-dom' import { currentInstance, isRef } from '@vue/runtime-dom'
@ -9,7 +16,7 @@ import {
insertionParent, insertionParent,
resetInsertionState, resetInsertionState,
} from './insertionState' } from './insertionState'
import { isHydrating, locateHydrationNode } from './dom/hydration' import { isHydrating } from './dom/hydration'
export type RawSlots = Record<string, VaporSlot> & { export type RawSlots = Record<string, VaporSlot> & {
$?: DynamicSlotSource[] $?: DynamicSlotSource[]
@ -98,11 +105,7 @@ export function createSlot(
): Block { ): Block {
const _insertionParent = insertionParent const _insertionParent = insertionParent
const _insertionAnchor = insertionAnchor const _insertionAnchor = insertionAnchor
if (isHydrating) { if (!isHydrating) resetInsertionState()
locateHydrationNode()
} else {
resetInsertionState()
}
const instance = currentInstance as VaporComponentInstance const instance = currentInstance as VaporComponentInstance
const rawSlots = instance.rawSlots const rawSlots = instance.rawSlots
@ -111,7 +114,6 @@ export function createSlot(
: EMPTY_OBJ : EMPTY_OBJ
let fragment: DynamicFragment let fragment: DynamicFragment
if (isRef(rawSlots._)) { if (isRef(rawSlots._)) {
fragment = instance.appContext.vapor!.vdomSlot( fragment = instance.appContext.vapor!.vdomSlot(
rawSlots._, rawSlots._,
@ -121,7 +123,10 @@ export function createSlot(
fallback, fallback,
) )
} else { } else {
fragment = __DEV__ ? new DynamicFragment('slot') : new DynamicFragment() fragment =
isHydrating || __DEV__
? new DynamicFragment(SLOT_ANCHOR_LABEL)
: new DynamicFragment()
const isDynamicName = isFunction(name) const isDynamicName = isFunction(name)
const renderSlot = () => { const renderSlot = () => {
const slot = getSlot(rawSlots, isFunction(name) ? name() : name) 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) insert(fragment, _insertionParent, _insertionAnchor)
} }

View File

@ -5,7 +5,12 @@ import {
resetInsertionState, resetInsertionState,
setInsertionState, setInsertionState,
} from '../insertionState' } from '../insertionState'
import { child, next } from './node' import {
_nthChild,
disableHydrationNodeLookup,
enableHydrationNodeLookup,
} from './node'
import { isVaporAnchors } from '@vue/shared'
export let isHydrating = false export let isHydrating = false
export let currentHydrationNode: Node | null = null export let currentHydrationNode: Node | null = null
@ -16,33 +21,53 @@ export function setCurrentHydrationNode(node: Node | null): void {
let isOptimized = false let isOptimized = false
export function withHydration(container: ParentNode, fn: () => void): void { function performHydration<T>(
fn: () => T,
setup: () => void,
cleanup: () => void,
): T {
if (!isOptimized) {
adoptTemplate = adoptTemplateImpl adoptTemplate = adoptTemplateImpl
locateHydrationNode = locateHydrationNodeImpl locateHydrationNode = locateHydrationNodeImpl
if (!isOptimized) {
// optimize anchor cache lookup // optimize anchor cache lookup
;(Comment.prototype as any).$fs = undefined ;(Comment.prototype as any).$fe = undefined
;(Node.prototype as any).$dp = undefined
isOptimized = true isOptimized = true
} }
enableHydrationNodeLookup()
isHydrating = true isHydrating = true
setInsertionState(container, 0) setup()
const res = fn() const res = fn()
resetInsertionState() cleanup()
currentHydrationNode = null currentHydrationNode = null
isHydrating = false isHydrating = false
disableHydrationNodeLookup()
return res 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 adoptTemplate: (node: Node, template: string) => Node | null
export let locateHydrationNode: () => void export let locateHydrationNode: () => void
type Anchor = Comment & { type Anchor = Comment & {
// cached matching fragment start to avoid repeated traversal // cached matching fragment end to avoid repeated traversal
// on nested fragments // 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 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 { function adoptTemplateImpl(node: Node, template: string): Node | null {
if (!(template[0] === '<' && template[1] === '!')) { if (!(template[0] === '<' && template[1] === '!')) {
while (node.nodeType === 8) node = next(node) while (node.nodeType === 8) node = node.nextSibling!
} }
if (__DEV__) { if (__DEV__) {
@ -71,51 +96,27 @@ function adoptTemplateImpl(node: Node, template: string): Node | null {
} }
} }
currentHydrationNode = next(node) currentHydrationNode = node.nextSibling
return node return node
} }
function locateHydrationNodeImpl() { function locateHydrationNodeImpl() {
let node: Node | null let node: Node | null
// prepend / firstChild // prepend / firstChild
if (insertionAnchor === 0) { if (insertionAnchor === 0) {
node = child(insertionParent!) node = insertionParent!.firstChild
} else { } 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 node = insertionAnchor
? insertionAnchor.previousSibling } else {
: insertionParent node = currentHydrationNode
? insertionParent.lastChild
: currentHydrationNode
if (node && isComment(node, ']')) { // if current hydration node is not under the current parent, or no
// fragment backward search // current node, find node by dynamic position or use the first child
if (node.$fs) { if (insertionParent && (!node || node.parentNode !== insertionParent)) {
// already cached matching fragment start node = _nthChild(insertionParent, insertionParent.$dp || 0)
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++
}
}
}
}
} }
} }
@ -127,3 +128,55 @@ function locateHydrationNodeImpl() {
resetInsertionState() resetInsertionState()
currentHydrationNode = node 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__ */ /*! #__NO_SIDE_EFFECTS__ */
export function createTextNode(value = ''): Text { export function createTextNode(value = ''): Text {
return document.createTextNode(value) return document.createTextNode(value)
@ -14,16 +21,175 @@ export function querySelector(selectors: string): Element | null {
} }
/*! #__NO_SIDE_EFFECTS__ */ /*! #__NO_SIDE_EFFECTS__ */
export function child(node: ParentNode): Node { export function _child(node: ParentNode): Node {
return node.firstChild! 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__ */ /*! #__NO_SIDE_EFFECTS__ */
export function nthChild(node: Node, i: number): Node { export function __child(node: ParentNode): Node {
return node.childNodes[i] let n = node.firstChild!
if (isComment(n, '[')) {
n = locateEndAnchor(n)!.nextSibling!
}
while (n && isVaporAnchors(n)) {
n = n.nextSibling!
}
return n
} }
/*! #__NO_SIDE_EFFECTS__ */ /*! #__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! 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__ */ /*! #__NO_SIDE_EFFECTS__ */
export function template(html: string, root?: boolean) { export function template(html: string, root?: boolean) {
let node: Node let node: Node
return (): Node & { $root?: true } => { return (n?: number): Node & { $root?: true } => {
if (isHydrating) { if (isHydrating) {
if (__DEV__ && !currentHydrationNode) { if (__DEV__ && !currentHydrationNode) {
// TODO this should not happen // TODO this should not happen
throw new Error('No current hydration node') 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 // fast path for text nodes
if (html[0] !== '<') { 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 export let insertionAnchor: Node | 0 | undefined
/** /**

View File

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

View File

@ -446,7 +446,7 @@ function testRender(type: string, render: typeof renderToString) {
).toBe( ).toBe(
`<div>parent<div class="child">` + `<div>parent<div class="child">` +
`<!--[--><span>from slot</span><!--]-->` + `<!--[--><span>from slot</span><!--]-->` +
`</div></div>`, `<!--slot--></div></div>`,
) )
// test fallback // test fallback
@ -461,7 +461,7 @@ function testRender(type: string, render: typeof renderToString) {
}), }),
), ),
).toBe( ).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( ).toBe(
`<div>parent<div class="child">` + `<div>parent<div class="child">` +
`<!--[--><span>from slot</span><!--]-->` + `<!--[--><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( expect(await render(app)).toBe(
`<div>parent<div class="child">` + `<div>parent<div class="child">` +
`<!--[--><span>from slot</span><!--]-->` + `<!--[--><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( 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( expect(await render(app)).toBe(
// should only have a single fragment // 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( expect(await render(app)).toBe(
// should only have a single fragment // 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"/>`, template: `<child :ok="ok" class="bar"/>`,
} }
expect(await renderToString(createApp(Parent, { ok: true }))).toBe( 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( expect(await renderToString(createApp(Parent, { ok: false }))).toBe(
`<span class="bar"></span>`, `<span class="bar"></span>`,

View File

@ -14,7 +14,9 @@ describe('ssr: dynamic component', () => {
template: `<component :is="'one'"><span>slot</span></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 () => { test('resolved to component with v-show', async () => {
@ -30,7 +32,7 @@ describe('ssr: dynamic component', () => {
}), }),
), ),
).toBe( ).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>`, 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 () => { test('resolve to component vnode', async () => {
@ -60,7 +62,9 @@ describe('ssr: dynamic component', () => {
template: `<component :is="vnode"><span>slot</span></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 () => { test('resolve to element vnode', async () => {
@ -75,6 +79,6 @@ describe('ssr: dynamic component', () => {
template: `<component :is="vnode"><span>slot</span></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)) 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 // #2892
@ -150,8 +152,8 @@ describe('ssr: scopedId runtime behavior', () => {
const result = await renderToString(createApp(Root)) const result = await renderToString(createApp(Root))
expect(result).toBe( expect(result).toBe(
`<div class="wrapper" root slotted wrapper>` + `<div class="wrapper" root slotted wrapper>` +
`<!--[--><!--[--><div root slotted-s wrapper-s></div><!--]--><!--]-->` + `<!--[--><!--[--><div root slotted-s wrapper-s></div><!--]--><!--slot--><!--]-->` +
`</div>`, `<!--slot--></div>`,
) )
}) })
@ -265,8 +267,8 @@ describe('ssr: scopedId runtime behavior', () => {
const result = await renderToString(createApp(Root)) const result = await renderToString(createApp(Root))
expect(result).toBe( expect(result).toBe(
`<div class="wrapper" root slotted wrapper>` + `<div class="wrapper" root slotted wrapper>` +
`<!--[--><!--[--><div root slotted-s wrapper-s></div><!--]--><!--]-->` + `<!--[--><!--[--><div root slotted-s wrapper-s></div><!--]--><!--slot--><!--]-->` +
`</div>`, `<!--slot--></div>`,
) )
}) })
}) })

View File

@ -16,7 +16,7 @@ describe('ssr: slot', () => {
template: `<one>hello</one>`, template: `<one>hello</one>`,
}), }),
), ),
).toBe(`<div><!--[-->hello<!--]--></div>`) ).toBe(`<div><!--[-->hello<!--]--><!--slot--></div>`)
}) })
test('element slot', async () => { test('element slot', async () => {
@ -27,7 +27,7 @@ describe('ssr: slot', () => {
template: `<one><div>hi</div></one>`, template: `<one><div>hi</div></one>`,
}), }),
), ),
).toBe(`<div><!--[--><div>hi</div><!--]--></div>`) ).toBe(`<div><!--[--><div>hi</div><!--]--><!--slot--></div>`)
}) })
test('empty slot', async () => { test('empty slot', async () => {
@ -42,7 +42,7 @@ describe('ssr: slot', () => {
template: `<one><template v-if="false"/></one>`, template: `<one><template v-if="false"/></one>`,
}), }),
), ),
).toBe(`<div><!--[--><!--]--></div>`) ).toBe(`<div><!--[--><!--]--><!--slot--></div>`)
}) })
test('empty slot (manual comments)', async () => { test('empty slot (manual comments)', async () => {
@ -57,7 +57,7 @@ describe('ssr: slot', () => {
template: `<one><!--hello--></one>`, template: `<one><!--hello--></one>`,
}), }),
), ),
).toBe(`<div><!--[--><!--]--></div>`) ).toBe(`<div><!--[--><!--]--><!--slot--></div>`)
}) })
test('empty slot (multi-line comments)', async () => { test('empty slot (multi-line comments)', async () => {
@ -72,7 +72,7 @@ describe('ssr: slot', () => {
template: `<one><!--he\nllo--></one>`, template: `<one><!--he\nllo--></one>`,
}), }),
), ),
).toBe(`<div><!--[--><!--]--></div>`) ).toBe(`<div><!--[--><!--]--><!--slot--></div>`)
}) })
test('multiple elements', async () => { test('multiple elements', async () => {
@ -83,7 +83,7 @@ describe('ssr: slot', () => {
template: `<one><div>one</div><div>two</div></one>`, 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 () => { test('fragment slot (template v-if)', async () => {
@ -94,7 +94,9 @@ describe('ssr: slot', () => {
template: `<one><template v-if="true">hello</template></one>`, 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 () => { test('fragment slot (template v-if + multiple elements)', async () => {
@ -106,7 +108,7 @@ describe('ssr: slot', () => {
}), }),
), ),
).toBe( ).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>`, template: `<one><div v-if="true">foo</div></one>`,
}), }),
), ),
).toBe(`<div>foo</div>`) ).toBe(`<div>foo</div><!--if-->`)
}) })
// #9933 // #9933
@ -183,7 +185,7 @@ describe('ssr: slot', () => {
`, `,
}), }),
), ),
).toBe(`<div><!--[--> new header <!--]--></div>`) ).toBe(`<div><!--[--> new header <!--]--><!--slot--></div>`)
}) })
// #11326 // #11326
@ -202,7 +204,9 @@ describe('ssr: slot', () => {
template: `<ButtonComp><Wrap><div v-if="false">hello</div></Wrap></ButtonComp>`, 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( expect(
await renderToString( await renderToString(
@ -219,7 +223,7 @@ describe('ssr: slot', () => {
}), }),
), ),
).toBe( ).toBe(
`<button><!--[--><div><!--[--><div>hello</div><!--]--></div><!--]--></button>`, `<button><!--[--><div><!--[--><div>hello</div><!--]--><!--slot--></div><!--]--></button><!--dynamic-component-->`,
) )
expect( expect(
@ -233,6 +237,6 @@ describe('ssr: slot', () => {
template: `<ButtonComp><template v-if="false">hello</template></ButtonComp>`, 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, type SSRBufferItem,
renderVNodeChildren, renderVNodeChildren,
} from '../render' } from '../render'
import { isArray } from '@vue/shared' import { SLOT_ANCHOR_LABEL, isArray } from '@vue/shared'
const { ensureValidVNode } = ssrUtils const { ensureValidVNode } = ssrUtils
@ -37,7 +37,7 @@ export function ssrRenderSlot(
parentComponent, parentComponent,
slotScopeId, slotScopeId,
) )
push(`<!--]-->`) push(`<!--]--><!--${SLOT_ANCHOR_LABEL}-->`)
} }
export function ssrRenderSlotInner( export function ssrRenderSlotInner(
@ -104,7 +104,7 @@ export function ssrRenderSlotInner(
if ( if (
transition && transition &&
slotBuffer[0] === '<!--[-->' && slotBuffer[0] === '<!--[-->' &&
slotBuffer[end - 1] === '<!--]-->' (slotBuffer[end - 1] as string).startsWith('<!--]-->')
) { ) {
start++ start++
end-- 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 './toDisplayString'
export * from './typeUtils' export * from './typeUtils'
export * from './subSequence' export * from './subSequence'
export * from './domAnchors'