From 12e41691c335607e3bbb5322f82c51c7e96fcda4 Mon Sep 17 00:00:00 2001 From: edison Date: Sun, 28 Sep 2025 16:23:43 +0800 Subject: [PATCH] wip: hydrate vapor teleport (#13864) --- .../__snapshots__/compile.spec.ts.snap | 34 +- .../compiler-vapor/__tests__/compile.spec.ts | 17 + .../__snapshots__/expression.spec.ts.snap | 4 +- .../transformChildren.spec.ts.snap | 22 +- .../__snapshots__/vFor.spec.ts.snap | 4 +- .../__snapshots__/vOnce.spec.ts.snap | 6 +- .../transforms/transformChildren.spec.ts | 4 +- .../compiler-vapor/src/generators/block.ts | 2 +- .../compiler-vapor/src/generators/template.ts | 49 +- packages/compiler-vapor/src/ir/index.ts | 1 + .../src/transforms/transformText.ts | 5 +- packages/compiler-vapor/src/transforms/vIf.ts | 1 + .../runtime-core/src/components/Teleport.ts | 2 +- packages/runtime-core/src/hydration.ts | 11 + packages/runtime-core/src/index.ts | 7 + .../runtime-vapor/__tests__/hydration.spec.ts | 716 +++++++++++++++++- packages/runtime-vapor/src/apiCreateFor.ts | 15 +- packages/runtime-vapor/src/block.ts | 13 +- packages/runtime-vapor/src/component.ts | 7 +- .../runtime-vapor/src/components/Teleport.ts | 274 ++++--- packages/runtime-vapor/src/dom/hydration.ts | 61 +- packages/runtime-vapor/src/dom/node.ts | 113 ++- packages/runtime-vapor/src/dom/prop.ts | 3 +- packages/runtime-vapor/src/dom/template.ts | 13 +- packages/runtime-vapor/src/fragment.ts | 27 +- packages/runtime-vapor/src/hmr.ts | 69 +- packages/runtime-vapor/src/insertionState.ts | 127 +--- packages/runtime-vapor/src/renderEffect.ts | 11 +- packages/runtime-vapor/src/vdomInterop.ts | 14 +- 29 files changed, 1178 insertions(+), 454 deletions(-) diff --git a/packages/compiler-vapor/__tests__/__snapshots__/compile.spec.ts.snap b/packages/compiler-vapor/__tests__/__snapshots__/compile.spec.ts.snap index 41e2e776b..d2cd54c9e 100644 --- a/packages/compiler-vapor/__tests__/__snapshots__/compile.spec.ts.snap +++ b/packages/compiler-vapor/__tests__/__snapshots__/compile.spec.ts.snap @@ -157,9 +157,9 @@ export function render(_ctx, $props, $emit, $attrs, $slots) { const _component_Comp = _resolveComponent("Comp") const n0 = t0() const n3 = t1() + const n2 = _child(n3, 1) _setInsertionState(n3, 0) const n1 = _createComponentWithFallback(_component_Comp) - const n2 = _child(n3) _renderEffect(() => { _setProp(n3, "id", _ctx.foo) _setText(n2, _toDisplayString(_ctx.bar)) @@ -212,6 +212,30 @@ export function render(_ctx) { }" `; +exports[`compile > execution order > setInsertionState > next, child and nthChild should be above the setInsertionState 1`] = ` +"import { resolveComponent as _resolveComponent, child as _child, next as _next, setInsertionState as _setInsertionState, createComponentWithFallback as _createComponentWithFallback, nthChild as _nthChild, createIf as _createIf, setProp as _setProp, renderEffect as _renderEffect, template as _template } from 'vue'; +const t0 = _template("
") +const t1 = _template("
", true) + +export function render(_ctx) { + const _component_Comp = _resolveComponent("Comp") + const n6 = t1() + const n5 = _next(_child(n6), 1) + const n7 = _nthChild(n6, 3, 3) + const p0 = _next(n7, 4) + const n4 = _child(p0, 0) + _setInsertionState(n6, n5) + const n0 = _createComponentWithFallback(_component_Comp) + _setInsertionState(n6, n7) + const n1 = _createIf(() => (true), () => { + const n3 = t0() + return n3 + }) + _renderEffect(() => _setProp(n4, "disabled", _ctx.foo)) + return n6 +}" +`; + exports[`compile > execution order > with insertionState 1`] = ` "import { resolveComponent as _resolveComponent, child as _child, setInsertionState as _setInsertionState, createSlot as _createSlot, createComponentWithFallback as _createComponentWithFallback, template as _template } from 'vue'; const t0 = _template("
", true) @@ -219,7 +243,7 @@ const t0 = _template("
", true) export function render(_ctx) { const _component_Comp = _resolveComponent("Comp") const n3 = t0() - const n1 = _child(n3) + const n1 = _child(n3, 0) _setInsertionState(n1, null) const n0 = _createSlot("default", null) _setInsertionState(n3, 1) @@ -234,9 +258,9 @@ const t0 = _template("

", true) export function render(_ctx) { const n3 = t0() - const n0 = _child(n3) - const n1 = _next(n0) - const n2 = _nthChild(n3, 3) + const n0 = _child(n3, 0) + const n1 = _next(n0, 1) + const n2 = _nthChild(n3, 3, 3) const x0 = _txt(n0) _setText(x0, _toDisplayString(_ctx.foo)) _renderEffect(() => { diff --git a/packages/compiler-vapor/__tests__/compile.spec.ts b/packages/compiler-vapor/__tests__/compile.spec.ts index ae59cc78e..6de93bd32 100644 --- a/packages/compiler-vapor/__tests__/compile.spec.ts +++ b/packages/compiler-vapor/__tests__/compile.spec.ts @@ -231,6 +231,23 @@ describe('compile', () => { ) }) + describe('setInsertionState', () => { + test('next, child and nthChild should be above the setInsertionState', () => { + const code = compile(` +
+
+ +
+
+
+
+
+ `) + expect(code).toMatchSnapshot() + }) + }) + test('with v-once', () => { const code = compile( `
diff --git a/packages/compiler-vapor/__tests__/transforms/__snapshots__/expression.spec.ts.snap b/packages/compiler-vapor/__tests__/transforms/__snapshots__/expression.spec.ts.snap index 2d4da87c3..9dc329f21 100644 --- a/packages/compiler-vapor/__tests__/transforms/__snapshots__/expression.spec.ts.snap +++ b/packages/compiler-vapor/__tests__/transforms/__snapshots__/expression.spec.ts.snap @@ -47,7 +47,7 @@ const t0 = _template("
", true) export function render(_ctx) { const n1 = t0() - const n0 = _child(n1) + const n0 = _child(n1, 0) const x1 = _txt(n1) _renderEffect(() => { const _foo = _ctx.foo @@ -86,7 +86,7 @@ const t0 = _template("
", true) export function render(_ctx) { const n1 = t0() - const n0 = _child(n1) + const n0 = _child(n1, 0) const x1 = _txt(n1) _renderEffect(() => { const _String = String diff --git a/packages/compiler-vapor/__tests__/transforms/__snapshots__/transformChildren.spec.ts.snap b/packages/compiler-vapor/__tests__/transforms/__snapshots__/transformChildren.spec.ts.snap index c56b68332..99f64c8cf 100644 --- a/packages/compiler-vapor/__tests__/transforms/__snapshots__/transformChildren.spec.ts.snap +++ b/packages/compiler-vapor/__tests__/transforms/__snapshots__/transformChildren.spec.ts.snap @@ -7,7 +7,7 @@ const t1 = _template("
", true) export function render(_ctx) { const n4 = t1() - const n3 = _next(_child(n4)) + const n3 = _next(_child(n4), 1) _setInsertionState(n4, n3) const n0 = _createIf(() => (1), () => { const n2 = t0() @@ -23,9 +23,9 @@ const t0 = _template("

", true) export function render(_ctx) { const n3 = t0() - const n0 = _child(n3) - const n1 = _next(n0) - const n2 = _next(n1) + const n0 = _child(n3, 0) + const n1 = _next(n0, 1) + const n2 = _next(n1, 2) const x0 = _txt(n0) const x2 = _txt(n2) _renderEffect(() => { @@ -43,7 +43,7 @@ const t0 = _template("
x
x
", true) export function render(_ctx) { const n1 = t0() - const n0 = _nthChild(n1, 2) + const n0 = _nthChild(n1, 2, 2) const x0 = _txt(n0) _renderEffect(() => _setText(x0, _toDisplayString(_ctx.msg))) return n1 @@ -56,12 +56,12 @@ const t0 = _template("
x
(_ctx.list), (_for_item0) => { const n3 = _createComponentWithFallback(_component_Comp) - const n2 = _child(n3) + const n2 = _child(n3, 0) _renderEffect(() => _setText(n2, _toDisplayString(_for_item0.value))) return [n2, n3] }, undefined, 2) @@ -259,7 +259,7 @@ export function render(_ctx) { const _component_Comp = _resolveComponent("Comp") const n0 = _createFor(() => (_ctx.list), (_for_item0) => { const n3 = _createComponentWithFallback(_component_Comp) - const n2 = _child(n3) + const n2 = _child(n3, 0) _renderEffect(() => _setText(n2, _toDisplayString(_for_item0.value))) return [n2, n3] }, undefined, 2) diff --git a/packages/compiler-vapor/__tests__/transforms/__snapshots__/vOnce.spec.ts.snap b/packages/compiler-vapor/__tests__/transforms/__snapshots__/vOnce.spec.ts.snap index 4ca745ef0..2fcd18da1 100644 --- a/packages/compiler-vapor/__tests__/transforms/__snapshots__/vOnce.spec.ts.snap +++ b/packages/compiler-vapor/__tests__/transforms/__snapshots__/vOnce.spec.ts.snap @@ -17,8 +17,8 @@ const t0 = _template("
", true) export function render(_ctx, $props, $emit, $attrs, $slots) { const n2 = t0() - const n0 = _child(n2) - const n1 = _next(n0) + const n0 = _child(n2, 0) + const n1 = _next(n0, 1) _setText(n0, _toDisplayString(_ctx.msg) + " ") _setClass(n1, _ctx.clz) return n2 @@ -54,7 +54,7 @@ const t0 = _template("
", true) export function render(_ctx) { const n1 = t0() - const n0 = _child(n1) + const n0 = _child(n1, 0) _setProp(n0, "id", _ctx.foo) return n1 }" diff --git a/packages/compiler-vapor/__tests__/transforms/transformChildren.spec.ts b/packages/compiler-vapor/__tests__/transforms/transformChildren.spec.ts index 2d8ae8c96..d41ed2ec4 100644 --- a/packages/compiler-vapor/__tests__/transforms/transformChildren.spec.ts +++ b/packages/compiler-vapor/__tests__/transforms/transformChildren.spec.ts @@ -56,7 +56,7 @@ describe('compiler: children transform', () => {
{{ msg }}
`, ) - expect(code).contains(`const n0 = _nthChild(n1, 2)`) + expect(code).contains(`const n0 = _nthChild(n1, 2, 2)`) expect(code).toMatchSnapshot() }) @@ -69,7 +69,7 @@ describe('compiler: children transform', () => {
`, ) // ensure the insertion anchor is generated before the insertion statement - expect(code).toMatch(`const n3 = _next(_child(n4))`) + expect(code).toMatch(`const n3 = _next(_child(n4), 1)`) expect(code).toMatch(`_setInsertionState(n4, n3)`) expect(code).toMatchSnapshot() }) diff --git a/packages/compiler-vapor/src/generators/block.ts b/packages/compiler-vapor/src/generators/block.ts index 9ad5da121..40fa8da63 100644 --- a/packages/compiler-vapor/src/generators/block.ts +++ b/packages/compiler-vapor/src/generators/block.ts @@ -71,7 +71,7 @@ export function genBlockContent( } for (const child of dynamic.children) { if (!child.hasDynamicChild) { - push(...genChildren(child, context, `n${child.id!}`)) + push(...genChildren(child, context, push, `n${child.id!}`)) } } diff --git a/packages/compiler-vapor/src/generators/template.ts b/packages/compiler-vapor/src/generators/template.ts index c22d0bf98..96fbd92b0 100644 --- a/packages/compiler-vapor/src/generators/template.ts +++ b/packages/compiler-vapor/src/generators/template.ts @@ -1,5 +1,9 @@ import type { CodegenContext } from '../generate' -import { DynamicFlag, type IRDynamicInfo } from '../ir' +import { + DynamicFlag, + type IRDynamicInfo, + type InsertionStateTypes, +} from '../ir' import { genDirectivesForElement } from './directive' import { genOperationWithInsertionState } from './operation' import { type CodeFragment, NEWLINE, buildCodeFragment, genCall } from './utils' @@ -36,7 +40,7 @@ export function genSelf( } if (hasDynamicChild) { - push(...genChildren(dynamic, context, `n${id}`)) + push(...genChildren(dynamic, context, push, `n${id}`)) } return frag @@ -45,6 +49,7 @@ export function genSelf( export function genChildren( dynamic: IRDynamicInfo, context: CodegenContext, + pushBlock: (...items: CodeFragment[]) => number, from: string = `n${dynamic.id}`, ): CodeFragment[] { const { helper } = context @@ -53,10 +58,20 @@ export function genChildren( let offset = 0 let prev: [variable: string, elementIndex: number] | undefined + let ifBranchCount = 0 + let prependCount = 0 for (const [index, child] of children.entries()) { + if ( + child.operation && + (child.operation as InsertionStateTypes).anchor === -1 + ) { + prependCount++ + } if (child.flags & DynamicFlag.NON_TEMPLATE) { offset-- + } else if (child.ifBranch) { + ifBranchCount++ } const id = @@ -72,29 +87,41 @@ export function genChildren( } const elementIndex = index + offset + const logicalIndex = elementIndex - ifBranchCount + prependCount // p for "placeholder" variables that are meant for possible reuse by // other access paths const variable = id === undefined ? `p${context.block.tempId++}` : `n${id}` - push(NEWLINE, `const ${variable} = `) - + pushBlock(NEWLINE, `const ${variable} = `) if (prev) { if (elementIndex - prev[1] === 1) { - push(...genCall(helper('next'), prev[0])) + pushBlock(...genCall(helper('next'), prev[0], String(logicalIndex))) } else { - push(...genCall(helper('nthChild'), from, String(elementIndex))) + pushBlock( + ...genCall( + helper('nthChild'), + from, + String(elementIndex), + String(logicalIndex), + ), + ) } } else { if (elementIndex === 0) { - push(...genCall(helper('child'), from)) + pushBlock(...genCall(helper('child'), from, String(logicalIndex))) } else { // check if there's a node that we can reuse from let init = genCall(helper('child'), from) if (elementIndex === 1) { - init = genCall(helper('next'), init) + init = genCall(helper('next'), init, String(logicalIndex)) } else if (elementIndex > 1) { - init = genCall(helper('nthChild'), from, String(elementIndex)) + init = genCall( + helper('nthChild'), + from, + String(elementIndex), + String(logicalIndex), + ) } - push(...init) + pushBlock(...init) } } @@ -107,7 +134,7 @@ export function genChildren( } prev = [variable, elementIndex] - push(...genChildren(child, context, variable)) + push(...genChildren(child, context, pushBlock, variable)) } return frag diff --git a/packages/compiler-vapor/src/ir/index.ts b/packages/compiler-vapor/src/ir/index.ts index bce00283e..5bea27fc0 100644 --- a/packages/compiler-vapor/src/ir/index.ts +++ b/packages/compiler-vapor/src/ir/index.ts @@ -272,6 +272,7 @@ export interface IRDynamicInfo { hasDynamicChild?: boolean operation?: OperationNode needsKey?: boolean + ifBranch?: boolean } export interface IREffect { diff --git a/packages/compiler-vapor/src/transforms/transformText.ts b/packages/compiler-vapor/src/transforms/transformText.ts index e9c273b85..dd81bec1e 100644 --- a/packages/compiler-vapor/src/transforms/transformText.ts +++ b/packages/compiler-vapor/src/transforms/transformText.ts @@ -16,6 +16,7 @@ import { isConstantExpression, isStaticExpression, } from '../utils' +import { escapeHtml } from '@vue/shared' type TextLike = TextNode | InterpolationNode const seen = new WeakMap< @@ -82,7 +83,7 @@ export const transformText: NodeTransform = (node, context) => { } else if (node.type === NodeTypes.INTERPOLATION) { processInterpolation(context as TransformContext) } else if (node.type === NodeTypes.TEXT) { - context.template += node.content + context.template += escapeHtml(node.content) } } @@ -143,7 +144,7 @@ function processTextContainer( const literals = values.map(getLiteralExpressionValue) if (literals.every(l => l != null)) { - context.childrenTemplate = literals.map(l => String(l)) + context.childrenTemplate = literals.map(l => escapeHtml(String(l))) } else { context.childrenTemplate = [' '] context.registerOperation({ diff --git a/packages/compiler-vapor/src/transforms/vIf.ts b/packages/compiler-vapor/src/transforms/vIf.ts index 2426fa021..531d29b05 100644 --- a/packages/compiler-vapor/src/transforms/vIf.ts +++ b/packages/compiler-vapor/src/transforms/vIf.ts @@ -59,6 +59,7 @@ export function processIf( } else { // check the adjacent v-if const siblingIf = getSiblingIf(context, true) + context.dynamic.ifBranch = true const siblings = context.parent && context.parent.dynamic.children let lastIfNode diff --git a/packages/runtime-core/src/components/Teleport.ts b/packages/runtime-core/src/components/Teleport.ts index 346d2f813..0dc70bcd8 100644 --- a/packages/runtime-core/src/components/Teleport.ts +++ b/packages/runtime-core/src/components/Teleport.ts @@ -394,7 +394,7 @@ function moveTeleport( } } -interface TeleportTargetElement extends Element { +export interface TeleportTargetElement extends Element { // last teleport target _lpa?: Node | null } diff --git a/packages/runtime-core/src/hydration.ts b/packages/runtime-core/src/hydration.ts index 34ae21809..1a5a3d2fd 100644 --- a/packages/runtime-core/src/hydration.ts +++ b/packages/runtime-core/src/hydration.ts @@ -659,6 +659,17 @@ export function createHydrationFunctions( ) } } + + // the server output does not contain blank text nodes. It appears here that + // it is a dynamically inserted anchor, and needs to be skipped. + // e.g. vaporInteropImpl.mount() > selfAnchor + if ( + node && + node.nodeType === DOMNodeTypes.TEXT && + !(node as Text).data.trim() + ) { + node = nextSibling(node) + } return node } diff --git a/packages/runtime-core/src/index.ts b/packages/runtime-core/src/index.ts index bbbd3d183..b7d811710 100644 --- a/packages/runtime-core/src/index.ts +++ b/packages/runtime-core/src/index.ts @@ -584,6 +584,13 @@ export { isTeleportDisabled, isTeleportDeferred, } from './components/Teleport' +/** + * @internal + */ +export type { TeleportTargetElement } from './components/Teleport' +/** + * @internal + */ export { createAsyncComponentContext, useAsyncComponentState, diff --git a/packages/runtime-vapor/__tests__/hydration.spec.ts b/packages/runtime-vapor/__tests__/hydration.spec.ts index d8ca5a606..ad26b4fd9 100644 --- a/packages/runtime-vapor/__tests__/hydration.spec.ts +++ b/packages/runtime-vapor/__tests__/hydration.spec.ts @@ -5,6 +5,8 @@ import * as runtimeVapor from '../src' import * as runtimeDom from '@vue/runtime-dom' import * as VueServerRenderer from '@vue/server-renderer' import { isString } from '@vue/shared' +import type { VaporComponentInstance } from '../src/component' +import type { TeleportFragment } from '../src/components/Teleport' const formatHtml = (raw: string) => { return raw @@ -77,22 +79,34 @@ async function testWithVDOMApp( }) } +function compileVaporComponent( + code: string, + data: runtimeDom.Ref = ref({}), + components?: Record, + ssr = false, +) { + return compile(``, data, components, { + vapor: true, + ssr, + }) +} + async function mountWithHydration( html: string, code: string, - data: runtimeDom.Ref, + data: runtimeDom.Ref = ref({}), + components?: Record, ) { const container = document.createElement('div') container.innerHTML = html + document.body.appendChild(container) - const clientComp = compile(``, data, undefined, { - vapor: true, - ssr: false, - }) + const clientComp = compileVaporComponent(code, data, components) const app = createVaporSSRApp(clientComp) app.mount(container) return { + block: (app._instance! as VaporComponentInstance).block, container, } } @@ -298,7 +312,7 @@ describe('Vapor Mode hydration', () => { `) expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(`""`) - expect(`Hydration children mismatch in
`).not.toHaveBeenWarned() + expect(`mismatch in
`).not.toHaveBeenWarned() }) test('root with mixed element and text', async () => { @@ -331,7 +345,7 @@ describe('Vapor Mode hydration', () => { expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot( `"
"`, ) - expect(`Hydration children mismatch in
`).not.toHaveBeenWarned() + expect(`mismatch in
`).not.toHaveBeenWarned() }) test('element with binding and text children', async () => { @@ -1588,7 +1602,8 @@ describe('Vapor Mode hydration', () => { expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot( ` " - abc" + abc + " `, ) @@ -1597,7 +1612,35 @@ describe('Vapor Mode hydration', () => { expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot( ` " - abcd" + abcd + " + `, + ) + }) + + test('empty v-for', async () => { + const { container, data } = await testHydration( + ``, + undefined, + ref([]), + ) + expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot( + ` + " + + " + `, + ) + + data.value.push('a') + await nextTick() + expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot( + ` + " + a + " `, ) }) @@ -1619,7 +1662,8 @@ describe('Vapor Mode hydration', () => { ` "
- abc
3
+ abc +
3
" `, ) @@ -1630,7 +1674,8 @@ describe('Vapor Mode hydration', () => { ` "
- abcd
4
+ abcd +
4
" `, ) @@ -1651,7 +1696,8 @@ describe('Vapor Mode hydration', () => { expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot( ` "
- abc
" + abc +
" `, ) @@ -1660,7 +1706,8 @@ describe('Vapor Mode hydration', () => { expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot( ` "
- abcd
" + abcd +
" `, ) @@ -1669,7 +1716,8 @@ describe('Vapor Mode hydration', () => { expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot( ` "
- bcd
" + bcd +
" `, ) }) @@ -1690,8 +1738,9 @@ describe('Vapor Mode hydration', () => { expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot( ` "
- abc - abc
" + abc + abc +
" `, ) @@ -1700,8 +1749,9 @@ describe('Vapor Mode hydration', () => { expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot( ` "
- abcd - abcd
" + abcd + abcd +
" `, ) @@ -1710,8 +1760,9 @@ describe('Vapor Mode hydration', () => { expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot( ` "
- cd - cd
" + cd + cd +
" `, ) }) @@ -1732,7 +1783,8 @@ describe('Vapor Mode hydration', () => { expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot( ` "
-
comp
comp
comp
" +
comp
comp
comp
+ " `, ) @@ -1741,7 +1793,8 @@ describe('Vapor Mode hydration', () => { expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot( ` "
-
comp
comp
comp
comp
" +
comp
comp
comp
comp
+ " `, ) }) @@ -1767,7 +1820,8 @@ describe('Vapor Mode hydration', () => { a b c - " + + " `, ) @@ -1780,7 +1834,8 @@ describe('Vapor Mode hydration', () => { a b c - d" + d + " `, ) }) @@ -1803,7 +1858,8 @@ describe('Vapor Mode hydration', () => {
foo
-bar-
foo
-bar- -
foo
-bar- +
foo
-bar- + " `, ) @@ -1816,7 +1872,8 @@ describe('Vapor Mode hydration', () => {
foo
-bar-
foo
-bar- -
foo
-bar-
foo
-bar- +
foo
-bar-
foo
-bar- + " `, ) @@ -1845,7 +1902,8 @@ describe('Vapor Mode hydration', () => { expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot( ` "
-
foo
non-hydration node
foo
non-hydration node
" +
foo
non-hydration node
foo
non-hydration node
+ " `, ) @@ -1854,7 +1912,8 @@ describe('Vapor Mode hydration', () => { expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot( ` "
-
bar
non-hydration node
bar
non-hydration node
" +
bar
non-hydration node
bar
non-hydration node
+ " `, ) @@ -1862,14 +1921,16 @@ describe('Vapor Mode hydration', () => { await nextTick() expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(` "
-
non-hydration node
non-hydration node
" +
non-hydration node
non-hydration node
+ " `) data.value.show = true await nextTick() expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(` "
-
bar
non-hydration node
bar
non-hydration node
" +
bar
non-hydration node
bar
non-hydration node
+ " `) }) }) @@ -2005,7 +2066,8 @@ describe('Vapor Mode hydration', () => { ` " - abc + abc + " `, ) @@ -2437,9 +2499,10 @@ describe('Vapor Mode hydration', () => { ` " -
a
b
c
+
a
b
c
foo -
a
b
c
+
a
b
c
+ " `, ) @@ -2450,9 +2513,10 @@ describe('Vapor Mode hydration', () => { ` " -
a
b
c
d
+
a
b
c
d
foo -
a
b
c
d
+
a
b
c
d
+ " `, ) @@ -2920,7 +2984,587 @@ describe('Vapor Mode hydration', () => { test.todo('force hydrate custom element with dynamic props', () => {}) }) - describe.todo('Teleport') + describe('Teleport', () => { + test('basic', async () => { + const data = ref({ + msg: ref('foo'), + disabled: ref(false), + fn: vi.fn(), + }) + + const teleportContainer = document.createElement('div') + teleportContainer.id = 'teleport' + teleportContainer.innerHTML = + `` + + `foo` + + `` + + `` + document.body.appendChild(teleportContainer) + + const { block, container } = await mountWithHydration( + '', + ` + {{data.msg}} + + `, + data, + ) + + const teleport = block as TeleportFragment + expect(teleport.anchor).toBe(container.lastChild) + expect(teleport.target).toBe(teleportContainer) + expect(teleport.targetStart).toBe(teleportContainer.childNodes[0]) + expect((teleport.nodes as Node[])[0]).toBe( + teleportContainer.childNodes[1], + ) + expect((teleport.nodes as Node[])[1]).toBe( + teleportContainer.childNodes[2], + ) + expect(teleport.targetAnchor).toBe(teleportContainer.childNodes[3]) + + expect(container.innerHTML).toMatchInlineSnapshot( + `""`, + ) + + // event handler + triggerEvent('click', teleportContainer.querySelector('.foo')!) + expect(data.value.fn).toHaveBeenCalled() + + data.value.msg = 'bar' + await nextTick() + expect(formatHtml(teleportContainer.innerHTML)).toBe( + `` + + `bar` + + `` + + ``, + ) + + data.value.disabled = true + await nextTick() + expect(container.innerHTML).toBe( + `` + + `bar` + + `` + + ``, + ) + expect(formatHtml(teleportContainer.innerHTML)).toMatchInlineSnapshot( + `""`, + ) + + data.value.msg = 'baz' + await nextTick() + expect(container.innerHTML).toBe( + `` + + `baz` + + `` + + ``, + ) + + data.value.disabled = false + await nextTick() + expect(container.innerHTML).toMatchInlineSnapshot( + `""`, + ) + expect(formatHtml(teleportContainer.innerHTML)).toBe( + `` + + `baz` + + `` + + ``, + ) + }) + + test('multiple + integration', async () => { + const data = ref({ + msg: ref('foo'), + fn1: vi.fn(), + fn2: vi.fn(), + }) + + const code = ` + + {{data.msg}} + + + + {{data.msg}}2 + + ` + + const SSRComp = compileVaporComponent(code, data, undefined, true) + const teleportContainer = document.createElement('div') + teleportContainer.id = 'teleport2' + const ctx = {} as any + const mainHtml = await VueServerRenderer.renderToString( + runtimeDom.createSSRApp(SSRComp), + ctx, + ) + expect(mainHtml).toBe( + `` + + `` + + `` + + ``, + ) + + const teleportHtml = ctx.teleports!['#teleport2'] + expect(teleportHtml).toBe( + `` + + `foo` + + `` + + `` + + `foo2` + + ``, + ) + + teleportContainer.innerHTML = teleportHtml + document.body.appendChild(teleportContainer) + + const { block, container } = await mountWithHydration( + mainHtml, + code, + data, + ) + + const teleports = block as any as TeleportFragment[] + const teleport1 = teleports[0] + const teleport2 = teleports[1] + expect(teleport1.anchor).toBe(container.childNodes[2]) + expect(teleport2.anchor).toBe(container.childNodes[4]) + + expect(teleport1.target).toBe(teleportContainer) + expect(teleport1.targetStart).toBe(teleportContainer.childNodes[0]) + expect((teleport1.nodes as Node[])[0]).toBe( + teleportContainer.childNodes[1], + ) + expect(teleport1.targetAnchor).toBe(teleportContainer.childNodes[3]) + + expect(teleport2.target).toBe(teleportContainer) + expect(teleport2.targetStart).toBe(teleportContainer.childNodes[4]) + expect((teleport2.nodes as Node[])[0]).toBe( + teleportContainer.childNodes[5], + ) + expect(teleport2.targetAnchor).toBe(teleportContainer.childNodes[7]) + + expect(container.innerHTML).toBe( + `` + + `` + + `` + + ``, + ) + + // event handler + triggerEvent('click', teleportContainer.querySelector('.foo')!) + expect(data.value.fn1).toHaveBeenCalled() + + triggerEvent('click', teleportContainer.querySelector('.foo2')!) + expect(data.value.fn2).toHaveBeenCalled() + + data.value.msg = 'bar' + await nextTick() + expect(teleportContainer.innerHTML).toBe( + `` + + `bar` + + `` + + `` + + `` + + `bar2` + + `` + + ``, + ) + }) + + test('disabled', async () => { + const data = ref({ + msg: ref('foo'), + fn1: vi.fn(), + fn2: vi.fn(), + }) + + const code = ` +
foo
+ + {{data.msg}} + + +
bar
+ ` + + const SSRComp = compileVaporComponent(code, data, undefined, true) + const teleportContainer = document.createElement('div') + teleportContainer.id = 'teleport3' + const ctx = {} as any + const mainHtml = await VueServerRenderer.renderToString( + runtimeDom.createSSRApp(SSRComp), + ctx, + ) + expect(mainHtml).toBe( + `` + + `
foo
` + + `` + + `foo` + + `` + + `` + + `
bar
` + + ``, + ) + + const teleportHtml = ctx.teleports!['#teleport3'] + expect(teleportHtml).toMatchInlineSnapshot( + `""`, + ) + + teleportContainer.innerHTML = teleportHtml + document.body.appendChild(teleportContainer) + + const { block, container } = await mountWithHydration( + mainHtml, + code, + data, + ) + + const blocks = block as any[] + expect(blocks[0]).toBe(container.childNodes[1]) + + const teleport = blocks[1] as TeleportFragment + expect((teleport.nodes as Node[])[0]).toBe(container.childNodes[3]) + expect((teleport.nodes as Node[])[1]).toBe(container.childNodes[4]) + expect(teleport.anchor).toBe(container.childNodes[5]) + expect(teleport.target).toBe(teleportContainer) + expect(teleport.targetStart).toBe(teleportContainer.childNodes[0]) + expect(teleport.targetAnchor).toBe(teleportContainer.childNodes[1]) + expect(blocks[2]).toBe(container.childNodes[6]) + + expect(container.innerHTML).toBe( + `` + + `
foo
` + + `` + + `foo` + + `` + + `` + + `
bar
` + + ``, + ) + + // event handler + triggerEvent('click', container.querySelector('.foo')!) + expect(data.value.fn1).toHaveBeenCalled() + + triggerEvent('click', container.querySelector('.foo2')!) + expect(data.value.fn2).toHaveBeenCalled() + + data.value.msg = 'bar' + await nextTick() + expect(container.innerHTML).toBe( + `` + + `
foo
` + + `` + + `bar` + + `` + + `` + + `
bar
` + + ``, + ) + }) + + test('disabled + as component root', async () => { + const { container } = await mountWithHydration( + `` + + `
Parent fragment
` + + `
Teleport content
` + + ``, + ` +
Parent fragment
+ +
Teleport content
+
+ `, + ) + expect(container.innerHTML).toBe( + `` + + `
Parent fragment
` + + `` + + `
Teleport content
` + + `` + + ``, + ) + expect(`mismatch`).not.toHaveBeenWarned() + }) + + test('as component root', async () => { + const teleportContainer = document.createElement('div') + teleportContainer.id = 'teleport4' + teleportContainer.innerHTML = `hello` + document.body.appendChild(teleportContainer) + + const { block, container } = await mountWithHydration( + '', + ``, + undefined, + { + Wrapper: compileVaporComponent( + `hello`, + ), + }, + ) + + const teleport = (block as VaporComponentInstance) + .block as TeleportFragment + expect(teleport.anchor).toBe(container.childNodes[1]) + expect(teleport.target).toBe(teleportContainer) + expect(teleport.targetStart).toBe(teleportContainer.childNodes[0]) + expect(teleport.nodes).toBe(teleportContainer.childNodes[1]) + expect(teleport.targetAnchor).toBe(teleportContainer.childNodes[2]) + }) + + test('nested', async () => { + const teleportContainer = document.createElement('div') + teleportContainer.id = 'teleport5' + teleportContainer.innerHTML = + `` + + `` + + `` + + `` + + `
child
` + + `` + document.body.appendChild(teleportContainer) + + const { block, container } = await mountWithHydration( + '', + ` +
child
+
`, + ) + + const teleport = block as TeleportFragment + expect(teleport.anchor).toBe(container.childNodes[1]) + expect(teleport.targetStart).toBe(teleportContainer.childNodes[0]) + expect(teleport.targetAnchor).toBe(teleportContainer.childNodes[3]) + + const childTeleport = teleport.nodes as TeleportFragment + expect(childTeleport.anchor).toBe(teleportContainer.childNodes[2]) + expect(childTeleport.targetStart).toBe(teleportContainer.childNodes[4]) + expect(childTeleport.targetAnchor).toBe(teleportContainer.childNodes[6]) + expect(childTeleport.nodes).toBe(teleportContainer.childNodes[5]) + }) + + test('unmount (full integration)', async () => { + const targetId = 'teleport6' + const data = ref({ + toggle: ref(true), + }) + + const template1 = `Teleported Comp1` + const Comp1 = compileVaporComponent(template1) + const SSRComp1 = compileVaporComponent( + template1, + undefined, + undefined, + true, + ) + + const template2 = `
Comp2
` + const Comp2 = compileVaporComponent(template2) + const SSRComp2 = compileVaporComponent( + template2, + undefined, + undefined, + true, + ) + + const appCode = ` +
+ + +
+ ` + + const SSRApp = compileVaporComponent( + appCode, + data, + { + Comp1: SSRComp1, + Comp2: SSRComp2, + }, + true, + ) + + const teleportContainer = document.createElement('div') + teleportContainer.id = targetId + document.body.appendChild(teleportContainer) + + const ctx = {} as any + const mainHtml = await VueServerRenderer.renderToString( + runtimeDom.createSSRApp(SSRApp), + ctx, + ) + expect(mainHtml).toBe( + '
', + ) + teleportContainer.innerHTML = ctx.teleports![`#${targetId}`] + + const { container } = await mountWithHydration(mainHtml, appCode, data, { + Comp1, + Comp2, + }) + + expect(container.innerHTML).toBe( + '
', + ) + expect(teleportContainer.innerHTML).toBe( + `` + + `Teleported Comp1` + + ``, + ) + expect(`mismatch`).not.toHaveBeenWarned() + + data.value.toggle = false + await nextTick() + expect(container.innerHTML).toBe('
Comp2
') + expect(teleportContainer.innerHTML).toBe('') + }) + + test('unmount (mismatch + full integration)', async () => { + const targetId = 'teleport7' + const data = ref({ + toggle: ref(true), + }) + + const template1 = `Teleported Comp1` + const Comp1 = compileVaporComponent(template1) + const SSRComp1 = compileVaporComponent( + template1, + undefined, + undefined, + true, + ) + + const template2 = `
Comp2
` + const Comp2 = compileVaporComponent(template2) + const SSRComp2 = compileVaporComponent( + template2, + undefined, + undefined, + true, + ) + + const appCode = ` +
+ + +
+ ` + + const SSRApp = compileVaporComponent( + appCode, + data, + { + Comp1: SSRComp1, + Comp2: SSRComp2, + }, + true, + ) + + const teleportContainer = document.createElement('div') + teleportContainer.id = targetId + document.body.appendChild(teleportContainer) + + const mainHtml = await VueServerRenderer.renderToString( + runtimeDom.createSSRApp(SSRApp), + ) + expect(mainHtml).toBe( + '
', + ) + expect(teleportContainer.innerHTML).toBe('') + + const { container } = await mountWithHydration(mainHtml, appCode, data, { + Comp1, + Comp2, + }) + + expect(container.innerHTML).toBe( + '
', + ) + expect(teleportContainer.innerHTML).toBe(`Teleported Comp1`) + expect(`Hydration children mismatch`).toHaveBeenWarned() + + data.value.toggle = false + await nextTick() + expect(container.innerHTML).toBe('
Comp2
') + expect(teleportContainer.innerHTML).toBe('') + }) + + test('target change (mismatch + full integration)', async () => { + const targetId1 = 'teleport8-1' + const targetId2 = 'teleport8-2' + const data = ref({ + target: ref(targetId1), + msg: ref('foo'), + }) + + const template = `{{data.msg}}` + const Comp = compileVaporComponent(template, data) + const SSRComp = compileVaporComponent(template, data, undefined, true) + + const teleportContainer1 = document.createElement('div') + teleportContainer1.id = targetId1 + const teleportContainer2 = document.createElement('div') + teleportContainer2.id = targetId2 + document.body.appendChild(teleportContainer1) + document.body.appendChild(teleportContainer2) + + // server render + const mainHtml = await VueServerRenderer.renderToString( + runtimeDom.createSSRApp(SSRComp), + ) + expect(mainHtml).toBe(``) + expect(teleportContainer1.innerHTML).toBe('') + expect(teleportContainer2.innerHTML).toBe('') + + // hydrate + const { container } = await mountWithHydration(mainHtml, template, data, { + Comp, + }) + + expect(container.innerHTML).toBe( + ``, + ) + expect(teleportContainer1.innerHTML).toBe(`foo`) + expect(teleportContainer2.innerHTML).toBe('') + expect(`Hydration children mismatch`).toHaveBeenWarned() + + data.value.target = targetId2 + data.value.msg = 'bar' + await nextTick() + expect(container.innerHTML).toBe( + ``, + ) + expect(teleportContainer1.innerHTML).toBe('') + expect(teleportContainer2.innerHTML).toBe(`bar`) + }) + + test('with disabled teleport + undefined target', async () => { + const data = ref({ + msg: ref('foo'), + }) + + const { container } = await mountWithHydration( + 'foo', + ` + {{data.msg}} + `, + data, + ) + + expect(container.innerHTML).toBe( + `foo`, + ) + + data.value.msg = 'bar' + await nextTick() + expect(container.innerHTML).toBe( + `bar`, + ) + }) + }) describe.todo('Suspense') }) diff --git a/packages/runtime-vapor/src/apiCreateFor.ts b/packages/runtime-vapor/src/apiCreateFor.ts index 7fdc31214..52a23e9fa 100644 --- a/packages/runtime-vapor/src/apiCreateFor.ts +++ b/packages/runtime-vapor/src/apiCreateFor.ts @@ -27,8 +27,9 @@ import { renderEffect } from './renderEffect' import { VaporVForFlags } from '../../shared/src/vaporFlags' import { advanceHydrationNode, + currentHydrationNode, + isComment, isHydrating, - locateFragmentEndAnchor, locateHydrationNode, setCurrentHydrationNode, } from './dom/hydration' @@ -145,12 +146,12 @@ export const createFor = ( findLastChild(newBlocks[newLength - 1].nodes)!.nextSibling, ) } - parentAnchor = locateFragmentEndAnchor()! - if (__DEV__) { - if (!parentAnchor) { - throw new Error(`v-for fragment anchor node was not found.`) - } - ;(parentAnchor as Comment).data = 'for' + parentAnchor = + newLength === 0 + ? currentHydrationNode!.nextSibling! + : currentHydrationNode! + if (!parentAnchor || (parentAnchor && !isComment(parentAnchor, ']'))) { + throw new Error(`v-for fragment anchor node was not found.`) } } } else { diff --git a/packages/runtime-vapor/src/block.ts b/packages/runtime-vapor/src/block.ts index aeac078ae..070b5f98b 100644 --- a/packages/runtime-vapor/src/block.ts +++ b/packages/runtime-vapor/src/block.ts @@ -19,7 +19,8 @@ import { type VaporFragment, isFragment, } from './fragment' -import { child } from './dom/node' +import { _child } from './dom/node' +import { TeleportFragment } from './components/Teleport' export interface TransitionOptions { $key?: any @@ -68,11 +69,11 @@ export function isValidBlock(block: Block): boolean { export function insert( block: Block, - parent: ParentNode & { $anchor?: Node | null }, + parent: ParentNode & { $prependAnchor?: Node | null }, anchor: Node | null | 0 = null, // 0 means prepend parentSuspense?: any, // TODO Suspense ): void { - anchor = anchor === 0 ? child(parent) : anchor + anchor = anchor === 0 ? parent.$prependAnchor || _child(parent) : anchor if (block instanceof Node) { if (!isHydrating) { // only apply transition on Element nodes @@ -182,12 +183,12 @@ export function normalizeBlock(block: Block): Node[] { } else if (isVaporComponent(block)) { nodes.push(...normalizeBlock(block.block!)) } else { - if (block.getNodes) { - nodes.push(...normalizeBlock(block.getNodes())) + if (block instanceof TeleportFragment) { + nodes.push(block.placeholder!, block.anchor!) } else { nodes.push(...normalizeBlock(block.nodes)) + block.anchor && nodes.push(block.anchor) } - block.anchor && nodes.push(block.anchor) } return nodes } diff --git a/packages/runtime-vapor/src/component.ts b/packages/runtime-vapor/src/component.ts index 8d7032209..d7e9fc5ab 100644 --- a/packages/runtime-vapor/src/component.ts +++ b/packages/runtime-vapor/src/component.ts @@ -52,7 +52,7 @@ import { resolveDynamicProps, setupPropsValidation, } from './componentProps' -import { renderEffect } from './renderEffect' +import { type RenderEffect, renderEffect } from './renderEffect' import { emit, normalizeEmitsOptions } from './componentEmits' import { setDynamicProps } from './dom/prop' import { @@ -73,7 +73,7 @@ import { locateHydrationNode, setCurrentHydrationNode, } from './dom/hydration' -import { isVaporTeleport } from './components/Teleport' +import { type TeleportFragment, isVaporTeleport } from './components/Teleport' import { insertionAnchor, insertionParent, @@ -445,8 +445,9 @@ export class VaporComponentInstance implements GenericComponentInstance { setupState?: Record devtoolsRawSetupState?: any hmrRerender?: () => void - hmrRerenderEffects?: (() => void)[] hmrReload?: (newComp: VaporComponent) => void + renderEffects?: RenderEffect[] + parentTeleport?: TeleportFragment | null propsOptions?: NormalizedPropsOptions emitsOptions?: ObjectEmitsOptions | null isSingleRoot?: boolean diff --git a/packages/runtime-vapor/src/components/Teleport.ts b/packages/runtime-vapor/src/components/Teleport.ts index 637985ddc..ef3d4598c 100644 --- a/packages/runtime-vapor/src/components/Teleport.ts +++ b/packages/runtime-vapor/src/components/Teleport.ts @@ -1,6 +1,8 @@ import { + MismatchTypes, type TeleportProps, - currentInstance, + type TeleportTargetElement, + isMismatchAllowed, isTeleportDeferred, isTeleportDisabled, queuePostFlushCb, @@ -12,16 +14,21 @@ import { createComment, createTextNode, querySelector } from '../dom/node' import { type LooseRawProps, type LooseRawSlots, - type VaporComponentInstance, isVaporComponent, } from '../component' import { rawPropsProxyHandlers } from '../componentProps' import { renderEffect } from '../renderEffect' import { extend, isArray } from '@vue/shared' import { VaporFragment } from '../fragment' - -const instanceToTeleportMap: WeakMap = - __DEV__ ? new WeakMap() : (undefined as any) +import { + advanceHydrationNode, + currentHydrationNode, + isComment, + isHydrating, + logMismatchError, + runWithoutHydration, + setCurrentHydrationNode, +} from '../dom/hydration' export const VaporTeleportImpl = { name: 'VaporTeleport', @@ -29,102 +36,90 @@ export const VaporTeleportImpl = { __vapor: true, process(props: LooseRawProps, slots: LooseRawSlots): TeleportFragment { - const frag = new TeleportFragment() - const updateChildrenEffect = renderEffect(() => - frag.updateChildren(slots.default && (slots.default as BlockFn)()), - ) - - const updateEffect = renderEffect(() => { - // access the props to trigger tracking - frag.props = extend( - {}, - new Proxy(props, rawPropsProxyHandlers) as any as TeleportProps, - ) - frag.update() - }) - - if (__DEV__) { - // used in `normalizeBlock` to get nodes of TeleportFragment during - // HMR updates. returns empty array if content is mounted in target - // container to prevent incorrect parent node lookup. - frag.getNodes = () => { - return frag.parent !== frag.currentParent ? [] : frag.nodes - } - - // for HMR rerender - const instance = currentInstance as VaporComponentInstance - ;( - instance!.hmrRerenderEffects || (instance!.hmrRerenderEffects = []) - ).push(() => { - // remove the teleport content - frag.remove() - - // stop effects - updateChildrenEffect.stop() - updateEffect.stop() - }) - - // for HMR reload - const nodes = frag.nodes - if (isVaporComponent(nodes)) { - instanceToTeleportMap.set(nodes, frag) - } else if (isArray(nodes)) { - nodes.forEach( - node => - isVaporComponent(node) && instanceToTeleportMap.set(node, frag), - ) - } - } - - return frag + return new TeleportFragment(props, slots) }, } export class TeleportFragment extends VaporFragment { + anchor?: Node + private rawProps?: LooseRawProps + private resolvedProps?: TeleportProps + private rawSlots?: LooseRawSlots + target?: ParentNode | null targetAnchor?: Node | null - anchor: Node - props?: TeleportProps + targetStart?: Node | null - private targetStart?: Node - private mainAnchor?: Node - private placeholder?: Node - private mountContainer?: ParentNode | null - private mountAnchor?: Node | null + placeholder?: Node + mountContainer?: ParentNode | null + mountAnchor?: Node | null - constructor() { + constructor(props: LooseRawProps, slots: LooseRawSlots) { super([]) - this.anchor = createTextNode() - } + this.rawProps = props + this.rawSlots = slots + this.anchor = isHydrating + ? undefined + : __DEV__ + ? createComment('teleport end') + : createTextNode() - get currentParent(): ParentNode { - return (this.mountContainer || this.parent)! - } + renderEffect(() => { + // access the props to trigger tracking + this.resolvedProps = extend( + {}, + new Proxy( + this.rawProps!, + rawPropsProxyHandlers, + ) as any as TeleportProps, + ) + this.handlePropsUpdate() + }) - get currentAnchor(): Node | null { - return this.mountAnchor || this.anchor + if (!isHydrating) { + this.initChildren() + } } get parent(): ParentNode | null { - return this.anchor && this.anchor.parentNode + return this.anchor ? this.anchor.parentNode : null } - updateChildren(children: Block): void { + private initChildren(): void { + renderEffect(() => { + this.handleChildrenUpdate( + this.rawSlots!.default && (this.rawSlots!.default as BlockFn)(), + ) + }) + + if (__DEV__) { + const nodes = this.nodes + if (isVaporComponent(nodes)) { + nodes.parentTeleport = this + } else if (isArray(nodes)) { + nodes.forEach( + node => isVaporComponent(node) && (node.parentTeleport = this), + ) + } + } + } + + private handleChildrenUpdate(children: Block): void { // not mounted yet - if (!this.parent) { + if (!this.parent || isHydrating) { this.nodes = children return } // teardown previous nodes - remove(this.nodes, this.currentParent) + remove(this.nodes, this.mountContainer!) // mount new nodes - insert((this.nodes = children), this.currentParent, this.currentAnchor) + insert((this.nodes = children), this.mountContainer!, this.mountAnchor!) } - update(): void { + private handlePropsUpdate(): void { // not mounted yet - if (!this.parent) return + if (!this.parent || isHydrating) return const mount = (parent: ParentNode, anchor: Node | null) => { insert( @@ -136,7 +131,7 @@ export class TeleportFragment extends VaporFragment { const mountToTarget = () => { const target = (this.target = resolveTeleportTarget( - this.props!, + this.resolvedProps!, querySelector, )) if (target) { @@ -161,12 +156,12 @@ export class TeleportFragment extends VaporFragment { } // mount into main container - if (isTeleportDisabled(this.props!)) { - mount(this.parent, this.mainAnchor!) + if (isTeleportDisabled(this.resolvedProps!)) { + mount(this.parent, this.anchor!) } // mount into target container else { - if (isTeleportDeferred(this.props!)) { + if (isTeleportDeferred(this.resolvedProps!)) { queuePostFlushCb(mountToTarget) } else { mountToTarget() @@ -175,20 +170,21 @@ export class TeleportFragment extends VaporFragment { } insert = (container: ParentNode, anchor: Node | null): void => { + if (isHydrating) return + // insert anchors in the main view this.placeholder = __DEV__ ? createComment('teleport start') : createTextNode() - this.mainAnchor = __DEV__ ? createComment('teleport end') : createTextNode() insert(this.placeholder, container, anchor) - insert(this.mainAnchor, container, anchor) - this.update() + insert(this.anchor!, container, anchor) + this.handlePropsUpdate() } remove = (parent: ParentNode | undefined = this.parent!): void => { // remove nodes if (this.nodes) { - remove(this.nodes, this.currentParent) + remove(this.nodes, this.mountContainer!) this.nodes = [] } @@ -200,19 +196,99 @@ export class TeleportFragment extends VaporFragment { this.targetAnchor = undefined } + if (this.anchor) { + remove(this.anchor, this.anchor.parentNode!) + this.anchor = undefined + } + if (this.placeholder) { remove(this.placeholder!, parent) this.placeholder = undefined - remove(this.mainAnchor!, parent) - this.mainAnchor = undefined } this.mountContainer = undefined this.mountAnchor = undefined } + private hydrateDisabledTeleport(targetNode: Node | null): void { + let nextNode = this.placeholder!.nextSibling! + setCurrentHydrationNode(nextNode) + this.mountAnchor = this.anchor = locateTeleportEndAnchor(nextNode)! + this.mountContainer = this.anchor.parentNode + this.targetStart = targetNode + this.targetAnchor = targetNode && targetNode.nextSibling + this.initChildren() + } + + private mount(target: Node): void { + target.appendChild((this.targetStart = createTextNode(''))) + target.appendChild( + (this.mountAnchor = this.targetAnchor = createTextNode('')), + ) + + if (!isMismatchAllowed(target as Element, MismatchTypes.CHILDREN)) { + if (__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) { + warn( + `Hydration children mismatch on`, + target, + `\nServer rendered element contains fewer child nodes than client nodes.`, + ) + } + logMismatchError() + } + + runWithoutHydration(this.initChildren.bind(this)) + } + hydrate = (): void => { - // TODO + const target = (this.target = resolveTeleportTarget( + this.resolvedProps!, + querySelector, + )) + const disabled = isTeleportDisabled(this.resolvedProps!) + this.placeholder = currentHydrationNode! + if (target) { + const targetNode = + (target as TeleportTargetElement)._lpa || target.firstChild + if (disabled) { + this.hydrateDisabledTeleport(targetNode) + } else { + this.anchor = locateTeleportEndAnchor()! + this.mountContainer = target + let targetAnchor = targetNode + while (targetAnchor) { + if (targetAnchor && targetAnchor.nodeType === 8) { + if ((targetAnchor as Comment).data === 'teleport start anchor') { + this.targetStart = targetAnchor + } else if ((targetAnchor as Comment).data === 'teleport anchor') { + this.mountAnchor = this.targetAnchor = targetAnchor + ;(target as TeleportTargetElement)._lpa = + this.targetAnchor && this.targetAnchor.nextSibling + break + } + } + targetAnchor = targetAnchor.nextSibling + } + + if (targetNode) { + setCurrentHydrationNode(targetNode.nextSibling) + } + + // if the HTML corresponding to Teleport is not embedded in the + // correct position on the final page during SSR. the targetAnchor will + // always be null, we need to manually add targetAnchor to ensure + // Teleport it can properly unmount or move + if (!this.targetAnchor) { + this.mount(target) + } else { + this.initChildren() + } + } + } else if (disabled) { + this.hydrateDisabledTeleport(currentHydrationNode!) + } + + advanceHydrationNode(this.anchor!) } } @@ -222,24 +298,14 @@ export function isVaporTeleport( return value === VaporTeleportImpl } -/** - * dev only - * during root component HMR reload, since the old component will be unmounted - * and a new one will be mounted, we need to update the teleport's nodes - * to ensure they are up to date. - */ -export function handleTeleportRootComponentHmrReload( - instance: VaporComponentInstance, - newInstance: VaporComponentInstance, -): void { - const teleport = instanceToTeleportMap.get(instance) - if (teleport) { - instanceToTeleportMap.set(newInstance, teleport) - if (teleport.nodes === instance) { - teleport.nodes = newInstance - } else if (isArray(teleport.nodes)) { - const i = teleport.nodes.indexOf(instance) - if (i !== -1) teleport.nodes[i] = newInstance +function locateTeleportEndAnchor( + node: Node = currentHydrationNode!, +): Node | null { + while (node) { + if (isComment(node, 'teleport end')) { + return node } + node = node.nextSibling as Node } + return null } diff --git a/packages/runtime-vapor/src/dom/hydration.ts b/packages/runtime-vapor/src/dom/hydration.ts index ef1fb1375..f6785f348 100644 --- a/packages/runtime-vapor/src/dom/hydration.ts +++ b/packages/runtime-vapor/src/dom/hydration.ts @@ -1,19 +1,19 @@ import { MismatchTypes, isMismatchAllowed, warn } from '@vue/runtime-dom' import { type ChildItem, - incrementIndexOffset, insertionAnchor, insertionParent, resetInsertionState, setInsertionState, } from '../insertionState' import { + _child, _next, - child, createElement, createTextNode, disableHydrationNodeLookup, enableHydrationNodeLookup, + locateChildByLogicalIndex, parentNode, } from './node' import { remove } from '../block' @@ -22,6 +22,15 @@ const isHydratingStack = [] as boolean[] export let isHydrating = false export let currentHydrationNode: Node | null = null +export function runWithoutHydration(fn: () => any): any { + try { + isHydrating = false + return fn() + } finally { + isHydrating = true + } +} + let isOptimized = false function performHydration( @@ -37,12 +46,9 @@ function performHydration( ;(Node.prototype as any).$pns = undefined ;(Node.prototype as any).$uc = undefined ;(Node.prototype as any).$idx = undefined - ;(Node.prototype as any).$children = undefined - ;(Node.prototype as any).$idxMap = undefined ;(Node.prototype as any).$prevDynamicCount = undefined ;(Node.prototype as any).$anchorCount = undefined ;(Node.prototype as any).$appendIndex = undefined - ;(Node.prototype as any).$indexOffset = undefined isOptimized = true } @@ -120,7 +126,6 @@ function adoptTemplateImpl(node: Node, template: string): Node | null { ) { const parent = parentNode(node)! node = parent.insertBefore(createTextNode(), node) - incrementIndexOffset(parent) break } } @@ -143,19 +148,16 @@ function adoptTemplateImpl(node: Node, template: string): Node | null { function locateHydrationNodeImpl(): void { let node: Node | null - let idxMap: number[] | undefined - if (insertionAnchor !== undefined && (idxMap = insertionParent!.$idxMap)) { + if (insertionAnchor !== undefined) { const { $prevDynamicCount: prevDynamicCount = 0, $appendIndex: appendIndex, - $indexOffset: indexOffset = 0, $anchorCount: anchorCount = 0, } = insertionParent! // prepend if (insertionAnchor === 0) { // use prevDynamicCount as logical index to locate the hydration node - const realIndex = idxMap![prevDynamicCount] + indexOffset - node = insertionParent!.childNodes[realIndex] + node = locateChildByLogicalIndex(insertionParent!, prevDynamicCount)! } // insert else if (insertionAnchor instanceof Node) { @@ -166,11 +168,13 @@ function locateHydrationNodeImpl(): void { // consecutive insert operations locate the correct hydration node. let { $idx, $uc: usedCount } = insertionAnchor as ChildItem if (usedCount !== undefined) { - const realIndex = idxMap![$idx + usedCount + 1] + indexOffset - node = insertionParent!.childNodes[realIndex] + node = locateChildByLogicalIndex( + insertionParent!, + ($idx || 0) + usedCount + 1, + )! usedCount++ } else { - node = insertionAnchor + insertionParent!.$lastLogicalChild = node = insertionAnchor // first use of this anchor: it doesn't consume the next child // so we track unique anchor appearances for later offset correction insertionParent!.$anchorCount = anchorCount + 1 @@ -180,22 +184,16 @@ function locateHydrationNodeImpl(): void { } // append else { - let realIndex: number if (appendIndex !== null && appendIndex !== undefined) { - realIndex = idxMap![appendIndex + 1] + indexOffset - node = insertionParent!.childNodes[realIndex] + node = locateChildByLogicalIndex(insertionParent!, appendIndex + 1)! } else { if (insertionAnchor === null) { - // insertionAnchor is null, indicates no previous static nodes - // use the first child as hydration node - realIndex = idxMap![0] + indexOffset - node = insertionParent!.childNodes[realIndex] + node = locateChildByLogicalIndex(insertionParent!, 0)! } else { - // insertionAnchor is a number > 0 - // indicates how many static nodes precede the node to append - // use it as index to locate the hydration node - realIndex = idxMap![prevDynamicCount + insertionAnchor] + indexOffset - node = insertionParent!.childNodes[realIndex] + node = locateChildByLogicalIndex( + insertionParent!, + prevDynamicCount + insertionAnchor, + )! } } insertionParent!.$appendIndex = (node as ChildItem).$idx @@ -246,15 +244,6 @@ export function locateEndAnchor( return null } -export function locateFragmentEndAnchor(label: string = ']'): Comment | null { - let node = currentHydrationNode! - while (node) { - if (isComment(node, label)) return node - node = node.nextSibling! - } - return null -} - function handleMismatch(node: Node, template: string): Node { if (!isMismatchAllowed(node.parentElement!, MismatchTypes.CHILDREN)) { ;(__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) && @@ -297,7 +286,7 @@ function handleMismatch(node: Node, template: string): Node { // element node const t = createElement('template') as HTMLTemplateElement t.innerHTML = template - const newNode = child(t.content).cloneNode(true) as Element + const newNode = _child(t.content).cloneNode(true) as Element newNode.innerHTML = (node as Element).innerHTML Array.from((node as Element).attributes).forEach(attr => { newNode.setAttribute(attr.name, attr.value) diff --git a/packages/runtime-vapor/src/dom/node.ts b/packages/runtime-vapor/src/dom/node.ts index 945c55fe0..5ec574e85 100644 --- a/packages/runtime-vapor/src/dom/node.ts +++ b/packages/runtime-vapor/src/dom/node.ts @@ -1,6 +1,7 @@ /* @__NO_SIDE_EFFECTS__ */ import type { ChildItem, InsertionParent } from '../insertionState' +import { isComment, locateEndAnchor } from './hydration' export function createElement(tagName: string): HTMLElement { return document.createElement(tagName) @@ -30,14 +31,14 @@ export function parentNode(node: Node): ParentNode | null { const _txt: typeof _child = _child /** - * Hydration-specific version of `child`. + * Hydration-specific version of `txt`. */ /* @__NO_SIDE_EFFECTS__ */ -const __txt: typeof __child = (node: ParentNode): Node => { +const __txt = (node: ParentNode): Node => { let n = node.firstChild! - // since SSR doesn't generate whitespace placeholder text nodes, if firstChild - // is null, manually insert a text node as the first child + // since SSR doesn't generate blank text nodes, + // manually insert a text node as the first child if (!n) { return node.appendChild(createTextNode()) } @@ -47,74 +48,44 @@ const __txt: typeof __child = (node: ParentNode): Node => { /* @__NO_SIDE_EFFECTS__ */ export function _child(node: InsertionParent): Node { - const children = node.$children - return children ? children[0] : node.firstChild! + return node.firstChild! } /** * Hydration-specific version of `child`. */ /* @__NO_SIDE_EFFECTS__ */ -export function __child(node: ParentNode): Node { - return __nthChild(node, 0)! +export function __child(node: ParentNode, logicalIndex: number = 0): Node { + return locateChildByLogicalIndex(node as InsertionParent, logicalIndex)! } /* @__NO_SIDE_EFFECTS__ */ export function _nthChild(node: InsertionParent, i: number): Node { - const children = node.$children - return children ? children[i] : node.childNodes[i] + return node.childNodes[i] } /** * Hydration-specific version of `nthChild`. */ /* @__NO_SIDE_EFFECTS__ */ -export function __nthChild(node: Node, i: number): Node { - const parent = node as InsertionParent - if (parent.$idxMap) { - const { - $prevDynamicCount: prevDynamicCount = 0, - $anchorCount: anchorCount = 0, - $idxMap: idxMap, - $indexOffset: indexOffset = 0, - } = parent - // prevDynamicCount tracks how many dynamic nodes have been processed - // so far (prepend/insert/append). - // For anchor-based insert, the first time an anchor is used we adopt the - // anchor node itself and do NOT consume the next child in `idxMap`, - // yet prevDynamicCount is still incremented. This overcounts the base - // offset by 1 per unique anchor that has appeared. - // anchorCount equals the number of unique anchors seen, so we - // subtract it to neutralize those "first-use doesn't consume" cases: - // base = prevDynamicCount - anchorCount - // Then index from this base: idxMap[base + i] + indexOffset. - const logicalIndex = prevDynamicCount - anchorCount + i - const realIndex = idxMap[logicalIndex] + indexOffset - return node.childNodes[realIndex] - } - return node.childNodes[i] +export function __nthChild(node: Node, logicalIndex: number): Node { + return locateChildByLogicalIndex(node as InsertionParent, logicalIndex)! } /* @__NO_SIDE_EFFECTS__ */ export function _next(node: Node): Node { - const children = (node.parentNode! as InsertionParent).$children - return children ? children[(node as ChildItem).$idx + 1] : node.nextSibling! + return node.nextSibling! } /** * Hydration-specific version of `next`. */ /* @__NO_SIDE_EFFECTS__ */ -export function __next(node: Node): Node { - const parent = node.parentNode! as InsertionParent - if (parent.$idxMap) { - const { $idxMap: idxMap, $indexOffset: indexOffset = 0 } = parent - const { $idx, $uc: usedCount = 0 } = node as ChildItem - const logicalIndex = $idx + usedCount + 1 - const realIndex = idxMap[logicalIndex] + indexOffset - return node.parentNode!.childNodes[realIndex] - } - return node.nextSibling! +export function __next(node: Node, logicalIndex: number): Node { + return locateChildByLogicalIndex( + node.parentNode! as InsertionParent, + logicalIndex, + )! } type DelegatedFunction any> = T & { @@ -122,26 +93,26 @@ type DelegatedFunction any> = T & { } /* @__NO_SIDE_EFFECTS__ */ -export const txt: DelegatedFunction = node => { - return txt.impl(node) +export const txt: DelegatedFunction = (...args) => { + return txt.impl(...args) } -txt.impl = _child +txt.impl = _txt /* @__NO_SIDE_EFFECTS__ */ -export const child: DelegatedFunction = node => { - return child.impl(node) +export const child: DelegatedFunction = (...args) => { + return child.impl(...args) } child.impl = _child /* @__NO_SIDE_EFFECTS__ */ -export const next: DelegatedFunction = node => { - return next.impl(node) +export const next: DelegatedFunction = (...args) => { + return next.impl(...args) } next.impl = _next /* @__NO_SIDE_EFFECTS__ */ -export const nthChild: DelegatedFunction = (node, i) => { - return nthChild.impl(node, i) +export const nthChild: DelegatedFunction = (...args) => { + return nthChild.impl(...args) } nthChild.impl = _nthChild @@ -155,9 +126,9 @@ nthChild.impl = _nthChild */ export function enableHydrationNodeLookup(): void { txt.impl = __txt - child.impl = __child - next.impl = __next - nthChild.impl = __nthChild + child.impl = __child as typeof _child + next.impl = __next as typeof _next + nthChild.impl = __nthChild as any as typeof _nthChild } export function disableHydrationNodeLookup(): void { @@ -166,3 +137,29 @@ export function disableHydrationNodeLookup(): void { next.impl = _next nthChild.impl = _nthChild } + +export function locateChildByLogicalIndex( + node: InsertionParent, + logicalIndex: number, +): Node | null { + let child = (node.$lastLogicalChild || node.firstChild) as ChildItem + let fromIndex = child.$idx || 0 + + while (child) { + if (fromIndex === logicalIndex) { + child.$idx = logicalIndex + return (node.$lastLogicalChild = child) + } + + child = ( + isComment(child, '[') + ? // fragment start: jump to the node after the matching end anchor + locateEndAnchor(child)!.nextSibling + : child.nextSibling + ) as ChildItem + + fromIndex++ + } + + return null +} diff --git a/packages/runtime-vapor/src/dom/prop.ts b/packages/runtime-vapor/src/dom/prop.ts index 4646572ea..b9a607b34 100644 --- a/packages/runtime-vapor/src/dom/prop.ts +++ b/packages/runtime-vapor/src/dom/prop.ts @@ -386,8 +386,7 @@ export function optimizePropertyLookup(): void { const proto = Element.prototype as any proto.$transition = undefined proto.$key = undefined - proto.$evtclick = undefined - proto.$children = undefined + proto.$prependAnchor = proto.$evtclick = undefined proto.$idx = undefined proto.$root = false proto.$html = diff --git a/packages/runtime-vapor/src/dom/template.ts b/packages/runtime-vapor/src/dom/template.ts index 2022cf339..734911301 100644 --- a/packages/runtime-vapor/src/dom/template.ts +++ b/packages/runtime-vapor/src/dom/template.ts @@ -1,15 +1,8 @@ import { adoptTemplate, currentHydrationNode, isHydrating } from './hydration' -import { child, createElement, createTextNode } from './node' +import { _child, createElement, createTextNode } from './node' let t: HTMLTemplateElement -export let currentTemplateFn: (Function & { $idxMap?: number[] }) | undefined = - undefined - -export function resetTemplateFn(): void { - currentTemplateFn = undefined -} - /*! #__NO_SIDE_EFFECTS__ */ export function template( html: string, @@ -18,8 +11,6 @@ export function template( let node: Node const fn = () => { if (isHydrating) { - currentTemplateFn = fn - // do not cache the adopted node in node because it contains child nodes // this avoids duplicate rendering of children const adopted = adoptTemplate(currentHydrationNode!, html)! @@ -34,7 +25,7 @@ export function template( if (!node) { t = t || createElement('template') t.innerHTML = html - node = child(t.content) + node = _child(t.content) } const ret = node.cloneNode(true) if (root) (ret as any).$root = true diff --git a/packages/runtime-vapor/src/fragment.ts b/packages/runtime-vapor/src/fragment.ts index b13458cfd..38e074001 100644 --- a/packages/runtime-vapor/src/fragment.ts +++ b/packages/runtime-vapor/src/fragment.ts @@ -9,13 +9,11 @@ import { isValidBlock, remove, } from './block' -import type { TransitionHooks } from '@vue/runtime-dom' +import { type TransitionHooks, queuePostFlushCb } from '@vue/runtime-dom' import { - advanceHydrationNode, currentHydrationNode, isComment, isHydrating, - locateFragmentEndAnchor, locateHydrationNode, } from './dom/hydration' import { @@ -24,7 +22,6 @@ import { } from './components/Transition' import { type VaporComponentInstance, isVaporComponent } from './component' import { isArray } from '@vue/shared' -import { incrementIndexOffset } from './insertionState' export class VaporFragment implements TransitionOptions @@ -42,7 +39,6 @@ export class VaporFragment remove?: (parent?: ParentNode, transitionHooks?: TransitionHooks) => void fallback?: BlockFn - getNodes?: () => Block setRef?: (comp: VaporComponentInstance) => void constructor(nodes: T) { @@ -149,7 +145,7 @@ export class DynamicFragment extends VaporFragment { // reuse the empty comment node as the anchor for empty if if (this.anchorLabel === 'if' && isEmpty) { - this.anchor = locateFragmentEndAnchor('')! + this.anchor = currentHydrationNode! if (!this.anchor) { throw new Error('Failed to locate if anchor') } else { @@ -172,7 +168,7 @@ export class DynamicFragment extends VaporFragment { } // reuse the vdom fragment end anchor for slots - this.anchor = locateFragmentEndAnchor()! + this.anchor = currentHydrationNode! if (!this.anchor) { throw new Error('Failed to locate slot anchor') } else { @@ -182,13 +178,14 @@ export class DynamicFragment extends VaporFragment { // create an anchor const { parentNode, nextSibling } = findLastChild(this)! - parentNode!.insertBefore( - (this.anchor = createComment(this.anchorLabel!)), - nextSibling, - ) - // increment index offset since we dynamically inserted a comment node - incrementIndexOffset(parentNode!) - advanceHydrationNode(this.anchor) + queuePostFlushCb(() => { + parentNode!.insertBefore( + (this.anchor = __DEV__ + ? createComment(this.anchorLabel!) + : createTextNode()), + nextSibling, + ) + }) } } @@ -241,7 +238,7 @@ export function findLastChild(node: Block): Node | undefined | null { } else if (isVaporComponent(node)) { return findLastChild(node.block!) } else { - if (node instanceof DynamicFragment && node.anchor) return node.anchor + if (node.anchor) return node.anchor return findLastChild(node.nodes!) } } diff --git a/packages/runtime-vapor/src/hmr.ts b/packages/runtime-vapor/src/hmr.ts index 17b1bd0f2..ceb15ec0a 100644 --- a/packages/runtime-vapor/src/hmr.ts +++ b/packages/runtime-vapor/src/hmr.ts @@ -12,19 +12,19 @@ import { mountComponent, unmountComponent, } from './component' -import { handleTeleportRootComponentHmrReload } from './components/Teleport' +import { isArray } from '@vue/shared' export function hmrRerender(instance: VaporComponentInstance): void { const normalized = normalizeBlock(instance.block) const parent = normalized[0].parentNode! const anchor = normalized[normalized.length - 1].nextSibling remove(instance.block, parent) - if (instance.hmrRerenderEffects) { - instance.hmrRerenderEffects.forEach(e => e()) - instance.hmrRerenderEffects.length = 0 - } const prev = setCurrentInstance(instance) pushWarningContext(instance) + if (instance.renderEffects) { + instance.renderEffects.forEach(e => e.stop()) + instance.renderEffects = [] + } devRender(instance) popWarningContext() setCurrentInstance(...prev) @@ -39,7 +39,8 @@ export function hmrReload( const parent = normalized[0].parentNode! const anchor = normalized[normalized.length - 1].nextSibling unmountComponent(instance, parent) - const prev = setCurrentInstance(instance.parent) + const parentInstance = instance.parent as VaporComponentInstance | null + const prev = setCurrentInstance(parentInstance) const newInstance = createComponent( newComp, instance.rawProps, @@ -48,5 +49,59 @@ export function hmrReload( ) setCurrentInstance(...prev) mountComponent(newInstance, parent, anchor) - handleTeleportRootComponentHmrReload(instance, newInstance) + + updateParentBlockOnHmrReload(parentInstance, instance, newInstance) + updateParentTeleportOnHmrReload(instance, newInstance) +} + +/** + * dev only + * update parentInstance.block to ensure that the correct parent and + * anchor are found during parentInstance HMR rerender/reload, as + * `normalizeBlock` relies on the current instance.block + */ +function updateParentBlockOnHmrReload( + parentInstance: VaporComponentInstance | null, + instance: VaporComponentInstance, + newInstance: VaporComponentInstance, +): void { + if (parentInstance) { + if (parentInstance.block === instance) { + parentInstance.block = newInstance + } else if (isArray(parentInstance.block)) { + for (let i = 0; i < parentInstance.block.length; i++) { + if (parentInstance.block[i] === instance) { + parentInstance.block[i] = newInstance + break + } + } + } + } +} + +/** + * dev only + * during root component HMR reload, since the old component will be unmounted + * and a new one will be mounted, we need to update the teleport's nodes + * to ensure that the correct parent and anchor are found during parentInstance + * HMR rerender/reload, as `normalizeBlock` relies on the current instance.block + */ +export function updateParentTeleportOnHmrReload( + instance: VaporComponentInstance, + newInstance: VaporComponentInstance, +): void { + const teleport = instance.parentTeleport + if (teleport) { + newInstance.parentTeleport = teleport + if (teleport.nodes === instance) { + teleport.nodes = newInstance + } else if (isArray(teleport.nodes)) { + for (let i = 0; i < teleport.nodes.length; i++) { + if (teleport.nodes[i] === instance) { + teleport.nodes[i] = newInstance + break + } + } + } + } } diff --git a/packages/runtime-vapor/src/insertionState.ts b/packages/runtime-vapor/src/insertionState.ts index fc015c219..10262bb20 100644 --- a/packages/runtime-vapor/src/insertionState.ts +++ b/packages/runtime-vapor/src/insertionState.ts @@ -1,5 +1,4 @@ -import { isComment, isHydrating } from './dom/hydration' -import { currentTemplateFn, resetTemplateFn } from './dom/template' +import { isHydrating } from './dom/hydration' export type ChildItem = ChildNode & { $idx: number // used count as an anchor @@ -7,20 +6,19 @@ export type ChildItem = ChildNode & { } export type InsertionParent = ParentNode & { - $children?: ChildItem[] + $prependAnchor?: Node | null + /** * hydration-specific properties */ - // mapping from logical index to real index in childNodes - $idxMap?: number[] // hydrated dynamic children count so far $prevDynamicCount?: number // number of unique insertion anchors that have appeared $anchorCount?: number // last append index $appendIndex?: number | null - // number of dynamically inserted nodes (e.g., comment anchors) - $indexOffset?: number + // last located logical child + $lastLogicalChild?: Node | null } export let insertionParent: InsertionParent | undefined export let insertionAnchor: Node | 0 | undefined | null @@ -31,7 +29,7 @@ export let insertionAnchor: Node | 0 | undefined | null * insertion on client-side render, and used for node adoption during hydration. */ export function setInsertionState( - parent: ParentNode, + parent: ParentNode & { $prependAnchor?: Node | null }, anchor?: Node | 0 | null | number, ): void { insertionParent = parent @@ -39,119 +37,26 @@ export function setInsertionState( if (anchor !== undefined) { if (isHydrating) { insertionAnchor = anchor as Node - initializeHydrationState(parent) - resetTemplateFn() + // when the setInsertionState is called for the first time, reset $lastLogicalChild, + // in order to reuse it in locateChildByLogicalIndex + if (insertionParent.$prevDynamicCount === undefined) { + insertionParent!.$lastLogicalChild = null + } } else { // special handling append anchor value to null insertionAnchor = typeof anchor === 'number' && anchor > 0 ? null : (anchor as Node) - cacheTemplateChildren(parent) + + // track the first child for potential future use + if (anchor === 0 && !parent.$prependAnchor) { + parent.$prependAnchor = parent.firstChild + } } } else { insertionAnchor = undefined } } -function initializeHydrationState(parent: InsertionParent) { - if (!parent.$idxMap) { - const childNodes = parent.childNodes - const len = childNodes.length - - // fast path for single child case. use first child as hydration node - // no need to build logical index map - if ( - len === 1 || - (len === 3 && - isComment(childNodes[0], '[') && - isComment(childNodes[2], ']')) - ) { - insertionAnchor = undefined - return - } - - if (currentTemplateFn) { - if (currentTemplateFn.$idxMap) { - const idxMap = (parent.$idxMap = currentTemplateFn.$idxMap) - // set $idx to childNodes - for (let i = 0; i < idxMap.length; i++) { - ;(childNodes[idxMap[i]] as ChildItem).$idx = i - } - } else { - parent.$idxMap = currentTemplateFn.$idxMap = buildLogicalIndexMap( - len, - childNodes, - ) - } - } else { - parent.$idxMap = buildLogicalIndexMap(len, childNodes) - } - parent.$prevDynamicCount = 0 - parent.$anchorCount = 0 - parent.$appendIndex = null - parent.$indexOffset = 0 - } -} - -function buildLogicalIndexMap(len: number, childNodes: NodeListOf) { - const idxMap = new Array() as number[] - // Build logical index map: - // - static node: map logical index to real index - // - fragment: map logical index to start anchor's real index - let logicalIndex = 0 - for (let i = 0; i < len; i++) { - const n = childNodes[i] as ChildItem - n.$idx = logicalIndex - if (n.nodeType === 8) { - const data = (n as any as Comment).data - // vdom fragment - if (data === '[') { - idxMap[logicalIndex++] = i - // find matching end anchor, accounting for nested fragments - let depth = 1 - let j = i + 1 - for (; j < len; j++) { - const c = childNodes[j] as Comment - if (c.nodeType === 8) { - const d = c.data - if (d === '[') depth++ - else if (d === ']') { - depth-- - if (depth === 0) break - } - } - } - // jump i to the end anchor - i = j - continue - } - } - idxMap[logicalIndex++] = i - } - return idxMap -} - -function cacheTemplateChildren(parent: InsertionParent) { - if (!parent.$children) { - const nodes = parent.childNodes - const len = nodes.length - if (len === 0) return - - const children = new Array(len) as ChildItem[] - for (let i = 0; i < len; i++) { - const node = nodes[i] as ChildItem - node.$idx = i - children[i] = node - } - parent.$children = children - } -} - export function resetInsertionState(): void { insertionParent = insertionAnchor = undefined } - -export function incrementIndexOffset(parent: InsertionParent): void { - if (parent.$indexOffset !== undefined) { - parent.$indexOffset++ - } -} diff --git a/packages/runtime-vapor/src/renderEffect.ts b/packages/runtime-vapor/src/renderEffect.ts index 8317c2130..3c937c0ed 100644 --- a/packages/runtime-vapor/src/renderEffect.ts +++ b/packages/runtime-vapor/src/renderEffect.ts @@ -11,7 +11,7 @@ import { import { type VaporComponentInstance, isVaporComponent } from './component' import { invokeArrayFns } from '@vue/shared' -class RenderEffect extends ReactiveEffect { +export class RenderEffect extends ReactiveEffect { i: VaporComponentInstance | null job: SchedulerJob updateJob: SchedulerJob @@ -41,6 +41,9 @@ class RenderEffect extends ReactiveEffect { this.onTrigger = instance.rtg ? e => invokeArrayFns(instance.rtg!, e) : void 0 + + // register effect for stopping them during HMR rerender + ;(instance.renderEffects || (instance.renderEffects = [])).push(this) } job.i = instance } @@ -82,14 +85,10 @@ class RenderEffect extends ReactiveEffect { } } -export function renderEffect( - fn: () => void, - noLifecycle = false, -): RenderEffect { +export function renderEffect(fn: () => void, noLifecycle = false): void { const effect = new RenderEffect(fn) if (noLifecycle) { effect.fn = fn } effect.run() - return effect } diff --git a/packages/runtime-vapor/src/vdomInterop.ts b/packages/runtime-vapor/src/vdomInterop.ts index 9a979c4e4..d00778882 100644 --- a/packages/runtime-vapor/src/vdomInterop.ts +++ b/packages/runtime-vapor/src/vdomInterop.ts @@ -59,7 +59,6 @@ import { currentHydrationNode, isComment, isHydrating, - locateFragmentEndAnchor, locateHydrationNode, setCurrentHydrationNode, hydrateNode as vaporHydrateNode, @@ -75,9 +74,7 @@ const vaporInteropImpl: Omit< > = { mount(vnode, container, anchor, parentComponent) { let selfAnchor = (vnode.el = vnode.anchor = createTextNode()) - if (!isHydrating) { - container.insertBefore(selfAnchor, anchor) - } + container.insertBefore(selfAnchor, anchor) const prev = currentInstance simpleSetCurrentInstance(parentComponent) @@ -119,12 +116,6 @@ const vaporInteropImpl: Omit< vnode.transition as VaporTransitionHooks, ) } - if (isHydrating) { - // insert self anchor after hydration completed to avoid mismatching - ;(instance.m || (instance.m = [])).push(() => { - container.insertBefore(selfAnchor, anchor) - }) - } mountComponent(instance, container, selfAnchor) simpleSetCurrentInstance(prev) return instance @@ -198,8 +189,7 @@ const vaporInteropImpl: Omit< const propsRef = (vnode.vs!.ref = shallowRef(vnode.props)) vaporHydrateNode(node, () => { vnode.vb = slot(new Proxy(propsRef, vaporSlotPropsProxyHandler)) - vnode.el = currentHydrationNode! - vnode.anchor = locateFragmentEndAnchor() + vnode.anchor = vnode.el = currentHydrationNode! if (__DEV__ && !vnode.anchor) { throw new Error(`Failed to locate slot anchor`)