From 086caa37d47637ce71b9feade30e2d678611fc46 Mon Sep 17 00:00:00 2001 From: daiwei Date: Mon, 1 Jun 2026 11:49:56 +0800 Subject: [PATCH 01/13] refactor(runtime-vapor): simplify slot fallback model Remove the carrier/fallback outlet model and make slot fallback switching rely on the slot boundary state directly. Gate slot dirty tracking behind compiler-emitted slot-root flags so only root v-if/v-for/slot/dynamic component branches register fallback rechecks. --- .../TransformTransition.spec.ts.snap | 6 +- .../__snapshots__/logicalIndex.spec.ts.snap | 14 +- .../transformElement.spec.ts.snap | 6 +- .../__snapshots__/transformKey.spec.ts.snap | 8 +- .../transformSlotOutlet.spec.ts.snap | 70 +++ .../transformTemplateRef.spec.ts.snap | 2 +- .../__snapshots__/vFor.spec.ts.snap | 2 +- .../__snapshots__/vHtml.spec.ts.snap | 2 +- .../transforms/__snapshots__/vIf.spec.ts.snap | 16 +- .../__snapshots__/vSlot.spec.ts.snap | 84 ++- .../__snapshots__/vText.spec.ts.snap | 2 +- .../transforms/transformSlotOutlet.spec.ts | 34 ++ .../__tests__/transforms/vSlot.spec.ts | 22 + .../compiler-vapor/src/generators/block.ts | 76 ++- .../src/generators/component.ts | 28 +- packages/compiler-vapor/src/generators/for.ts | 4 + packages/compiler-vapor/src/generators/if.ts | 23 +- .../src/generators/slotOutlet.ts | 3 +- packages/compiler-vapor/src/ir/index.ts | 3 + packages/runtime-core/src/apiCreateApp.ts | 1 + .../apiCreateDynamicComponent.spec.ts | 20 +- .../__tests__/componentAttrs.spec.ts | 4 +- .../__tests__/componentSlots.spec.ts | 549 +++++++++++------ .../runtime-vapor/__tests__/dom/prop.spec.ts | 30 +- .../runtime-vapor/__tests__/hydration.spec.ts | 36 +- .../runtime-vapor/__tests__/scopeId.spec.ts | 23 +- .../__tests__/vdomInterop.spec.ts | 12 +- .../src/apiCreateDynamicComponent.ts | 12 +- packages/runtime-vapor/src/apiCreateFor.ts | 14 +- packages/runtime-vapor/src/apiCreateIf.ts | 5 +- .../src/apiDefineAsyncComponent.ts | 4 + packages/runtime-vapor/src/block.ts | 19 +- packages/runtime-vapor/src/componentSlots.ts | 3 +- packages/runtime-vapor/src/fragment.ts | 568 +++++++----------- packages/runtime-vapor/src/vdomInterop.ts | 222 ++++--- packages/shared/src/vaporFlags.ts | 24 +- 36 files changed, 1172 insertions(+), 779 deletions(-) diff --git a/packages/compiler-vapor/__tests__/transforms/__snapshots__/TransformTransition.spec.ts.snap b/packages/compiler-vapor/__tests__/transforms/__snapshots__/TransformTransition.spec.ts.snap index 16d1779dd5e..eec798e684b 100644 --- a/packages/compiler-vapor/__tests__/transforms/__snapshots__/TransformTransition.spec.ts.snap +++ b/packages/compiler-vapor/__tests__/transforms/__snapshots__/TransformTransition.spec.ts.snap @@ -51,9 +51,9 @@ export function render(_ctx) { }, () => { const n13 = t1() return n13 - }, 389 /* BLOCK_SHAPE, INDEX_SHIFT */) + }, 773 /* BLOCK_SHAPE, INDEX_SHIFT */) return n14 - }, 261 /* BLOCK_SHAPE, INDEX_SHIFT */), 133 /* BLOCK_SHAPE, INDEX_SHIFT */) + }, 645 /* BLOCK_SHAPE, SLOT_ROOT, INDEX_SHIFT */), 389 /* BLOCK_SHAPE, SLOT_ROOT, INDEX_SHIFT */) return [n0, n3, n7] }, true) return n16 @@ -102,7 +102,7 @@ export function render(_ctx) { const n0 = _createIf(() => (_ctx.show), () => { const n2 = t0() return n2 - }) + }, null, 129 /* BLOCK_SHAPE, SLOT_ROOT */) return n0 }, true) return n3 diff --git a/packages/compiler-vapor/__tests__/transforms/__snapshots__/logicalIndex.spec.ts.snap b/packages/compiler-vapor/__tests__/transforms/__snapshots__/logicalIndex.spec.ts.snap index ed0cd4ef6bc..2135374ab45 100644 --- a/packages/compiler-vapor/__tests__/transforms/__snapshots__/logicalIndex.spec.ts.snap +++ b/packages/compiler-vapor/__tests__/transforms/__snapshots__/logicalIndex.spec.ts.snap @@ -399,7 +399,7 @@ export function render(_ctx) { }, () => { const n5 = t1() return n5 - }, 229 /* BLOCK_SHAPE, TRUE_NO_SCOPE, FALSE_NO_SCOPE, INDEX_SHIFT */) + }, 357 /* BLOCK_SHAPE, TRUE_NO_SCOPE, FALSE_NO_SCOPE, INDEX_SHIFT */) _setInsertionState(n7, null, 2) const n6 = _createAssetComponent("Comp2") return n7 @@ -423,7 +423,7 @@ export function render(_ctx) { }, () => { const n5 = t1() return n5 - }, 229 /* BLOCK_SHAPE, TRUE_NO_SCOPE, FALSE_NO_SCOPE, INDEX_SHIFT */) + }, 357 /* BLOCK_SHAPE, TRUE_NO_SCOPE, FALSE_NO_SCOPE, INDEX_SHIFT */) return n6 }" `; @@ -444,7 +444,7 @@ export function render(_ctx) { }, () => { const n4 = t1() return n4 - }, 229 /* BLOCK_SHAPE, TRUE_NO_SCOPE, FALSE_NO_SCOPE, INDEX_SHIFT */) + }, 357 /* BLOCK_SHAPE, TRUE_NO_SCOPE, FALSE_NO_SCOPE, INDEX_SHIFT */) return n6 }" `; @@ -469,7 +469,7 @@ export function render(_ctx) { }, () => { const n6 = t2() return n6 - }, 357 /* BLOCK_SHAPE, TRUE_NO_SCOPE, FALSE_NO_SCOPE, INDEX_SHIFT */), 165 /* BLOCK_SHAPE, TRUE_NO_SCOPE, INDEX_SHIFT */) + }, 613 /* BLOCK_SHAPE, TRUE_NO_SCOPE, FALSE_NO_SCOPE, INDEX_SHIFT */), 293 /* BLOCK_SHAPE, TRUE_NO_SCOPE, INDEX_SHIFT */) return n8 }" `; @@ -489,7 +489,7 @@ export function render(_ctx) { }, () => { const n4 = t1() return n4 - }, 229 /* BLOCK_SHAPE, TRUE_NO_SCOPE, FALSE_NO_SCOPE, INDEX_SHIFT */) + }, 357 /* BLOCK_SHAPE, TRUE_NO_SCOPE, FALSE_NO_SCOPE, INDEX_SHIFT */) return n5 }" `; @@ -509,7 +509,7 @@ export function render(_ctx) { }, () => { const n4 = t1() return n4 - }, 229 /* BLOCK_SHAPE, TRUE_NO_SCOPE, FALSE_NO_SCOPE, INDEX_SHIFT */) + }, 357 /* BLOCK_SHAPE, TRUE_NO_SCOPE, FALSE_NO_SCOPE, INDEX_SHIFT */) _setInsertionState(n6, null, 2) const n5 = _createAssetComponent("Comp") return n6 @@ -531,7 +531,7 @@ export function render(_ctx) { }, () => { const n4 = t1() return n4 - }, 229 /* BLOCK_SHAPE, TRUE_NO_SCOPE, FALSE_NO_SCOPE, INDEX_SHIFT */) + }, 357 /* BLOCK_SHAPE, TRUE_NO_SCOPE, FALSE_NO_SCOPE, INDEX_SHIFT */) return n5 }" `; diff --git a/packages/compiler-vapor/__tests__/transforms/__snapshots__/transformElement.spec.ts.snap b/packages/compiler-vapor/__tests__/transforms/__snapshots__/transformElement.spec.ts.snap index cca03eb0209..15a909ade83 100644 --- a/packages/compiler-vapor/__tests__/transforms/__snapshots__/transformElement.spec.ts.snap +++ b/packages/compiler-vapor/__tests__/transforms/__snapshots__/transformElement.spec.ts.snap @@ -149,7 +149,7 @@ export function render(_ctx) { const n1 = _createIf(() => (_ctx.ok), () => { const n3 = _createComponentWithFallback(_component_Child) return n3 - }) + }, null, 129 /* BLOCK_SHAPE, SLOT_ROOT */) return n1 })) return [n0, n5] @@ -814,7 +814,7 @@ exports[`compiler: element transform > dynamic component > dynamic binding 1`] = "import { createDynamicComponent as _createDynamicComponent } from 'vue'; export function render(_ctx) { - const n0 = _createDynamicComponent(() => (_ctx.foo), null, null, true) + const n0 = _createDynamicComponent(() => (_ctx.foo), null, null, 1) return n0 }" `; @@ -823,7 +823,7 @@ exports[`compiler: element transform > dynamic component > dynamic binding short "import { createDynamicComponent as _createDynamicComponent } from 'vue'; export function render(_ctx) { - const n0 = _createDynamicComponent(() => (_ctx.is), null, null, true) + const n0 = _createDynamicComponent(() => (_ctx.is), null, null, 1) return n0 }" `; diff --git a/packages/compiler-vapor/__tests__/transforms/__snapshots__/transformKey.spec.ts.snap b/packages/compiler-vapor/__tests__/transforms/__snapshots__/transformKey.spec.ts.snap index 8740f5b7ef5..1112a88fc1e 100644 --- a/packages/compiler-vapor/__tests__/transforms/__snapshots__/transformKey.spec.ts.snap +++ b/packages/compiler-vapor/__tests__/transforms/__snapshots__/transformKey.spec.ts.snap @@ -5,7 +5,7 @@ exports[`compiler: key > with dynamic key > + key 1`] = ` export function render(_ctx) { const n0 = _createKeyedFragment(() => (_ctx.id), () => { - const n1 = _createDynamicComponent(() => (_ctx.view), null, null, true) + const n1 = _createDynamicComponent(() => (_ctx.view), null, null, 1) return n1 }) return n0 @@ -125,7 +125,7 @@ export function render(_ctx) { return n5 }) return n4 - }), 165 /* BLOCK_SHAPE, TRUE_NO_SCOPE, INDEX_SHIFT */) + }), 293 /* BLOCK_SHAPE, TRUE_NO_SCOPE, INDEX_SHIFT */) return n0 }" `; @@ -163,7 +163,7 @@ exports[`compiler: key > with static key > + key 1`] = ` "import { createDynamicComponent as _createDynamicComponent, setBlockKey as _setBlockKey } from 'vue'; export function render(_ctx) { - const n0 = _createDynamicComponent(() => ('div'), null, null, true) + const n0 = _createDynamicComponent(() => ('div'), null, null, 1) _setBlockKey(n0, "1") return n0 }" @@ -173,7 +173,7 @@ exports[`compiler: key > with static key > + key 1`] = ` "import { createDynamicComponent as _createDynamicComponent, setBlockKey as _setBlockKey } from 'vue'; export function render(_ctx) { - const n0 = _createDynamicComponent(() => (_ctx.view), null, null, true) + const n0 = _createDynamicComponent(() => (_ctx.view), null, null, 1) _setBlockKey(n0, "1") return n0 }" diff --git a/packages/compiler-vapor/__tests__/transforms/__snapshots__/transformSlotOutlet.spec.ts.snap b/packages/compiler-vapor/__tests__/transforms/__snapshots__/transformSlotOutlet.spec.ts.snap index df1d973768a..f3fea0c32cf 100644 --- a/packages/compiler-vapor/__tests__/transforms/__snapshots__/transformSlotOutlet.spec.ts.snap +++ b/packages/compiler-vapor/__tests__/transforms/__snapshots__/transformSlotOutlet.spec.ts.snap @@ -48,6 +48,25 @@ export function render(_ctx) { }" `; +exports[`compiler: transform outlets > does not mark non-root fallback v-if as slot root 1`] = ` +"import { setInsertionState as _setInsertionState, createIf as _createIf, createSlot as _createSlot, template as _template } from 'vue'; +const t0 = _template("", 2) +const t1 = _template("
", 1) + +export function render(_ctx) { + const n0 = _createSlot("default", null, () => { + const n5 = t1() + _setInsertionState(n5, null, 0) + const n2 = _createIf(() => (_ctx.ok), () => { + const n4 = t0() + return n4 + }) + return n5 + }) + return n0 +}" +`; + exports[`compiler: transform outlets > dynamically named slot outlet 1`] = ` "import { createSlot as _createSlot } from 'vue'; @@ -111,6 +130,57 @@ export function render(_ctx) { }" `; +exports[`compiler: transform outlets > nested root v-for fallback 1`] = ` +"import { txt as _txt, toDisplayString as _toDisplayString, setText as _setText, renderEffect as _renderEffect, createFor as _createFor, createIf as _createIf, createSlot as _createSlot, template as _template } from 'vue'; +const t0 = _template(" ") + +export function render(_ctx) { + const n0 = _createSlot("default", null, () => { + const n2 = _createIf(() => (_ctx.ok), () => { + const n4 = _createFor(() => (_ctx.items), (_for_item0) => { + const n6 = t0() + const x6 = _txt(n6) + _renderEffect(() => _setText(x6, _toDisplayString(_for_item0.value))) + return n6 + }, undefined, 40) + const x4 = _txt(n4) + _renderEffect(() => _setText(x4, _toDisplayString(_ctx.item))) + return n4 + }, null, 129 /* BLOCK_SHAPE, SLOT_ROOT */) + return n2 + }) + return n0 +}" +`; + +exports[`compiler: transform outlets > root dynamic component fallback 1`] = ` +"import { createDynamicComponent as _createDynamicComponent, createSlot as _createSlot } from 'vue'; + +export function render(_ctx) { + const n0 = _createSlot("default", null, () => { + const n2 = _createDynamicComponent(() => (_ctx.view), null, null, 5) + return n2 + }) + return n0 +}" +`; + +exports[`compiler: transform outlets > root v-if fallback 1`] = ` +"import { createIf as _createIf, createSlot as _createSlot, template as _template } from 'vue'; +const t0 = _template("", 3) + +export function render(_ctx) { + const n0 = _createSlot("default", null, () => { + const n2 = _createIf(() => (_ctx.ok), () => { + const n4 = t0() + return n4 + }, null, 129 /* BLOCK_SHAPE, SLOT_ROOT */) + return n2 + }) + return n0 +}" +`; + exports[`compiler: transform outlets > slot outlet with scopeId and slotted=false should generate noSlotted 1`] = ` "import { createSlot as _createSlot } from 'vue'; diff --git a/packages/compiler-vapor/__tests__/transforms/__snapshots__/transformTemplateRef.spec.ts.snap b/packages/compiler-vapor/__tests__/transforms/__snapshots__/transformTemplateRef.spec.ts.snap index 258da1878d4..dbbb1d1f380 100644 --- a/packages/compiler-vapor/__tests__/transforms/__snapshots__/transformTemplateRef.spec.ts.snap +++ b/packages/compiler-vapor/__tests__/transforms/__snapshots__/transformTemplateRef.spec.ts.snap @@ -44,7 +44,7 @@ exports[`compiler: template ref transform > dynamic component static ref 1`] = ` "import { createDynamicComponent as _createDynamicComponent, setStaticTemplateRef as _setStaticTemplateRef } from 'vue'; export function render(_ctx) { - const n0 = _createDynamicComponent(() => (_ctx.view), null, null, true) + const n0 = _createDynamicComponent(() => (_ctx.view), null, null, 1) _setStaticTemplateRef(n0, "foo") return n0 }" diff --git a/packages/compiler-vapor/__tests__/transforms/__snapshots__/vFor.spec.ts.snap b/packages/compiler-vapor/__tests__/transforms/__snapshots__/vFor.spec.ts.snap index df8817b451e..320d0777365 100644 --- a/packages/compiler-vapor/__tests__/transforms/__snapshots__/vFor.spec.ts.snap +++ b/packages/compiler-vapor/__tests__/transforms/__snapshots__/vFor.spec.ts.snap @@ -355,7 +355,7 @@ export function render(_ctx) { }, () => { const n6 = _createComponentWithFallback(_component_Comp) return n6 - }, 138 /* BLOCK_SHAPE, INDEX_SHIFT */) + }, 266 /* BLOCK_SHAPE, INDEX_SHIFT */) return n2 }, undefined, 16) return n0 diff --git a/packages/compiler-vapor/__tests__/transforms/__snapshots__/vHtml.spec.ts.snap b/packages/compiler-vapor/__tests__/transforms/__snapshots__/vHtml.spec.ts.snap index 050ae7a53fd..62c4c449830 100644 --- a/packages/compiler-vapor/__tests__/transforms/__snapshots__/vHtml.spec.ts.snap +++ b/packages/compiler-vapor/__tests__/transforms/__snapshots__/vHtml.spec.ts.snap @@ -47,7 +47,7 @@ exports[`v-html > work with dynamic component 1`] = ` "import { createDynamicComponent as _createDynamicComponent, setBlockHtml as _setBlockHtml, renderEffect as _renderEffect } from 'vue'; export function render(_ctx) { - const n0 = _createDynamicComponent(() => (_ctx.Comp), null, null, true) + const n0 = _createDynamicComponent(() => (_ctx.Comp), null, null, 1) _renderEffect(() => _setBlockHtml(n0, _ctx.foo)) return n0 }" diff --git a/packages/compiler-vapor/__tests__/transforms/__snapshots__/vIf.spec.ts.snap b/packages/compiler-vapor/__tests__/transforms/__snapshots__/vIf.spec.ts.snap index 7dbb26c0bb3..38a0caba021 100644 --- a/packages/compiler-vapor/__tests__/transforms/__snapshots__/vIf.spec.ts.snap +++ b/packages/compiler-vapor/__tests__/transforms/__snapshots__/vIf.spec.ts.snap @@ -36,7 +36,7 @@ export function render(_ctx) { const n10 = t3() const n11 = t4() return [n10, n11] - }, 362 /* BLOCK_SHAPE, TRUE_NO_SCOPE, FALSE_NO_SCOPE, INDEX_SHIFT */), 165 /* BLOCK_SHAPE, TRUE_NO_SCOPE, INDEX_SHIFT */) + }, 618 /* BLOCK_SHAPE, TRUE_NO_SCOPE, FALSE_NO_SCOPE, INDEX_SHIFT */), 293 /* BLOCK_SHAPE, TRUE_NO_SCOPE, INDEX_SHIFT */) const n13 = t5() const x13 = _txt(n13) _renderEffect(() => _setText(x13, _toDisplayString(_ctx.text))) @@ -87,7 +87,7 @@ export function render(_ctx) { }, () => _createIf(() => (_ctx.bar), () => { const n4 = t1() return n4 - }, null, 33 /* BLOCK_SHAPE, TRUE_NO_SCOPE */), 165 /* BLOCK_SHAPE, TRUE_NO_SCOPE, INDEX_SHIFT */) + }, null, 33 /* BLOCK_SHAPE, TRUE_NO_SCOPE */), 293 /* BLOCK_SHAPE, TRUE_NO_SCOPE, INDEX_SHIFT */) const n6 = _createIf(() => (_ctx.baz), () => { const n8 = t2() return n8 @@ -185,7 +185,7 @@ export function render(_ctx) { }, () => { const n5 = t2() return n5 - }, 230 /* BLOCK_SHAPE, TRUE_NO_SCOPE, FALSE_NO_SCOPE, INDEX_SHIFT */) + }, 358 /* BLOCK_SHAPE, TRUE_NO_SCOPE, FALSE_NO_SCOPE, INDEX_SHIFT */) return n0 }" `; @@ -221,7 +221,7 @@ export function render(_ctx) { }, () => { const n4 = t1() return n4 - }, 229 /* BLOCK_SHAPE, TRUE_NO_SCOPE, FALSE_NO_SCOPE, INDEX_SHIFT */) + }, 357 /* BLOCK_SHAPE, TRUE_NO_SCOPE, FALSE_NO_SCOPE, INDEX_SHIFT */) return n0 }" `; @@ -245,7 +245,7 @@ export function render(_ctx) { }, () => { const n10 = t2() return n10 - }, 117 /* BLOCK_SHAPE, TRUE_NO_SCOPE, FALSE_NO_SCOPE, ONCE */), 293 /* BLOCK_SHAPE, TRUE_NO_SCOPE, INDEX_SHIFT */), 165 /* BLOCK_SHAPE, TRUE_NO_SCOPE, INDEX_SHIFT */) + }, 117 /* BLOCK_SHAPE, TRUE_NO_SCOPE, FALSE_NO_SCOPE, ONCE */), 549 /* BLOCK_SHAPE, TRUE_NO_SCOPE, INDEX_SHIFT */), 293 /* BLOCK_SHAPE, TRUE_NO_SCOPE, INDEX_SHIFT */) return n0 }" `; @@ -262,7 +262,7 @@ export function render(_ctx) { }, () => _createIf(() => (_ctx.orNot), () => { const n4 = t1() return n4 - }, null, 33 /* BLOCK_SHAPE, TRUE_NO_SCOPE */), 165 /* BLOCK_SHAPE, TRUE_NO_SCOPE, INDEX_SHIFT */) + }, null, 33 /* BLOCK_SHAPE, TRUE_NO_SCOPE */), 293 /* BLOCK_SHAPE, TRUE_NO_SCOPE, INDEX_SHIFT */) return n0 }" `; @@ -288,7 +288,7 @@ export function render(_ctx) { }, () => { const n7 = t2() return n7 - }, 357 /* BLOCK_SHAPE, TRUE_NO_SCOPE, FALSE_NO_SCOPE, INDEX_SHIFT */) + }, 613 /* BLOCK_SHAPE, TRUE_NO_SCOPE, FALSE_NO_SCOPE, INDEX_SHIFT */) return n8 }" `; @@ -306,7 +306,7 @@ export function render(_ctx) { }, () => _createIf(() => (_ctx.bar), () => { const n4 = t1() return n4 - }, null, 33 /* BLOCK_SHAPE, TRUE_NO_SCOPE */), 165 /* BLOCK_SHAPE, TRUE_NO_SCOPE, INDEX_SHIFT */) + }, null, 33 /* BLOCK_SHAPE, TRUE_NO_SCOPE */), 293 /* BLOCK_SHAPE, TRUE_NO_SCOPE, INDEX_SHIFT */) const n6 = t2() return [n0, n6] }" diff --git a/packages/compiler-vapor/__tests__/transforms/__snapshots__/vSlot.spec.ts.snap b/packages/compiler-vapor/__tests__/transforms/__snapshots__/vSlot.spec.ts.snap index 3abe00df8ab..f2c8b8b7a41 100644 --- a/packages/compiler-vapor/__tests__/transforms/__snapshots__/vSlot.spec.ts.snap +++ b/packages/compiler-vapor/__tests__/transforms/__snapshots__/vSlot.spec.ts.snap @@ -41,6 +41,25 @@ export function render(_ctx) { }" `; +exports[`compiler: transform slot > does not mark non-root v-if slot content as slot root 1`] = ` +"import { setInsertionState as _setInsertionState, createIf as _createIf, createAssetComponent as _createAssetComponent, template as _template } from 'vue'; +const t0 = _template("", 2) +const t1 = _template("
") + +export function render(_ctx) { + const n4 = _createAssetComponent("Comp", null, () => { + const n3 = t1() + _setInsertionState(n3, null, 0) + const n0 = _createIf(() => (_ctx.show), () => { + const n2 = t0() + return n2 + }) + return n3 + }, true) + return n4 +}" +`; + exports[`compiler: transform slot > dynamic slots name 1`] = ` "import { createAssetComponent as _createAssetComponent, template as _template } from 'vue'; const t0 = _template("foo", 2) @@ -147,7 +166,7 @@ export function render(_ctx) { const _component_Comp = _resolveComponent("Comp") const n2 = _createComponentWithFallback(_component_Comp, null, _withVaporCtx(() => { const n1 = _createComponentWithFallback(_component_Comp, null, _withVaporCtx(() => { - const n0 = _createSlot() + const n0 = _createSlot("default", null, null, 4) return n0 })) return n1 @@ -161,7 +180,7 @@ exports[`compiler: transform slot > forwarded slots > tag only 1`] = ` export function render(_ctx) { const n1 = _createAssetComponent("Comp", null, _withVaporCtx(() => { - const n0 = _createSlot() + const n0 = _createSlot("default", null, null, 4) return n0 }), true) return n1 @@ -173,7 +192,7 @@ exports[`compiler: transform slot > forwarded slots > tag w/ template 1`] export function render(_ctx) { const n2 = _createAssetComponent("Comp", null, _withVaporCtx(() => { - const n0 = _createSlot() + const n0 = _createSlot("default", null, null, 4) return n0 }), true) return n2 @@ -186,9 +205,9 @@ exports[`compiler: transform slot > forwarded slots > tag w/ v-for 1`] = export function render(_ctx) { const n3 = _createAssetComponent("Comp", null, _withVaporCtx(() => { const n0 = _createFor(() => (_ctx.b), (_for_item0) => { - const n2 = _createSlot() + const n2 = _createSlot("default", null, null, 4) return n2 - }, undefined, 16) + }, undefined, 48) return n0 }), true) return n3 @@ -201,9 +220,9 @@ exports[`compiler: transform slot > forwarded slots > tag w/ v-if 1`] = ` export function render(_ctx) { const n3 = _createAssetComponent("Comp", null, _withVaporCtx(() => { const n0 = _createIf(() => (_ctx.ok), () => { - const n2 = _createSlot() + const n2 = _createSlot("default", null, null, 4) return n2 - }) + }, null, 129 /* BLOCK_SHAPE, SLOT_ROOT */) return n0 }), true) return n3 @@ -223,6 +242,41 @@ export function render(_ctx) { }" `; +exports[`compiler: transform slot > marks root slot outlet fallback as slot root 1`] = ` +"import { createIf as _createIf, createSlot as _createSlot, withVaporCtx as _withVaporCtx, createAssetComponent as _createAssetComponent, template as _template } from 'vue'; +const t0 = _template("", 2) + +export function render(_ctx) { + const n5 = _createAssetComponent("Comp", null, _withVaporCtx(() => { + const n0 = _createSlot("default", null, () => { + const n2 = _createIf(() => (_ctx.show), () => { + const n4 = t0() + return n4 + }, null, 129 /* BLOCK_SHAPE, SLOT_ROOT */) + return n2 + }, 4) + return n0 + }), true) + return n5 +}" +`; + +exports[`compiler: transform slot > marks root v-if slot content as slot root 1`] = ` +"import { createIf as _createIf, createAssetComponent as _createAssetComponent, template as _template } from 'vue'; +const t0 = _template("", 2) + +export function render(_ctx) { + const n3 = _createAssetComponent("Comp", null, () => { + const n0 = _createIf(() => (_ctx.show), () => { + const n2 = t0() + return n2 + }, null, 129 /* BLOCK_SHAPE, SLOT_ROOT */) + return n0 + }, true) + return n3 +}" +`; + exports[`compiler: transform slot > named slots w/ implicit default slot 1`] = ` "import { createAssetComponent as _createAssetComponent, template as _template } from 'vue'; const t0 = _template("foo", 2) @@ -565,7 +619,7 @@ export function render(_ctx) { _withVaporCtx(() => (_createForSlots(_ctx.slots, (_, name) => ({ name: name, fn: _withVaporCtx(() => { - const n0 = _createSlot(() => (name)) + const n0 = _createSlot(() => (name), null, null, 4) return n0 }) })))) @@ -587,7 +641,7 @@ export function render(_ctx) { _setInsertionState(n3, null, 0) const n2 = _createComponentWithFallback(_component_ChildComp) return n3 - }, undefined, 8) + }, undefined, 40) return n0 }), true) return n5 @@ -606,7 +660,7 @@ export function render(_ctx) { _setInsertionState(n3, null, 0) const n2 = _createComponentWithFallback(_component_ChildComp) return n3 - }) + }, null, 129 /* BLOCK_SHAPE, SLOT_ROOT */) return n0 }), true) return n5 @@ -637,7 +691,7 @@ export function render(_ctx) { _setInsertionState(n3, null, 0) const n2 = _createPlainElement("my-element") return n3 - }) + }, null, 129 /* BLOCK_SHAPE, SLOT_ROOT */) return n0 }), true) return n5 @@ -674,7 +728,7 @@ export function render(_ctx) { return n5 }) return n6 - }) + }, null, 129 /* BLOCK_SHAPE, SLOT_ROOT */) return n0 }), true) return n8 @@ -713,7 +767,7 @@ exports[`compiler: transform slot > withVaporCtx optimization > slot with slot o export function render(_ctx) { const n2 = _createAssetComponent("Comp", null, _withVaporCtx(() => { - const n0 = _createSlot() + const n0 = _createSlot("default", null, null, 4) return n0 }), true) return n2 @@ -731,7 +785,7 @@ export function render(_ctx) { const x2 = _txt(n2) _renderEffect(() => _setText(x2, _toDisplayString(_for_item0.value))) return n2 - }, undefined, 8) + }, undefined, 40) const x0 = _txt(n0) _renderEffect(() => _setText(x0, _toDisplayString(_ctx.item))) return n0 @@ -753,7 +807,7 @@ export function render(_ctx) { }, () => { const n4 = t1() return n4 - }, 133 /* BLOCK_SHAPE, INDEX_SHIFT */) + }, 389 /* BLOCK_SHAPE, SLOT_ROOT, INDEX_SHIFT */) return n0 }, true) return n7 diff --git a/packages/compiler-vapor/__tests__/transforms/__snapshots__/vText.spec.ts.snap b/packages/compiler-vapor/__tests__/transforms/__snapshots__/vText.spec.ts.snap index 80068f205b3..6c765bd657e 100644 --- a/packages/compiler-vapor/__tests__/transforms/__snapshots__/vText.spec.ts.snap +++ b/packages/compiler-vapor/__tests__/transforms/__snapshots__/vText.spec.ts.snap @@ -48,7 +48,7 @@ exports[`v-text > work with dynamic component 1`] = ` "import { createDynamicComponent as _createDynamicComponent, toDisplayString as _toDisplayString, setBlockText as _setBlockText, renderEffect as _renderEffect } from 'vue'; export function render(_ctx) { - const n0 = _createDynamicComponent(() => (_ctx.Comp), null, null, true) + const n0 = _createDynamicComponent(() => (_ctx.Comp), null, null, 1) _renderEffect(() => _setBlockText(n0, _toDisplayString(_ctx.foo))) return n0 }" diff --git a/packages/compiler-vapor/__tests__/transforms/transformSlotOutlet.spec.ts b/packages/compiler-vapor/__tests__/transforms/transformSlotOutlet.spec.ts index ee5b4e382b0..8282c3e6b97 100644 --- a/packages/compiler-vapor/__tests__/transforms/transformSlotOutlet.spec.ts +++ b/packages/compiler-vapor/__tests__/transforms/transformSlotOutlet.spec.ts @@ -7,6 +7,8 @@ import { transformSlotOutlet, transformText, transformVBind, + transformVFor, + transformVIf, transformVOn, transformVShow, } from '../../src' @@ -15,6 +17,8 @@ import { makeCompile } from './_utils' const compileWithSlotsOutlet = makeCompile({ nodeTransforms: [ transformText, + transformVIf, + transformVFor, transformSlotOutlet, transformElement, transformChildren, @@ -239,6 +243,36 @@ describe('compiler: transform outlets', () => { }) }) + test('root v-if fallback', () => { + const { code } = compileWithSlotsOutlet(``) + + expect(code).toMatchSnapshot() + }) + + test('nested root v-for fallback', () => { + const { code } = compileWithSlotsOutlet( + ``, + ) + + expect(code).toMatchSnapshot() + }) + + test('does not mark non-root fallback v-if as slot root', () => { + const { code } = compileWithSlotsOutlet( + `
`, + ) + + expect(code).toMatchSnapshot() + }) + + test('root dynamic component fallback', () => { + const { code } = compileWithSlotsOutlet( + ``, + ) + + expect(code).toMatchSnapshot() + }) + test('error on unexpected custom directive on ', () => { const onError = vi.fn() const source = `` diff --git a/packages/compiler-vapor/__tests__/transforms/vSlot.spec.ts b/packages/compiler-vapor/__tests__/transforms/vSlot.spec.ts index 3598ed0ea19..c0f85739860 100644 --- a/packages/compiler-vapor/__tests__/transforms/vSlot.spec.ts +++ b/packages/compiler-vapor/__tests__/transforms/vSlot.spec.ts @@ -594,6 +594,28 @@ describe('compiler: transform slot', () => { }) }) + test('marks root v-if slot content as slot root', () => { + const { code } = compileWithSlots(``) + + expect(code).toMatchSnapshot() + }) + + test('does not mark non-root v-if slot content as slot root', () => { + const { code } = compileWithSlots( + `
`, + ) + + expect(code).toMatchSnapshot() + }) + + test('marks root slot outlet fallback as slot root', () => { + const { code } = compileWithSlots( + ``, + ) + + expect(code).toMatchSnapshot() + }) + describe('forwarded slots', () => { test(' tag only', () => { const { code } = compileWithSlots(``) diff --git a/packages/compiler-vapor/src/generators/block.ts b/packages/compiler-vapor/src/generators/block.ts index fd2879c0eaa..263a0987368 100644 --- a/packages/compiler-vapor/src/generators/block.ts +++ b/packages/compiler-vapor/src/generators/block.ts @@ -1,4 +1,12 @@ -import type { BlockIRNode, CoreHelper, IRDynamicInfo, IRSlots } from '../ir' +import type { + BlockIRNode, + CoreHelper, + CreateComponentIRNode, + ForIRNode, + IRDynamicInfo, + IRSlots, + IfIRNode, +} from '../ir' import { IRNodeTypes, IRSlotType, @@ -23,6 +31,7 @@ import { } from './operation' import { genChildren, genSelf } from './template' import { toValidAssetId } from '@vue/compiler-dom' +import { VaporSlotFlags } from '@vue/shared' export function genBlock( oper: BlockIRNode, @@ -177,6 +186,71 @@ export function genBlockContent( } } +export function markSlotRootOperations(block: BlockIRNode): void { + for (let i = 0; i < block.returns.length; i++) { + const child = findReturnedDynamic(block, block.returns[i]) + const operation = child && child.operation + if (!operation) continue + + if (operation.type === IRNodeTypes.IF) { + markSlotRootIf(operation) + } else if (operation.type === IRNodeTypes.FOR) { + markSlotRootFor(operation) + } else if (operation.type === IRNodeTypes.SLOT_OUTLET_NODE) { + markSlotRootSlotOutlet(operation) + } else if (operation.type === IRNodeTypes.CREATE_COMPONENT_NODE) { + markSlotRootComponent(operation) + } + } +} + +function markSlotRootIf(operation: IfIRNode): void { + if (!operation.once) { + operation.slotRoot = true + } + markSlotRootOperations(operation.positive) + + const negative = operation.negative + if (!negative) return + if (negative.type === IRNodeTypes.IF) { + markSlotRootIf(negative) + } else { + markSlotRootOperations(negative) + } +} + +function markSlotRootFor(operation: ForIRNode): void { + if (!operation.once) { + operation.slotRoot = true + } + markSlotRootOperations(operation.render) +} + +function markSlotRootSlotOutlet( + operation: Extract, +): void { + operation.flags |= VaporSlotFlags.SLOT_ROOT + if (operation.fallback) { + markSlotRootOperations(operation.fallback) + } +} + +function markSlotRootComponent(operation: CreateComponentIRNode): void { + if (!operation.once && operation.dynamic && !operation.dynamic.isStatic) { + operation.slotRoot = true + } +} + +function findReturnedDynamic( + block: BlockIRNode, + id: number, +): IRDynamicInfo | undefined { + for (let i = 0; i < block.dynamic.children.length; i++) { + const child = block.dynamic.children[i] + if (child.id === id) return child + } +} + interface AssetComponentUsage { count: number root: boolean diff --git a/packages/compiler-vapor/src/generators/component.ts b/packages/compiler-vapor/src/generators/component.ts index b9a0e6e640b..2b91010619c 100644 --- a/packages/compiler-vapor/src/generators/component.ts +++ b/packages/compiler-vapor/src/generators/component.ts @@ -1,4 +1,5 @@ import { + VaporDynamicComponentFlags, camelize, extend, getModifierPropName, @@ -49,7 +50,7 @@ import { } from '@vue/compiler-dom' import { genEventHandler } from './event' import { genDirectiveModifiers, genDirectivesForElement } from './directive' -import { genBlock } from './block' +import { genBlock, markSlotRootOperations } from './block' import { type DestructureMap, type DestructureMapValue, @@ -76,7 +77,15 @@ export function genCreateComponent( useAssetComponentHelper && operation.tag.endsWith('__self') const tag = genTag() - const { root, props, slots, once } = operation + const { root, props, slots, once, slotRoot } = operation + const isRuntimeDynamicComponent = !!( + operation.dynamic && !operation.dynamic.isStatic + ) + const dynamicComponentFlags = isRuntimeDynamicComponent + ? (root ? VaporDynamicComponentFlags.SINGLE_ROOT : 0) | + (once ? VaporDynamicComponentFlags.ONCE : 0) | + (slotRoot ? VaporDynamicComponentFlags.SLOT_ROOT : 0) + : 0 const rawSlots = genRawSlots(slots, context) const [ids, handlers] = processInlineHandlers(props, context) const rawProps = context.withId(() => genRawProps(props, context, true), ids) @@ -93,7 +102,7 @@ export function genCreateComponent( ...inlineHandlers, `const n${operation.id} = `, ...genCall( - operation.dynamic && !operation.dynamic.isStatic + isRuntimeDynamicComponent ? helper('createDynamicComponent') : operation.useCreateElement ? helper('createPlainElement') @@ -105,9 +114,15 @@ export function genCreateComponent( tag, rawProps, rawSlots, - root ? 'true' : false, - once && 'true', - maybeSelfReference && 'true', + isRuntimeDynamicComponent + ? dynamicComponentFlags + ? String(dynamicComponentFlags) + : false + : root + ? 'true' + : false, + isRuntimeDynamicComponent ? false : once && 'true', + isRuntimeDynamicComponent ? false : maybeSelfReference && 'true', ), ...genDirectivesForElement(operation.id, context), ] @@ -757,6 +772,7 @@ function genSlotBlockWithProps(oper: SlotBlockIRNode, context: CodegenContext) { } const exitSlotBlock = context.enterSlotBlock() + markSlotRootOperations(oper) let blockFn = context.withId( () => genBlock(oper, context, propsName ? [propsName] : []), idMap, diff --git a/packages/compiler-vapor/src/generators/for.ts b/packages/compiler-vapor/src/generators/for.ts index 8b0588cec5c..8d69e5036eb 100644 --- a/packages/compiler-vapor/src/generators/for.ts +++ b/packages/compiler-vapor/src/generators/for.ts @@ -45,6 +45,7 @@ export function genFor( id, component, onlyChild, + slotRoot, } = oper const rawValue = value && value.content @@ -155,6 +156,9 @@ export function genFor( if (once) { flags |= VaporVForFlags.ONCE } + if (slotRoot) { + flags |= VaporVForFlags.SLOT_ROOT + } const onResetCalls: CodeFragment[] = [] for (let i = 0; i < selectorPatterns.length; i++) { diff --git a/packages/compiler-vapor/src/generators/if.ts b/packages/compiler-vapor/src/generators/if.ts index 70272ece737..9510e475981 100644 --- a/packages/compiler-vapor/src/generators/if.ts +++ b/packages/compiler-vapor/src/generators/if.ts @@ -11,9 +11,15 @@ export function genIf( isNested = false, ): CodeFragment[] { const { helper } = context - const { condition, positive, negative, once, index, blockShape } = oper + const { condition, positive, negative, once, slotRoot, index, blockShape } = + oper const [frag, push] = buildCodeFragment() - const flags = genIfFlags(blockShape, once, negative ? index : undefined) + const flags = genIfFlags( + blockShape, + once, + slotRoot, + negative ? index : undefined, + ) const conditionExpr: CodeFragment[] = [ '() => (', @@ -49,9 +55,13 @@ export function genIf( function genIfFlags( blockShape: number, once: boolean | undefined, + slotRoot: boolean | undefined, index: number | undefined, ): string | false { let flags = blockShape + if (slotRoot) { + flags |= VaporIfFlags.SLOT_ROOT + } if (once) { flags |= VaporIfFlags.ONCE } else if (index !== undefined) { @@ -67,12 +77,13 @@ function genIfFlags( } return __DEV__ - ? `${flags} /* ${genIfFlagNames(once, index, blockShape)} */` + ? `${flags} /* ${genIfFlagNames(once, slotRoot, index, blockShape)} */` : String(flags) } function genIfFlagNames( once: boolean | undefined, + slotRoot: boolean | undefined, index: number | undefined, blockShape: number, ): string { @@ -87,7 +98,11 @@ function genIfFlagNames( if (once) { names.push('ONCE') - } else if (index !== undefined) { + } + if (slotRoot) { + names.push('SLOT_ROOT') + } + if (!once && index !== undefined) { names.push('INDEX_SHIFT') } diff --git a/packages/compiler-vapor/src/generators/slotOutlet.ts b/packages/compiler-vapor/src/generators/slotOutlet.ts index 32557cb331e..3fdb5c9e981 100644 --- a/packages/compiler-vapor/src/generators/slotOutlet.ts +++ b/packages/compiler-vapor/src/generators/slotOutlet.ts @@ -1,6 +1,6 @@ import type { CodegenContext } from '../generate' import type { SlotOutletIRNode } from '../ir' -import { genBlock } from './block' +import { genBlock, markSlotRootOperations } from './block' import { genExpression } from './expression' import { type CodeFragment, NEWLINE, buildCodeFragment, genCall } from './utils' import { genRawProps } from './component' @@ -15,6 +15,7 @@ export function genSlotOutlet( let fallbackArg: CodeFragment[] | undefined if (fallback) { + markSlotRootOperations(fallback) fallbackArg = genBlock(fallback, context) } const createSlot = helper('createSlot') diff --git a/packages/compiler-vapor/src/ir/index.ts b/packages/compiler-vapor/src/ir/index.ts index e502ca8b556..78260c5ddf4 100644 --- a/packages/compiler-vapor/src/ir/index.ts +++ b/packages/compiler-vapor/src/ir/index.ts @@ -95,6 +95,7 @@ export interface IfIRNode extends BaseIRNode, EffectBoundary { positive: BlockIRNode negative?: BlockIRNode | IfIRNode once?: boolean + slotRoot?: boolean index?: number parent?: number anchor?: number @@ -115,6 +116,7 @@ export interface ForIRNode extends BaseIRNode, IRFor, EffectBoundary { keyProp?: SimpleExpressionNode render: BlockIRNode once: boolean + slotRoot?: boolean component: boolean onlyChild: boolean parent?: number @@ -235,6 +237,7 @@ export interface CreateComponentIRNode extends BaseIRNode, EffectBoundary { asset: boolean root: boolean once: boolean + slotRoot?: boolean dynamic?: SimpleExpressionNode useCreateElement: boolean parent?: number diff --git a/packages/runtime-core/src/apiCreateApp.ts b/packages/runtime-core/src/apiCreateApp.ts index ddf2b165ec7..3a37323f0ae 100644 --- a/packages/runtime-core/src/apiCreateApp.ts +++ b/packages/runtime-core/src/apiCreateApp.ts @@ -252,6 +252,7 @@ export interface VaporInteropInterface { parentComponent: any, // VaporComponentInstance fallback?: any, // VaporSlot once?: boolean, + slotRoot?: boolean, ) => any vdomMountVNode: ( vnode: VNode, diff --git a/packages/runtime-vapor/__tests__/apiCreateDynamicComponent.spec.ts b/packages/runtime-vapor/__tests__/apiCreateDynamicComponent.spec.ts index 46c8f890a58..37b7f64ddbc 100644 --- a/packages/runtime-vapor/__tests__/apiCreateDynamicComponent.spec.ts +++ b/packages/runtime-vapor/__tests__/apiCreateDynamicComponent.spec.ts @@ -4,6 +4,7 @@ import { nextTick, resolveDynamicComponent, } from '@vue/runtime-dom' +import { VaporDynamicComponentFlags } from '@vue/shared' import { type VaporComponentInstance, createComponent, @@ -78,7 +79,13 @@ describe('api: createDynamicComponent', () => { const { html } = define({ setup() { - return createDynamicComponent(() => val.value, null, null, true, true) + return createDynamicComponent( + () => val.value, + null, + null, + VaporDynamicComponentFlags.SINGLE_ROOT | + VaporDynamicComponentFlags.ONCE, + ) }, }).render() @@ -98,8 +105,8 @@ describe('api: createDynamicComponent', () => { () => val.value, { id: () => id.value }, null, - true, - true, + VaporDynamicComponentFlags.SINGLE_ROOT | + VaporDynamicComponentFlags.ONCE, ) }, }).render() @@ -243,7 +250,12 @@ describe('api: createDynamicComponent', () => { test('compiled static key on dynamic component fallback', () => { const Comp = defineVaporComponent({ setup() { - const n0 = createDynamicComponent(() => 'div', null, null, true) + const n0 = createDynamicComponent( + () => 'div', + null, + null, + VaporDynamicComponentFlags.SINGLE_ROOT, + ) setBlockKey(n0, 'foo') return n0 }, diff --git a/packages/runtime-vapor/__tests__/componentAttrs.spec.ts b/packages/runtime-vapor/__tests__/componentAttrs.spec.ts index eab69de3499..5335597edf3 100644 --- a/packages/runtime-vapor/__tests__/componentAttrs.spec.ts +++ b/packages/runtime-vapor/__tests__/componentAttrs.spec.ts @@ -22,7 +22,7 @@ import { template, } from '../src' import { makeRender } from './_utils' -import { stringifyStyle } from '@vue/shared' +import { VaporDynamicComponentFlags, stringifyStyle } from '@vue/shared' import { setElementText } from '../src/dom/prop' const define = makeRender() @@ -1120,7 +1120,7 @@ describe('attribute fallthrough', () => { return n0 }, }, - true, + VaporDynamicComponentFlags.SINGLE_ROOT, ) return n1 }, diff --git a/packages/runtime-vapor/__tests__/componentSlots.spec.ts b/packages/runtime-vapor/__tests__/componentSlots.spec.ts index dd30d02f782..4028564af16 100644 --- a/packages/runtime-vapor/__tests__/componentSlots.spec.ts +++ b/packages/runtime-vapor/__tests__/componentSlots.spec.ts @@ -4,6 +4,7 @@ import { VaporTeleport, child, createComponent, + createDynamicComponent, createFor, createForSlots, createIf, @@ -35,7 +36,13 @@ import { toDisplayString, useSlots, } from '@vue/runtime-dom' -import { VaporSlotFlags } from '@vue/shared' +import { + VaporBlockShape, + VaporDynamicComponentFlags, + VaporIfFlags, + VaporSlotFlags, + VaporVForFlags, +} from '@vue/shared' import { makeRender } from './_utils' import type { DynamicSlot } from '../src/componentSlots' import { setElementText, setText } from '../src/dom/prop' @@ -48,9 +55,8 @@ import { } from '../src/dom/hydration' import { DynamicFragment, - ForFragment, type SlotBoundaryContext, - type SlotFallbackOutlet, + type SlotFallbackState, SlotFragment, VaporFragment, getCurrentSlotBoundary, @@ -58,7 +64,6 @@ import { isHydratingSlotFallbackActive, markSlotFallbackDirty, recheckSlotFallback, - syncActiveSlotFallback, trackSlotBoundaryDirtying, withHydratingSlotBoundary, withHydratingSlotFallbackActive, @@ -66,6 +71,11 @@ import { } from '../src/fragment' const define = makeRender() +const keyedIfShape = + VaporBlockShape.SINGLE_ROOT | (1 << VaporIfFlags.INDEX_SHIFT) +const slotRootIfShape = VaporBlockShape.SINGLE_ROOT | VaporIfFlags.SLOT_ROOT +const keyedSlotRootIfShape = keyedIfShape | VaporIfFlags.SLOT_ROOT +const slotRootForFlags = VaporVForFlags.SLOT_ROOT function renderWithSlots(slots: any): any { let instance: any @@ -88,22 +98,23 @@ function renderWithSlots(slots: any): any { return instance } -function createTestSlotFallbackOutlet(options: { +function createTestSlotFallbackState(options: { fallback?: BlockFn content?: Block parentNode?: ParentNode | null anchor?: Node | null + isBusy?: () => boolean isContentValid?: () => boolean isDisposed?: () => boolean -}): SlotFallbackOutlet { - let outlet!: SlotFallbackOutlet +}): SlotFallbackState { + let state!: SlotFallbackState const boundary: SlotBoundaryContext = { parent: null, getFallback: () => options.fallback, run: fn => fn(), - markDirty: () => markSlotFallbackDirty(outlet), + markDirty: () => markSlotFallbackDirty(state), } - outlet = { + state = { boundary, activeFallback: null, pendingRecheck: false, @@ -111,11 +122,14 @@ function createTestSlotFallbackOutlet(options: { getContent: () => options.content || [], getParentNode: () => options.parentNode || null, getAnchor: () => options.anchor || null, - isDisposed: options.isDisposed, - isContentValid: options.isContentValid, + isBusy: options.isBusy || (() => false), + isDisposed: options.isDisposed || (() => false), + isContentValid: + options.isContentValid || (() => isValidBlock(options.content || [])), + syncNodes: () => {}, notifyFallbackValidityChange: vi.fn(), } - return outlet + return state } describe('component: slots', () => { @@ -311,7 +325,7 @@ describe('component: slots', () => { child.update(() => []) - expect(container.innerHTML).toBe('outer fallback') + expect(container.innerHTML).toBe('outer fallback') }) test('slot fragment local fallback ignores unrelated ancestor fallback refs', async () => { @@ -341,52 +355,109 @@ describe('component: slots', () => { expect(localFallback).toHaveBeenCalledTimes(1) }) - test('slot fragment activates local fallback while preserving content carrier', () => { - const container = document.createElement('div') - const content = new DynamicFragment('if', false, false) - const frag = new SlotFragment() - - frag.updateSlot( - () => content, - () => document.createTextNode('fallback'), + test('slot fragment switches local fallback and root v-if content without keeping the inactive anchor in DOM', async () => { + const show = ref(false) + const Child = defineVaporComponent(() => + createSlot('default', null, () => document.createTextNode('fallback')), ) - insert(frag, container) + const { host } = define(() => + createComponent(Child, null, { + default: withVaporCtx(() => + createIf( + () => show.value, + () => document.createTextNode('content'), + undefined, + keyedSlotRootIfShape, + ), + ), + }), + ).render() + + expect(host.innerHTML).toBe('fallback') + + show.value = true + await nextTick() - expect(container.innerHTML).toBe('fallback') + expect(host.innerHTML).toBe('content') }) - test('slot fragment activates local fallback while preserving v-for carrier', () => { - const container = document.createElement('div') - const content = new ForFragment([[], document.createComment('for')]) - const frag = new SlotFragment() + test('slot fragment switches local fallback and root v-for content without keeping the inactive anchor in DOM', async () => { + const items = ref([]) + const Child = defineVaporComponent(() => + createSlot('default', null, () => document.createTextNode('fallback')), + ) + const { host } = define(() => + createComponent(Child, null, { + default: withVaporCtx(() => + createFor( + () => items.value, + item => document.createTextNode(item.value), + undefined, + slotRootForFlags, + ), + ), + }), + ).render() - frag.updateSlot( - () => content, - () => document.createTextNode('fallback'), + expect(host.innerHTML).toBe('fallback') + + items.value = ['content'] + await nextTick() + + expect(host.innerHTML).toBe('content') + }) + + test('slot fragment switches root dynamic component content to fallback', async () => { + const current = shallowRef(() => document.createTextNode('content')) + const Child = defineVaporComponent(() => + createSlot('default', null, () => document.createTextNode('fallback')), ) - insert(frag, container) + const { host } = define(() => + createComponent(Child, null, { + default: withVaporCtx(() => + createDynamicComponent( + () => current.value, + null, + null, + VaporDynamicComponentFlags.SINGLE_ROOT | + VaporDynamicComponentFlags.SLOT_ROOT, + ), + ), + }), + ).render() + + expect(host.innerHTML).toBe('content') - expect(container.innerHTML).toBe('fallback') + current.value = null + await nextTick() + + expect(host.innerHTML).toBe('fallback') }) - test('slot fallback falls through while preserving local carrier', () => { + test('slot fallback falls through and restores local root v-if fallback without keeping inactive anchor in DOM', async () => { const container = document.createElement('div') const anchor = document.createComment('slot') - const localCarrier = new DynamicFragment('if', false, false) - let outlet!: SlotFallbackOutlet + const showLocal = ref(false) + let state!: SlotFallbackState const parentBoundary: SlotBoundaryContext = { parent: null, - getFallback: () => () => document.createTextNode('outlet fallback'), + getFallback: () => () => document.createTextNode('parent fallback'), run: fn => fn(), markDirty: vi.fn(), } const boundary: SlotBoundaryContext = { parent: parentBoundary, - getFallback: () => () => localCarrier, + getFallback: () => () => + createIf( + () => showLocal.value, + () => document.createTextNode('local fallback'), + undefined, + keyedSlotRootIfShape, + ), run: fn => fn(), - markDirty: () => markSlotFallbackDirty(outlet), + markDirty: () => markSlotFallbackDirty(state), } - outlet = { + state = { boundary, activeFallback: null, pendingRecheck: false, @@ -394,14 +465,22 @@ describe('component: slots', () => { getContent: () => [], getParentNode: () => container, getAnchor: () => anchor, + isBusy: () => false, + isDisposed: () => false, isContentValid: () => false, + syncNodes: () => {}, notifyFallbackValidityChange: vi.fn(), } container.append(anchor) - recheckSlotFallback(outlet) + recheckSlotFallback(state) + + expect(container.innerHTML).toBe('parent fallback') + + showLocal.value = true + await nextTick() - expect(container.innerHTML).toBe('outlet fallback') + expect(container.innerHTML).toBe('local fallback') }) test('slot fallback falls through when local fallback is removed', () => { @@ -409,10 +488,10 @@ describe('component: slots', () => { const anchor = document.createComment('slot') let localFallback: BlockFn | undefined = () => document.createTextNode('local fallback') - let outlet!: SlotFallbackOutlet + let state!: SlotFallbackState const parentBoundary: SlotBoundaryContext = { parent: null, - getFallback: () => () => document.createTextNode('outlet fallback'), + getFallback: () => () => document.createTextNode('parent fallback'), run: fn => fn(), markDirty: vi.fn(), } @@ -420,9 +499,9 @@ describe('component: slots', () => { parent: parentBoundary, getFallback: () => localFallback, run: fn => fn(), - markDirty: () => markSlotFallbackDirty(outlet), + markDirty: () => markSlotFallbackDirty(state), } - outlet = { + state = { boundary, activeFallback: null, pendingRecheck: false, @@ -430,17 +509,20 @@ describe('component: slots', () => { getContent: () => [], getParentNode: () => container, getAnchor: () => anchor, + isBusy: () => false, + isDisposed: () => false, isContentValid: () => false, + syncNodes: () => {}, notifyFallbackValidityChange: vi.fn(), } container.append(anchor) - recheckSlotFallback(outlet) + recheckSlotFallback(state) expect(container.innerHTML).toBe('local fallback') localFallback = undefined - recheckSlotFallback(outlet, true) - expect(container.innerHTML).toBe('outlet fallback') + recheckSlotFallback(state, true) + expect(container.innerHTML).toBe('parent fallback') }) test('slot fragment delays fallback activation until pending child validity resolves', () => { @@ -468,16 +550,122 @@ describe('component: slots', () => { expect(container.innerHTML).toBe('fallback') }) - test('slot fallback outlet ignores dirty notifications after dispose', () => { + test('slot boundary dirtying ignores non-root v-if updates', async () => { + const markDirty = vi.fn() + const show = ref(true) + const boundary: SlotBoundaryContext = { + parent: null, + getFallback: () => undefined, + run: fn => fn(), + markDirty, + } + const Child = defineVaporComponent(() => + withOwnedSlotBoundary(boundary, () => + createIf( + () => show.value, + () => document.createTextNode('content'), + undefined, + keyedIfShape, + ), + ), + ) + + define(() => createComponent(Child)).render() + show.value = false + await nextTick() + + expect(markDirty).not.toHaveBeenCalled() + }) + + test('slot boundary dirtying ignores non-root v-for updates', async () => { + const markDirty = vi.fn() + const items = ref([1]) + const boundary: SlotBoundaryContext = { + parent: null, + getFallback: () => undefined, + run: fn => fn(), + markDirty, + } + const Child = defineVaporComponent(() => + withOwnedSlotBoundary(boundary, () => + createFor( + () => items.value, + item => document.createTextNode(String(item.value)), + ), + ), + ) + + define(() => createComponent(Child)).render() + items.value = [] + await nextTick() + + expect(markDirty).not.toHaveBeenCalled() + }) + + test('slot boundary dirtying tracks root v-if updates', async () => { + const markDirty = vi.fn() + const show = ref(true) + const boundary: SlotBoundaryContext = { + parent: null, + getFallback: () => undefined, + run: fn => fn(), + markDirty, + } + const Child = defineVaporComponent(() => + withOwnedSlotBoundary(boundary, () => + createIf( + () => show.value, + () => document.createTextNode('content'), + undefined, + keyedSlotRootIfShape, + ), + ), + ) + + define(() => createComponent(Child)).render() + show.value = false + await nextTick() + + expect(markDirty).toHaveBeenCalledTimes(1) + }) + + test('slot boundary dirtying tracks root v-for updates', async () => { + const markDirty = vi.fn() + const items = ref([1]) + const boundary: SlotBoundaryContext = { + parent: null, + getFallback: () => undefined, + run: fn => fn(), + markDirty, + } + const Child = defineVaporComponent(() => + withOwnedSlotBoundary(boundary, () => + createFor( + () => items.value, + item => document.createTextNode(String(item.value)), + undefined, + slotRootForFlags, + ), + ), + ) + + define(() => createComponent(Child)).render() + items.value = [] + await nextTick() + + expect(markDirty).toHaveBeenCalledTimes(1) + }) + + test('slot fallback state ignores dirty notifications after dispose', () => { let disposed = false - const outlet = createTestSlotFallbackOutlet({ + const state = createTestSlotFallbackState({ isDisposed: () => disposed, }) disposed = true - outlet.boundary.markDirty() + state.boundary.markDirty() - expect(outlet.pendingRecheck).toBe(false) + expect(state.pendingRecheck).toBe(false) }) test('removed slot fragment ignores queued fallback dirty notifications', () => { @@ -652,13 +840,13 @@ describe('component: slots', () => { expect(frag.anchor).toBe(end.previousSibling) }) - test('slot fallback outlet stops fallback scope when fallback body throws', async () => { + test('slot fallback state stops fallback scope when fallback body throws', async () => { const source = ref(0) const effectRuns = vi.fn() const cleanup = vi.fn() const err = new Error('fallback boom') - const outlet = createTestSlotFallbackOutlet({ + const state = createTestSlotFallbackState({ fallback: () => { onScopeDispose(cleanup) renderEffect(() => { @@ -668,8 +856,8 @@ describe('component: slots', () => { }, }) - expect(() => recheckSlotFallback(outlet)).toThrow(err) - expect(outlet.activeFallback).toBe(null) + expect(() => recheckSlotFallback(state)).toThrow(err) + expect(state.activeFallback).toBe(null) expect(cleanup).toHaveBeenCalledTimes(1) expect(effectRuns).toHaveBeenCalledTimes(1) @@ -679,96 +867,56 @@ describe('component: slots', () => { expect(effectRuns).toHaveBeenCalledTimes(1) }) - test('slot fallback outlet does not accumulate order-sync hooks', async () => { - const fallback = new VaporFragment([ - document.createTextNode('a'), - document.createTextNode('b'), - ]) - const outlet = createTestSlotFallbackOutlet({ - fallback: () => fallback, - }) - - recheckSlotFallback(outlet) - expect(fallback.onUpdated).toHaveLength(1) - - syncActiveSlotFallback(outlet) - await nextTick() - - expect(fallback.onUpdated).toHaveLength(1) - }) - - test('slot fallback outlet re-syncs the whole carrier block order', async () => { - const container = document.createElement('div') - const carrierA = document.createTextNode('x') - const carrierB = document.createTextNode('y') - const marker = document.createTextNode('!') - const slotAnchor = document.createComment('slot') - const fallback = new VaporFragment([ - document.createTextNode('a'), - document.createTextNode('b'), - ]) - const outlet = createTestSlotFallbackOutlet({ + test('slot fallback state rechecks when idle', () => { + const fallback = document.createTextNode('fallback') + const state = createTestSlotFallbackState({ fallback: () => fallback, - content: [carrierA, carrierB], - parentNode: container, - anchor: slotAnchor, - isContentValid: () => false, }) - container.append(carrierA, marker, carrierB, slotAnchor) - recheckSlotFallback(outlet) - - expect(container.innerHTML).toBe('abx!y') - - syncActiveSlotFallback(outlet) - await nextTick() + state.boundary.markDirty() - expect(container.innerHTML).toBe('abxy!') + expect(state.activeFallback).toBe(fallback) }) - test('slot fallback outlet re-syncs carrier order when fallback ends with a fragment anchor', async () => { - const container = document.createElement('div') - const carrierA = document.createTextNode('x') - const carrierB = document.createTextNode('y') - const marker = document.createTextNode('!') - const slotAnchor = document.createComment('slot') - const trailingFragment = new DynamicFragment('if', false, false) - trailingFragment.update(() => document.createTextNode('b')) - const fallback = new VaporFragment([ - document.createTextNode('a'), - trailingFragment, - ]) - const outlet = createTestSlotFallbackOutlet({ - fallback: () => fallback, - content: [carrierA, carrierB], - parentNode: container, - anchor: slotAnchor, - isContentValid: () => false, + test('vdom slot dirties parent boundary once when content stays valid', async () => { + const text = ref('A') + const boundary = { + parent: null, + getFallback: () => undefined, + run: (fn: () => any) => fn(), + markDirty: vi.fn(), + } + const instance = renderWithSlots({}) + const app = createApp({ render: () => null }) + app.use(vaporInteropPlugin) + const vapor = (app._context as any).vapor + const slotsRef = shallowRef({ + default: () => [h('div', text.value)], }) + const frag = withOwnedSlotBoundary(boundary, () => + vapor.vdomSlot( + slotsRef, + 'default', + {}, + instance, + undefined, + false, + true, + ), + ) + const host = document.createElement('div') - container.append(carrierA, marker, carrierB, slotAnchor) - recheckSlotFallback(outlet) - - expect(container.innerHTML).toBe('abx!y') + insert(frag, host) + boundary.markDirty.mockClear() - syncActiveSlotFallback(outlet) + text.value = 'B' await nextTick() - expect(container.innerHTML).toBe('abxy!') - }) - - test('slot fallback outlet defaults to idle when isBusy is omitted', () => { - const fallback = document.createTextNode('fallback') - const outlet = createTestSlotFallbackOutlet({ - fallback: () => fallback, - }) - - outlet.boundary.markDirty() - - expect(outlet.activeFallback).toBe(fallback) + expect(host.innerHTML).toContain('
B
') + expect(boundary.markDirty).toHaveBeenCalledTimes(1) }) - test('vdom slot dirties parent boundary once when content stays valid', async () => { + test('vdom slot ignores non-root updates inside slot boundary', async () => { const text = ref('A') const boundary = { parent: null, @@ -795,7 +943,7 @@ describe('component: slots', () => { await nextTick() expect(host.innerHTML).toContain('
B
') - expect(boundary.markDirty).toHaveBeenCalledTimes(1) + expect(boundary.markDirty).not.toHaveBeenCalled() }) test('vdom slot dirties parent boundary once when switching from valid content to local fallback', async () => { @@ -814,8 +962,14 @@ describe('component: slots', () => { default: () => (show.value ? [h('div', 'content')] : []), }) const frag = withOwnedSlotBoundary(boundary, () => - vapor.vdomSlot(slotsRef, 'default', {}, instance, () => - template('fallback')(), + vapor.vdomSlot( + slotsRef, + 'default', + {}, + instance, + () => template('fallback')(), + false, + true, ), ) const host = document.createElement('div') @@ -846,7 +1000,15 @@ describe('component: slots', () => { default: () => (show.value ? [h('div', 'content')] : []), }) const frag = withOwnedSlotBoundary(boundary, () => - vapor.vdomSlot(slotsRef, 'default', {}, instance), + vapor.vdomSlot( + slotsRef, + 'default', + {}, + instance, + undefined, + false, + true, + ), ) const host = document.createElement('div') @@ -887,7 +1049,15 @@ describe('component: slots', () => { return child } const frag = withOwnedSlotBoundary(boundary, () => - vapor.vdomSlot(slotsRef, 'default', {}, instance, renderInnerFallback), + vapor.vdomSlot( + slotsRef, + 'default', + {}, + instance, + renderInnerFallback, + false, + true, + ), ) const host = document.createElement('div') @@ -1162,10 +1332,10 @@ describe('component: slots', () => { test('dynamic slot source inside forwarded slot should preserve owner', async () => { const msg = ref('late') const Leaf = defineVaporComponent(() => createSlot('late', null)) - const Carrier = defineVaporComponent(() => createSlot('default', null)) + const SlotHost = defineVaporComponent(() => createSlot('default', null)) const Root = defineVaporComponent(() => { const slots = useSlots() - return createComponent(Carrier, null, { + return createComponent(SlotHost, null, { default: withVaporCtx(() => createComponent(Leaf, null, { $: [ @@ -1344,6 +1514,8 @@ describe('component: slots', () => { () => { return document.createTextNode('content') }, + undefined, + keyedSlotRootIfShape, ) }, }) @@ -1354,7 +1526,7 @@ describe('component: slots', () => { toggle.value = false await nextTick() - expect(html()).toBe('fallback') + expect(html()).toBe('fallback') toggle.value = true await nextTick() @@ -1381,13 +1553,15 @@ describe('component: slots', () => { () => { return document.createTextNode('content') }, + undefined, + keyedSlotRootIfShape, ) }, }) }, }).render() - expect(html()).toBe('fallback') + expect(html()).toBe('fallback') toggle.value = true await nextTick() @@ -1395,7 +1569,7 @@ describe('component: slots', () => { toggle.value = false await nextTick() - expect(html()).toBe('fallback') + expect(html()).toBe('fallback') }) test('dynamic slot work with v-if', async () => { @@ -1514,13 +1688,15 @@ describe('component: slots', () => { () => { return document.createTextNode('content') }, + undefined, + keyedSlotRootIfShape, ) }, }) }, }).render() - expect(html()).toBe('fallback') + expect(html()).toBe('fallback') toggle.value = true await nextTick() @@ -1528,7 +1704,7 @@ describe('component: slots', () => { toggle.value = false await nextTick() - expect(html()).toBe('fallback') + expect(html()).toBe('fallback') }) test('render fallback with nested v-if', async () => { @@ -1555,19 +1731,23 @@ describe('component: slots', () => { () => { return document.createTextNode('content') }, + undefined, + keyedSlotRootIfShape, ) }, + undefined, + keyedSlotRootIfShape, ) }, }) }, }).render() - expect(html()).toBe('fallback') + expect(html()).toBe('fallback') outerShow.value = true await nextTick() - expect(html()).toBe('fallback') + expect(html()).toBe('fallback') innerShow.value = true await nextTick() @@ -1575,15 +1755,15 @@ describe('component: slots', () => { innerShow.value = false await nextTick() - expect(html()).toBe('fallback') + expect(html()).toBe('fallback') outerShow.value = false await nextTick() - expect(html()).toBe('fallback') + expect(html()).toBe('fallback') outerShow.value = true await nextTick() - expect(html()).toBe('fallback') + expect(html()).toBe('fallback') innerShow.value = true await nextTick() @@ -1614,6 +1794,8 @@ describe('component: slots', () => { ) return n4 }, + undefined, + slotRootForFlags, ) return n2 }, @@ -1625,11 +1807,11 @@ describe('component: slots', () => { items.value.pop() await nextTick() - expect(html()).toBe('fallback') + expect(html()).toBe('fallback') items.value.pop() await nextTick() - expect(html()).toBe('fallback') + expect(html()).toBe('fallback') items.value.push(2) await nextTick() @@ -1660,6 +1842,8 @@ describe('component: slots', () => { ) return n4 }, + undefined, + slotRootForFlags, ) return n2 }, @@ -1667,7 +1851,7 @@ describe('component: slots', () => { }, }).render() - expect(html()).toBe('fallback') + expect(html()).toBe('fallback') items.value.push(1) await nextTick() @@ -1675,11 +1859,11 @@ describe('component: slots', () => { items.value.pop() await nextTick() - expect(html()).toBe('fallback') + expect(html()).toBe('fallback') items.value.pop() await nextTick() - expect(html()).toBe('fallback') + expect(html()).toBe('fallback') items.value.push(2) await nextTick() @@ -1713,16 +1897,19 @@ describe('component: slots', () => { ) return n5 }, + undefined, + keyedSlotRootIfShape, ) }, item => item.text, + slotRootForFlags, ) }, }) }, }).render() - expect(html()).toBe('fallback') + expect(html()).toBe('fallback') items.value[0].show = true await nextTick() @@ -1730,7 +1917,7 @@ describe('component: slots', () => { items.value[0].show = false await nextTick() - expect(html()).toBe('fallback') + expect(html()).toBe('fallback') }) test('should not render fallback for a single empty item in v-for', async () => { @@ -1763,9 +1950,12 @@ describe('component: slots', () => { ) return n5 }, + undefined, + keyedSlotRootIfShape, ) }, item => item.text, + slotRootForFlags, ) }, }) @@ -2432,6 +2622,8 @@ describe('component: slots', () => { }) return n5 }, + undefined, + keyedSlotRootIfShape, ) return n0 }), @@ -2489,6 +2681,8 @@ describe('component: slots', () => { const n4 = template('
if content
')() return n4 }, + undefined, + keyedSlotRootIfShape, ) return n2 }) @@ -2507,7 +2701,7 @@ describe('component: slots', () => { }, }).render() - expect(html()).toBe('child fallback') + expect(html()).toBe('child fallback') show.value = true await nextTick() @@ -2539,6 +2733,8 @@ describe('component: slots', () => { ) return n4 }, + undefined, + slotRootForFlags, ) return n2 }) @@ -2557,7 +2753,7 @@ describe('component: slots', () => { }, }).render() - expect(html()).toBe('child fallback') + expect(html()).toBe('child fallback') items.value.push(1) await nextTick() @@ -2565,7 +2761,8 @@ describe('component: slots', () => { items.value.pop() await nextTick() - expect(html()).toBe('child fallback') + await nextTick() + expect(html()).toBe('child fallback') }) test('consecutive slots with insertion state', async () => { @@ -3110,7 +3307,7 @@ describe('component: slots', () => { expect(fallbackSpy).toHaveBeenCalledTimes(2) }) - test('moving active vdom fallback keeps slot carrier order after teleport move', async () => { + test('moving active vdom fallback keeps slot anchor order after teleport move', async () => { const targetA = document.createElement('div') targetA.id = 'component-slots-fallback-target-a' const targetB = document.createElement('div') @@ -3286,11 +3483,11 @@ describe('component: slots', () => { const root = document.createElement('div') createVaporApp(VaporForwardedSlot).use(vaporInteropPlugin).mount(root) - expect(root.innerHTML).toBe('
fallback
') + expect(root.innerHTML).toBe('
fallback
') useFallback.value = false await nextTick() - expect(root.innerHTML).toBe('') + expect(root.innerHTML).toBe('') }) test('vdom fallback toggles between local and inherited fallback for non-slot-fragment content', async () => { @@ -3335,15 +3532,15 @@ describe('component: slots', () => { const root = document.createElement('div') createVaporApp(VaporForwardedSlot).use(vaporInteropPlugin).mount(root) - expect(root.innerHTML).toBe('
local fallback
') + expect(root.innerHTML).toBe('
local fallback
') useFallback.value = false await nextTick() - expect(root.innerHTML).toBe('
outer fallback
') + expect(root.innerHTML).toBe('
outer fallback
') useFallback.value = true await nextTick() - expect(root.innerHTML).toBe('
local fallback
') + expect(root.innerHTML).toBe('
local fallback
') }) test('nested interop vapor slot fallback should satisfy enclosing vapor slot validity after content becomes invalid', async () => { @@ -3368,6 +3565,8 @@ describe('component: slots', () => { createIf( () => showContent.value, () => template('content')(), + undefined, + slotRootIfShape, ), ), }, @@ -3398,9 +3597,7 @@ describe('component: slots', () => { showContent.value = false await nextTick() - expect(root.innerHTML).toBe( - '
local fallback
', - ) + expect(root.innerHTML).toBe('
local fallback
') }) test('vdom fallback addition activates inherited vapor fallback', async () => { @@ -3436,7 +3633,7 @@ describe('component: slots', () => { useFallback.value = true await nextTick() - expect(root.innerHTML).toBe('
fallback
') + expect(root.innerHTML).toBe('
fallback
') }) test('vdom fallback addition should not remount valid forwarded vapor content', async () => { @@ -3497,6 +3694,8 @@ describe('component: slots', () => { return createIf( () => showContent.value, () => template('content')(), + undefined, + keyedSlotRootIfShape, ) }, }) @@ -3537,7 +3736,7 @@ describe('component: slots', () => { showContent.value = false await nextTick() - expect(root.innerHTML).toBe('
fallback
') + expect(root.innerHTML).toBe('
fallback
') expect(mountSpy).toHaveBeenCalledTimes(1) }) @@ -3622,6 +3821,8 @@ describe('component: slots', () => { createIf( () => showInner.value, () => template('inner')(), + undefined, + slotRootIfShape, ), ), }, @@ -3649,7 +3850,7 @@ describe('component: slots', () => { showInner.value = false await nextTick() expect(root.innerHTML).toBe( - 'stable
outer fallback
', + 'stable
outer fallback
', ) }) @@ -3691,6 +3892,8 @@ describe('component: slots', () => { createIf( () => showInner.value, () => template('inner')(), + undefined, + slotRootIfShape, ), ), }, @@ -3720,7 +3923,7 @@ describe('component: slots', () => { showInner.value = false await nextTick() expect(root.innerHTML).toBe( - 'stable
outer fallback
', + 'stable
outer fallback
', ) }) diff --git a/packages/runtime-vapor/__tests__/dom/prop.spec.ts b/packages/runtime-vapor/__tests__/dom/prop.spec.ts index 65f285ba0cb..485c3a3a4f4 100644 --- a/packages/runtime-vapor/__tests__/dom/prop.spec.ts +++ b/packages/runtime-vapor/__tests__/dom/prop.spec.ts @@ -1,4 +1,4 @@ -import { NOOP, toDisplayString } from '@vue/shared' +import { NOOP, VaporDynamicComponentFlags, toDisplayString } from '@vue/shared' import { setDynamicProp as _setDynamicProp, setAttr, @@ -756,7 +756,12 @@ describe('patchProp', () => { const value = ref('foo') const { html } = define({ setup() { - const n1 = createDynamicComponent(() => Comp, null, null, true) + const n1 = createDynamicComponent( + () => Comp, + null, + null, + VaporDynamicComponentFlags.SINGLE_ROOT, + ) renderEffect(() => setBlockText(n1, toDisplayString(value))) return n1 }, @@ -769,7 +774,12 @@ describe('patchProp', () => { const value = ref('foo') const { html } = define({ setup() { - const n1 = createDynamicComponent(() => 'button', null, null, true) + const n1 = createDynamicComponent( + () => 'button', + null, + null, + VaporDynamicComponentFlags.SINGLE_ROOT, + ) renderEffect(() => setBlockText(n1, toDisplayString(value))) return n1 }, @@ -848,7 +858,12 @@ describe('patchProp', () => { const value = ref('

foo

') const { html } = define({ setup() { - const n1 = createDynamicComponent(() => Comp, null, null, true) + const n1 = createDynamicComponent( + () => Comp, + null, + null, + VaporDynamicComponentFlags.SINGLE_ROOT, + ) renderEffect(() => setBlockHtml(n1, value.value)) return n1 }, @@ -861,7 +876,12 @@ describe('patchProp', () => { const value = ref('

foo

') const { html } = define({ setup() { - const n1 = createDynamicComponent(() => 'button', null, null, true) + const n1 = createDynamicComponent( + () => 'button', + null, + null, + VaporDynamicComponentFlags.SINGLE_ROOT, + ) renderEffect(() => setBlockHtml(n1, value.value)) return n1 }, diff --git a/packages/runtime-vapor/__tests__/hydration.spec.ts b/packages/runtime-vapor/__tests__/hydration.spec.ts index 43753b48970..ad778d1e644 100644 --- a/packages/runtime-vapor/__tests__/hydration.spec.ts +++ b/packages/runtime-vapor/__tests__/hydration.spec.ts @@ -3523,7 +3523,17 @@ describe('Vapor Mode hydration', () => { expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot( ` " -
baz
+
baz
+ " + `, + ) + + data.items[0].show = true + await nextTick() + expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot( + ` + " + bar " `, ) @@ -3696,9 +3706,9 @@ describe('Vapor Mode hydration', () => { ) expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot( ` - "
- foo -
" + "
+ foo +
" `, ) @@ -3706,9 +3716,9 @@ describe('Vapor Mode hydration', () => { await nextTick() expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot( ` - "
- foo1 -
" + "
+ foo1 +
" `, ) }) @@ -10791,7 +10801,7 @@ describe('VDOM interop', () => { ` " -
outlet fallback
+
outlet fallback
" `, @@ -10803,7 +10813,7 @@ describe('VDOM interop', () => { ` " -
updated outlet fallback
+
updated outlet fallback
" `, @@ -11023,7 +11033,7 @@ describe('VDOM interop', () => { ` " -
foo

bar

+
foo

bar

tail " `, @@ -11068,7 +11078,7 @@ describe('VDOM interop', () => { "
foo -
+
" `, ) @@ -11082,7 +11092,7 @@ describe('VDOM interop', () => { "
bar -
+
" `, ) @@ -11137,7 +11147,7 @@ describe('VDOM interop', () => { "
foo - +
" `) }) diff --git a/packages/runtime-vapor/__tests__/scopeId.spec.ts b/packages/runtime-vapor/__tests__/scopeId.spec.ts index 515a7843b67..9a757c264cc 100644 --- a/packages/runtime-vapor/__tests__/scopeId.spec.ts +++ b/packages/runtime-vapor/__tests__/scopeId.spec.ts @@ -6,7 +6,7 @@ import { ref, renderSlot, } from '@vue/runtime-dom' -import { VaporSlotFlags } from '@vue/shared' +import { VaporDynamicComponentFlags, VaporSlotFlags } from '@vue/shared' import { VaporTransition, createComponent, @@ -176,7 +176,12 @@ describe('scopeId', () => { const Comp = defineVaporComponent({ __scopeId: 'child', setup() { - return createDynamicComponent(() => 'button', null, null, true) + return createDynamicComponent( + () => 'button', + null, + null, + VaporDynamicComponentFlags.SINGLE_ROOT, + ) }, }) const { html } = define({ @@ -194,7 +199,12 @@ describe('scopeId', () => { const Comp = defineVaporComponent({ __scopeId: 'child', setup() { - return createDynamicComponent(() => 'button', null, null, true) + return createDynamicComponent( + () => 'button', + null, + null, + VaporDynamicComponentFlags.SINGLE_ROOT, + ) }, }) const { html } = define({ @@ -1000,7 +1010,12 @@ describe('vdom interop', () => { const VaporChild = defineVaporComponent({ __scopeId: 'vapor-child', setup() { - return createDynamicComponent(() => 'button', null, null, true) + return createDynamicComponent( + () => 'button', + null, + null, + VaporDynamicComponentFlags.SINGLE_ROOT, + ) }, }) diff --git a/packages/runtime-vapor/__tests__/vdomInterop.spec.ts b/packages/runtime-vapor/__tests__/vdomInterop.spec.ts index 7e9e1cc37d3..d7bfde2bde0 100644 --- a/packages/runtime-vapor/__tests__/vdomInterop.spec.ts +++ b/packages/runtime-vapor/__tests__/vdomInterop.spec.ts @@ -32,7 +32,7 @@ import { withCtx, withDirectives, } from '@vue/runtime-dom' -import { VaporSlotFlags } from '@vue/shared' +import { VaporDynamicComponentFlags, VaporSlotFlags } from '@vue/shared' import { VaporSlot } from '../../runtime-core/src/vnode' import { compile, makeInteropRender } from './_utils' import { @@ -566,7 +566,7 @@ describe('vdomInterop', () => { return input }, }, - true, + VaporDynamicComponentFlags.SINGLE_ROOT, ) }, }) @@ -4187,7 +4187,7 @@ describe('vdomInterop', () => { { default: () => template('teleported')(), }, - true, + VaporDynamicComponentFlags.SINGLE_ROOT, ) }, }) @@ -4267,7 +4267,7 @@ describe('vdomInterop', () => { } }) - test('keeps slot fallback before carrier anchor after teleport move and fallback update', async () => { + test('keeps slot fallback before slot anchor after teleport move and fallback update', async () => { const targetA = document.createElement('div') targetA.id = 'interop-slot-fallback-target-a' const targetB = document.createElement('div') @@ -4740,7 +4740,7 @@ describe('vdomInterop', () => { () => h(VDomAsyncChild as any), null, null, - true, + VaporDynamicComponentFlags.SINGLE_ROOT, ), fallback: () => template('loading')(), }, @@ -4773,7 +4773,7 @@ describe('vdomInterop', () => { default: () => template('resolved')(), fallback: () => template('fallback')(), }, - true, + VaporDynamicComponentFlags.SINGLE_ROOT, ) }, }) diff --git a/packages/runtime-vapor/src/apiCreateDynamicComponent.ts b/packages/runtime-vapor/src/apiCreateDynamicComponent.ts index 737a49833b1..450563515c3 100644 --- a/packages/runtime-vapor/src/apiCreateDynamicComponent.ts +++ b/packages/runtime-vapor/src/apiCreateDynamicComponent.ts @@ -8,7 +8,7 @@ import { resolveDynamicComponent, setCurrentRenderingInstance, } from '@vue/runtime-dom' -import { ShapeFlags } from '@vue/shared' +import { ShapeFlags, VaporDynamicComponentFlags } from '@vue/shared' import { insert, isBlock } from './block' import { type LooseRawSlots, @@ -41,9 +41,11 @@ export function createDynamicComponent( getter: () => any, rawProps?: RawProps | null, rawSlots?: LooseRawSlots | null, - isSingleRoot?: boolean, - once?: boolean, + flags: number = 0, ): VaporFragment { + const isSingleRoot = !!(flags & VaporDynamicComponentFlags.SINGLE_ROOT) + const once = !!(flags & VaporDynamicComponentFlags.ONCE) + const slotRoot = !!(flags & VaporDynamicComponentFlags.SLOT_ROOT) const _insertionParent = insertionParent const _insertionAnchor = insertionAnchor if (!isHydrating) resetInsertionState() @@ -53,8 +55,8 @@ export function createDynamicComponent( const frag = isHydrating || __DEV__ - ? new DynamicFragment('dynamic-component') - : new DynamicFragment() + ? new DynamicFragment('dynamic-component', false, true, slotRoot) + : new DynamicFragment(undefined, false, true, slotRoot) const normalizedRawSlots = normalizeRawSlots(rawSlots) const scopeOwner = getScopeOwner() diff --git a/packages/runtime-vapor/src/apiCreateFor.ts b/packages/runtime-vapor/src/apiCreateFor.ts index 8cf65e6ec2e..74abad384ce 100644 --- a/packages/runtime-vapor/src/apiCreateFor.ts +++ b/packages/runtime-vapor/src/apiCreateFor.ts @@ -117,7 +117,7 @@ export const createFor = ( parentAnchor = __DEV__ ? createComment('for') : createTextNode() } - const frag = new ForFragment(oldBlocks) + const frag = new ForFragment(oldBlocks, !!(flags & VaporVForFlags.SLOT_ROOT)) const instance = currentInstance! const isComponent = !!(flags & VaporVForFlags.IS_COMPONENT) const canUseFastRemove = @@ -823,16 +823,8 @@ function normalizeAnchor(node: Block): Node { } else if (isVaporComponent(node)) { return normalizeAnchor(node.block!) } else { - const getEffectiveOutput = ( - node as VaporFragment & { - getEffectiveOutput?: () => Block - } - ).getEffectiveOutput - // SlotFragment may render active fallback while keeping carriers in nodes. - const nodes = getEffectiveOutput - ? getEffectiveOutput.call(node) - : node.nodes - // Empty ForFragment keeps its insertion carrier in `nodes`, even though it + const nodes = node.nodes + // Empty ForFragment keeps its insertion anchor in `nodes`, even though it // is not a valid content block. return isValidBlock(nodes) ? normalizeAnchor(nodes) diff --git a/packages/runtime-vapor/src/apiCreateIf.ts b/packages/runtime-vapor/src/apiCreateIf.ts index 4059379576d..8a1474b4798 100644 --- a/packages/runtime-vapor/src/apiCreateIf.ts +++ b/packages/runtime-vapor/src/apiCreateIf.ts @@ -52,10 +52,11 @@ export function createIf( const index = flags >> VaporIfFlags.INDEX_SHIFT const keyed = index > 0 const keyBase = keyed ? (index - 1) * 2 : 0 + const trackSlotBoundary = !!(flags & VaporIfFlags.SLOT_ROOT) frag = isHydrating || __DEV__ - ? new DynamicFragment('if', keyed, false) - : new DynamicFragment(undefined, keyed, false) + ? new DynamicFragment('if', keyed, false, trackSlotBoundary) + : new DynamicFragment(undefined, keyed, false, trackSlotBoundary) renderEffect(() => { const ok = condition() if (isHydrating) { diff --git a/packages/runtime-vapor/src/apiDefineAsyncComponent.ts b/packages/runtime-vapor/src/apiDefineAsyncComponent.ts index 6c294855abe..f3b7d45ed8a 100644 --- a/packages/runtime-vapor/src/apiDefineAsyncComponent.ts +++ b/packages/runtime-vapor/src/apiDefineAsyncComponent.ts @@ -121,6 +121,7 @@ export function defineVaporAsyncComponent( frag!.update(() => createInnerComp(resolvedComp!, instance)) return frag } + frag.validityPending = true const onError = (err: Error) => { setPendingRequest(null) @@ -136,6 +137,7 @@ export function defineVaporAsyncComponent( return load() .then(() => { resolvedComp = getResolvedComp() + frag.validityPending = false if (resolvedComp) { frag.update(() => createInnerComp(resolvedComp!, instance)) } @@ -143,6 +145,7 @@ export function defineVaporAsyncComponent( }) .catch(err => { onError(err) + frag.validityPending = false if (errorComponent) { frag.update(() => createInnerComp( @@ -185,6 +188,7 @@ export function defineVaporAsyncComponent( render = () => createComponent(loadingComponent) } + frag.validityPending = !render && !error.value frag.update(render) // Manually trigger cacheBlock for KeepAlive if (isKeepAliveEnabled && frag.keepAliveCtx) { diff --git a/packages/runtime-vapor/src/block.ts b/packages/runtime-vapor/src/block.ts index 35b78231ed1..c4354eba79d 100644 --- a/packages/runtime-vapor/src/block.ts +++ b/packages/runtime-vapor/src/block.ts @@ -22,6 +22,7 @@ import { } from './fragment' import { isTeleportEnabled, isTeleportFragment } from './teleport' import { isTransitionEnabled } from './transition' +import { isInteropEnabled } from './vdomInteropState' export interface VaporTransitionHooks extends TransitionHooks { state: TransitionState @@ -78,25 +79,13 @@ export function isValidBlock(block: Block | null | undefined): boolean { } else if (isArray(block)) { return block.length > 0 && block.some(isValidBlock) } else { - const isBlockValid = ( - block as VaporFragment & { - isBlockValid?: () => boolean - } - ).isBlockValid - if (isBlockValid) { - return isBlockValid.call(block) + if (isInteropEnabled && block.isBlockValid) { + return block.isBlockValid() } if (block.validityPending) { return true } - const getEffectiveOutput = ( - block as VaporFragment & { - getEffectiveOutput?: () => Block - } - ).getEffectiveOutput - return isValidBlock( - getEffectiveOutput ? getEffectiveOutput.call(block) : block.nodes, - ) + return isValidBlock(block.nodes) } } diff --git a/packages/runtime-vapor/src/componentSlots.ts b/packages/runtime-vapor/src/componentSlots.ts index 664971f40dc..3af117735b4 100644 --- a/packages/runtime-vapor/src/componentSlots.ts +++ b/packages/runtime-vapor/src/componentSlots.ts @@ -243,6 +243,7 @@ export function createSlot( instance, fallback, once, + !!(flags & VaporSlotFlags.SLOT_ROOT), ) } else { if (isHydrating) hydrationCursor = captureHydrationCursor() @@ -278,7 +279,7 @@ export function createSlot( if (fallback) { withOwnedSlotBoundary(slotFragment.parentSlotBoundary, () => { const fallbackBlock = fallback() - // Keep the live fallback owner on the SlotFragment itself. The + // Keep the live fallback block on the SlotFragment itself. The // native slot outlet is temporary and gets removed by CE slot // replacement, but the fragment remains Vapor's long-lived owner. slotFragment.customElementFallback = fallbackBlock diff --git a/packages/runtime-vapor/src/fragment.ts b/packages/runtime-vapor/src/fragment.ts index b92275315e4..daaaf301a0f 100644 --- a/packages/runtime-vapor/src/fragment.ts +++ b/packages/runtime-vapor/src/fragment.ts @@ -13,6 +13,7 @@ import { insert, isValidBlock, remove, + removeNode, } from './block' import { type GenericComponentInstance, @@ -67,12 +68,9 @@ export class VaporFragment< vnode?: VNode | null anchor?: Node parentComponent?: GenericComponentInstance | null - // Interop fragments can be visible to outer slot boundaries before their - // initial output has settled, both during hydration and during the first - // non-hydrating render pass. Treat them as valid until that resolution - // finishes so parents do not eagerly activate fallback against a child whose - // final block has not been determined yet. + // Async component fragments are valid while waiting for resolved output. validityPending?: boolean + isBlockValid?: () => boolean insert?: ( parent: ParentNode, anchor: Node | null, @@ -151,9 +149,9 @@ export class ForFragment extends VaporFragment { // O(1) instead of N per-item Map.delete calls. resetListeners?: (() => void)[] - constructor(nodes: Block[]) { + constructor(nodes: Block[], trackSlotBoundary: boolean) { super(nodes) - trackSlotBoundaryDirtying(this) + if (trackSlotBoundary) trackSlotBoundaryDirtying(this) } onReset(fn: () => void): void { @@ -263,7 +261,7 @@ export class DynamicFragment extends VaporFragment { anchorLabel?: string, keyed: boolean = false, locate: boolean = true, - trackSlotBoundary: boolean = true, + trackSlotBoundary: boolean = false, ) { super(EMPTY_BLOCK) if (keyed) this.keyed = true @@ -285,7 +283,12 @@ export class DynamicFragment extends VaporFragment { if (trackSlotBoundary) trackSlotBoundaryDirtying(this) } - update(render?: BlockFn, key: any = render, noScope: boolean = false): void { + update( + render?: BlockFn, + key: any = render, + noScope: boolean = false, + shouldInsert: boolean = true, + ): void { if (key === this.current) { // On initial hydration, `key === current` means `render` is empty, // so this fragment hydrates as empty content. @@ -314,9 +317,10 @@ export class DynamicFragment extends VaporFragment { const instance = currentInstance const prevSub = setActiveSub() - const parent = isHydrating ? null : this.anchor.parentNode + const parent = !isHydrating && shouldInsert ? this.anchor.parentNode : null + const wasMounted = this.current !== undefined // teardown previous branch - if (this.current !== undefined) { + if (wasMounted) { const scope = this.scope if (scope) { let retainScope = false @@ -356,9 +360,10 @@ export class DynamicFragment extends VaporFragment { parent, pending.key, pending.noScope, + true, ) } else { - this.renderBranch(render, transition, parent, key, noScope) + this.renderBranch(render, transition, parent, key, noScope, true) } } finally { setCurrentInstance(...prevInstance) @@ -403,7 +408,14 @@ export class DynamicFragment extends VaporFragment { } } - this.renderBranch(render, transition, parent, key, noScope) + this.renderBranch( + render, + transition, + parent, + key, + noScope, + wasMounted || !!parent, + ) setActiveSub(prevSub) if (isHydrating && this.anchorLabel !== 'slot' && !reusingDeferredAnchor) { @@ -417,6 +429,7 @@ export class DynamicFragment extends VaporFragment { parent: ParentNode | null, key: any, noScope: boolean = false, + notifyUpdated: boolean = !!parent, ): void { this.current = key if (render) { @@ -486,11 +499,9 @@ export class DynamicFragment extends VaporFragment { this.nodes = EMPTY_BLOCK } - if (parent) { - const onUpdated = this.onUpdated - if (onUpdated) { - onUpdated.forEach(hook => hook(this.nodes)) - } + const onUpdated = this.onUpdated + if (notifyUpdated && onUpdated) { + onUpdated.forEach(hook => hook(this.nodes)) } } @@ -780,116 +791,7 @@ function getRedirectedBoundary( export function trackSlotBoundaryDirtying(fragment: VaporFragment): void { const boundary = currentSlotBoundary if (!boundary) return - ;(fragment.onUpdated || (fragment.onUpdated = [])).push(() => - boundary.markDirty(), - ) -} - -function walkSlotFallbackBlock( - block: Block, - node: (node: Node) => boolean, - fragment: (block: VaporFragment, walk: (block: Block) => boolean) => boolean, -): boolean { - if (block instanceof Node) { - return node(block) - } - - if (isVaporComponent(block)) { - return walkSlotFallbackBlock(block.block, node, fragment) - } - - if (isArray(block)) { - for (const child of block) { - if (walkSlotFallbackBlock(child, node, fragment)) { - return true - } - } - return false - } - - return fragment(block, block => walkSlotFallbackBlock(block, node, fragment)) -} - -// Slot fallback preservation is keyed off the fragment owner, even though -// carrier relocation / ordering still operates on the full Block wrapper. -export function resolveSlotFallbackCarrierOwner( - block: Block, -): VaporFragment | null { - let owner: VaporFragment | null = null - walkSlotFallbackBlock( - block, - () => false, - block => { - owner = block - return true - }, - ) - return owner -} - -export function findFirstSlotFallbackCarrierNode(block: Block): Node | null { - let node: Node | null = null - walkSlotFallbackBlock( - block, - value => { - node = value - return true - }, - (block, walk) => { - if (walk(block.nodes)) { - return true - } - if (block.anchor) { - node = block.anchor - return true - } - return false - }, - ) - return node -} - -function collectBlockNodes( - block: Block, - nodes: Node[] = [], - includeComments: boolean = false, -): Node[] { - walkSlotFallbackBlock( - block, - block => { - if (includeComments || !(block instanceof Comment)) { - nodes.push(block) - } - return false - }, - block => { - collectBlockNodes(block.nodes, nodes, true) - if (block.anchor) { - nodes.push(block.anchor) - } - return false - }, - ) - return nodes -} - -export function mutateSlotFallbackCarrier( - block: Block, - apply: (block: Node | VaporFragment) => void, -): void { - walkSlotFallbackBlock( - block, - block => { - if (!(block instanceof Comment)) { - apply(block) - } - return false - }, - block => { - apply(block) - return false - }, - ) + ;(fragment.onUpdated ||= []).push(() => boundary.markDirty()) } export function hasSlotFallback( @@ -904,320 +806,247 @@ export function hasSlotFallback( return false } -export function renderSlotFallback( - boundary: SlotBoundaryContext | null | undefined, - scope?: EffectScope, - ...args: any[] -): Block | undefined { - const [block, hasFallback] = renderSlotFallbackBlock( - boundary || null, - scope, - args, - ) - return hasFallback ? block : undefined -} - -function renderSlotFallbackBlock( +function renderSlotFallback( boundary: SlotBoundaryContext | null, - scope: EffectScope | undefined, - args: any[], -): [Block, boolean] { + scope: EffectScope, +): Block | undefined { if (!boundary) { - return [[], false] + return undefined } const localFallback = boundary.getFallback() if (!localFallback) { - return renderSlotFallbackBlock(boundary.parent, scope, args) + return renderSlotFallback(boundary.parent, scope) } const renderFallback = () => withOwnedSlotBoundary(getRedirectedBoundary(boundary), () => - localFallback(...args), + localFallback(), ) - const local = boundary.run( - () => (scope ? scope.run(renderFallback) : renderFallback()) || [], - scope, - ) + const local = boundary.run(() => scope.run(renderFallback) || [], scope) if (isValidBlock(local)) { - return [local, true] + return local } - const [inherited] = renderSlotFallbackBlock(boundary.parent, scope, args) - return [ - resolveSlotFallbackCarrierOwner(local) ? [inherited, local] : inherited, - true, - ] + const inherited = renderSlotFallback(boundary.parent, scope) + return inherited === undefined ? local : inherited } -export interface SlotFallbackOutlet { +export interface SlotFallbackState { boundary: SlotBoundaryContext activeFallback: Block | null fallbackScope?: EffectScope - lastEffectiveValid?: boolean + lastNodesValid?: boolean pendingRecheck: boolean isRenderingFallback: boolean - rerunRecheckAfterFallbackRender?: boolean getContent(): Block getParentNode(): ParentNode | null getAnchor(): Node | null - isBusy?(): boolean - isDisposed?(): boolean - isContentValid?(): boolean - syncEffectiveOutput?(): void + isBusy(): boolean + isDisposed(): boolean + isContentValid(): boolean + syncNodes(): void notifyFallbackValidityChange(): void } -type SlotFallbackResult = - | { - found: true - block: Block - scope: EffectScope +function detachBlock(block: Block, parent: ParentNode): void { + if (block instanceof Node) { + if (block.parentNode === parent) { + removeNode(block, parent) } - | { - found: false + } else if (isVaporComponent(block)) { + if (block.block) { + detachBlock(block.block, parent) } - -export function getSlotEffectiveOutput(outlet: SlotFallbackOutlet): Block { - return outlet.activeFallback || outlet.getContent() -} - -function isSlotFallbackContentValid(outlet: SlotFallbackOutlet): boolean { - return outlet.isContentValid - ? outlet.isContentValid() - : isValidBlock(outlet.getContent()) + } else if (isArray(block)) { + for (let i = 0; i < block.length; i++) { + detachBlock(block[i], parent) + } + } else { + detachBlock(block.nodes, parent) + if ( + !(block instanceof SlotFragment) && + block.anchor && + block.anchor.parentNode === parent + ) { + removeNode(block.anchor, parent) + } + } } -export function markSlotFallbackDirty(outlet: SlotFallbackOutlet): void { - if (outlet.isDisposed && outlet.isDisposed()) { +export function markSlotFallbackDirty(state: SlotFallbackState): void { + if (state.isDisposed()) { return } - if (outlet.isRenderingFallback) { - if (isHydrating) { - outlet.pendingRecheck = true - } + if (state.isRenderingFallback) { + state.pendingRecheck = true return } - if (outlet.isBusy && outlet.isBusy()) { - outlet.pendingRecheck = true + if (state.isBusy()) { + state.pendingRecheck = true return } - recheckSlotFallback(outlet, true) + recheckSlotFallback(state, true) } -function clearSlotFallback(outlet: SlotFallbackOutlet): void { - if (outlet.activeFallback) { - const parentNode = outlet.getParentNode() +function clearSlotFallback(state: SlotFallbackState): void { + const fallback = state.activeFallback + if (fallback) { + const parentNode = state.getParentNode() if (parentNode) { - remove(outlet.activeFallback, parentNode) + remove(fallback, parentNode) } - outlet.activeFallback = null + state.activeFallback = null } - if (outlet.fallbackScope) { - outlet.fallbackScope.stop() - outlet.fallbackScope = undefined + if (state.fallbackScope) { + state.fallbackScope.stop() + state.fallbackScope = undefined } } -function renderSlotFallbackForOutlet( - outlet: SlotFallbackOutlet, -): SlotFallbackResult { - const scope = new EffectScope() +function renderSlotFallbackState( + state: SlotFallbackState, +): { block: Block; scope: EffectScope } | undefined { + const scope = new EffectScope(true) let renderedFallback: Block | undefined - outlet.isRenderingFallback = true + state.isRenderingFallback = true try { - renderedFallback = renderSlotFallback(outlet.boundary, scope) || undefined + renderedFallback = renderSlotFallback(state.boundary, scope) } catch (err) { scope.stop() throw err } finally { - outlet.isRenderingFallback = false + state.isRenderingFallback = false } if (!renderedFallback) { scope.stop() - return { found: false } + return undefined } return { - found: true, block: renderedFallback, scope, } } -function syncSlotFallbackOrder(outlet: SlotFallbackOutlet, block: Block): void { - if (!isFragment(block) || !isArray(block.nodes) || block.nodes.length < 2) { - return - } - - const carrierNodes = collectBlockNodes(outlet.getContent(), [], true) - const fallbackNodes = collectBlockNodes(block, [], true) - const lastNode = fallbackNodes[fallbackNodes.length - 1] - if (!carrierNodes.length || !lastNode) { - return - } - - const parentNode = carrierNodes[0].parentNode - if (!parentNode || lastNode.parentNode !== parentNode) { - return - } - - let inOrder = true - let nextNode = lastNode.nextSibling - for (const carrierNode of carrierNodes) { - if (carrierNode.parentNode !== parentNode) { - return - } - if (carrierNode !== nextNode) { - inOrder = false - break - } - nextNode = carrierNode.nextSibling - } - - if (inOrder) { - return - } - - let anchor = lastNode.nextSibling - for (let i = carrierNodes.length - 1; i >= 0; i--) { - const carrierNode = carrierNodes[i] - parentNode.insertBefore(carrierNode, anchor) - anchor = carrierNode as ChildNode - } -} - -function ensureSlotFallbackOrderHook( - outlet: SlotFallbackOutlet, - block: Block, -): void { - if (!isFragment(block)) { - return - } - - const fragment = block as VaporFragment & { - hasSlotFallbackOrderHook?: boolean - } - if (fragment.hasSlotFallbackOrderHook) { - return - } - - ;(fragment.onUpdated || (fragment.onUpdated = [])).push(() => - syncSlotFallbackOrder(outlet, fragment), - ) - fragment.hasSlotFallbackOrderHook = true -} - -export function insertActiveSlotFallback(outlet: SlotFallbackOutlet): void { - if (isHydrating || !outlet.activeFallback) { +export function insertActiveSlotFallback(state: SlotFallbackState): void { + const fallback = state.activeFallback + if (isHydrating || !fallback || !isValidBlock(fallback)) { return } - const parentNode = outlet.getParentNode() + const parentNode = state.getParentNode() if (!parentNode) { return } - const carrierAnchor = findFirstSlotFallbackCarrierNode(outlet.getContent()) - insert( - outlet.activeFallback, - parentNode, - carrierAnchor && carrierAnchor.parentNode === parentNode - ? carrierAnchor - : outlet.getAnchor(), - ) + insert(fallback, parentNode, state.getAnchor()) } function commitSlotFallback( - outlet: SlotFallbackOutlet, + state: SlotFallbackState, block: Block, scope: EffectScope, + detachContent: boolean, ): void { - outlet.activeFallback = block - outlet.fallbackScope = scope - ensureSlotFallbackOrderHook(outlet, block) + const parentNode = state.getParentNode() + if (detachContent && !isHydrating && parentNode) { + detachBlock(state.getContent(), parentNode) + } + state.activeFallback = block + state.fallbackScope = scope if (isTransitionEnabled) { - const transitionOutlet = outlet as SlotFallbackOutlet & TransitionOptions - if (transitionOutlet.$transition) { + const transitionState = state as SlotFallbackState & TransitionOptions + if (transitionState.$transition) { // Match VDOM slot fallback branch identity so fallback enter does not // early-remove the currently leaving slot content. setBlockKey(block, '_fb') - transitionOutlet.$transition = applyTransitionHooks( + transitionState.$transition = applyTransitionHooks( block, - transitionOutlet.$transition, + transitionState.$transition, ) } } - insertActiveSlotFallback(outlet) + insertActiveSlotFallback(state) } -export function syncActiveSlotFallback(outlet: SlotFallbackOutlet): void { - if (!outlet.activeFallback) { - return - } - const activeFallback = outlet.activeFallback - queuePostFlushCb(() => { - syncSlotFallbackOrder(outlet, activeFallback) - }) -} - -export function disposeSlotFallback(outlet: SlotFallbackOutlet): void { - clearSlotFallback(outlet) - outlet.pendingRecheck = false - outlet.lastEffectiveValid = undefined +export function disposeSlotFallback(state: SlotFallbackState): void { + clearSlotFallback(state) + state.pendingRecheck = false + state.lastNodesValid = undefined } export function recheckSlotFallback( - outlet: SlotFallbackOutlet, + state: SlotFallbackState, force: boolean = false, ): void { - if (outlet.isRenderingFallback) { - outlet.pendingRecheck = true + if (state.isRenderingFallback) { + state.pendingRecheck = true return } - const prevValid = - outlet.lastEffectiveValid === undefined - ? outlet.activeFallback - ? isValidBlock(outlet.activeFallback) - : isSlotFallbackContentValid(outlet) - : outlet.lastEffectiveValid - const contentValid = isSlotFallbackContentValid(outlet) + const fallback = state.activeFallback + const fallbackValid = fallback ? isValidBlock(fallback) : false + const contentValid = state.isContentValid() + const prevNodesValid = + state.lastNodesValid === undefined + ? fallback + ? fallbackValid + : contentValid + : state.lastNodesValid + if (!force && contentValid && !fallback && prevNodesValid) { + state.syncNodes() + state.lastNodesValid = true + return + } if (contentValid) { - clearSlotFallback(outlet) - } else { - if (force) { - clearSlotFallback(outlet) + const content = state.getContent() + const hadFallback = !!fallback + clearSlotFallback(state) + if (!isHydrating && hadFallback) { + const parentNode = state.getParentNode() + if (parentNode) { + insert(content, parentNode, state.getAnchor()) + } } - if (outlet.activeFallback) { - insertActiveSlotFallback(outlet) - } else { - const result = renderSlotFallbackForOutlet(outlet) - if (result.found) { - commitSlotFallback(outlet, result.block, result.scope) - if ( - outlet.pendingRecheck && - outlet.rerunRecheckAfterFallbackRender !== false - ) { - outlet.pendingRecheck = false - recheckSlotFallback(outlet, true) + } else { + if ( + fallback && + prevNodesValid && + !fallbackValid && + !hasSlotFallback(state.boundary.parent) + ) { + const parentNode = state.getParentNode() + if (parentNode) { + detachBlock(fallback, parentNode) + } + } else if (fallback && !prevNodesValid && fallbackValid) { + insertActiveSlotFallback(state) + } else if (force || !fallback) { + const hadFallback = !!fallback + const result = renderSlotFallbackState(state) + clearSlotFallback(state) + if (result) { + commitSlotFallback(state, result.block, result.scope, !hadFallback) + if (state.pendingRecheck) { + state.pendingRecheck = false + recheckSlotFallback(state, true) } - } else { - clearSlotFallback(outlet) } + } else { + insertActiveSlotFallback(state) } } - const nextValid = outlet.activeFallback - ? isValidBlock(outlet.activeFallback) - : isSlotFallbackContentValid(outlet) - if (outlet.syncEffectiveOutput) { - outlet.syncEffectiveOutput() - } - outlet.lastEffectiveValid = nextValid - if (prevValid !== nextValid) { - outlet.notifyFallbackValidityChange() + const nextFallback = state.activeFallback + const nextNodesValid = nextFallback + ? isValidBlock(nextFallback) + : state.isContentValid() + state.syncNodes() + state.lastNodesValid = nextNodesValid + if (prevNodesValid !== nextNodesValid) { + state.notifyFallbackValidityChange() } } @@ -1314,22 +1143,19 @@ function isReusableDynamicFragmentAnchor( ) } -export class SlotFragment - extends DynamicFragment - implements SlotFallbackOutlet -{ +export class SlotFragment extends DynamicFragment implements SlotFallbackState { private disposed = false forwarded = false parentSlotBoundary: SlotBoundaryContext | null = getCurrentSlotBoundary() // Custom elements with `shadowRoot: false` replace their native slot outlet - // after mount. Keep the live fallback owner on the fragment so CE slot sync + // after mount. Keep the live fallback block on the fragment so CE slot sync // can preserve block ownership after the outlet node is gone. customElementFallback?: Block activeFallback: Block | null = null fallbackScope?: EffectScope pendingRecheck = false isRenderingFallback = false - readonly rerunRecheckAfterFallbackRender = false + private content: Block = EMPTY_BLOCK private localFallback?: BlockFn private isUpdatingSlot = false private _slotFallbackBoundary?: SlotBoundaryContext @@ -1369,32 +1195,31 @@ export class SlotFragment return this.ensureSlotFallbackBoundary() } - getEffectiveOutput(): Block { - return getSlotEffectiveOutput(this) - } - private insertSlot(parent: ParentNode, anchor: Node | null): void { this.disposed = false - if (this.fallbackBlock) { - insert(this.fallbackBlock, parent, anchor) - mutateSlotFallbackCarrier(this.nodes, block => - insert(block, parent, anchor), - ) - return - } insert(this.nodes, parent, anchor) } private removeSlot(parent?: ParentNode): void { this.disposed = true - if (this.fallbackBlock) { - mutateSlotFallbackCarrier(this.nodes, block => remove(block, parent)) - } else { - remove(this.nodes, parent) + const nodes = this.nodes + remove(nodes, parent) + if (this.activeFallback === nodes) { + this.activeFallback = null } disposeSlotFallback(this) } + private updateContent(render: BlockFn | undefined, key: any): void { + this.nodes = this.content + // When fallback is active, recompute content without inserting it. The + // content may still be invalid, so recheckSlotFallback decides whether it + // can return to the DOM. + this.update(render, key, false, !this.activeFallback) + this.content = this.nodes + this.nodes = this.activeFallback || this.content + } + updateSlot( render?: BlockFn, fallback?: BlockFn, @@ -1412,6 +1237,7 @@ export class SlotFragment !this._slotFallbackBoundary ) { this.update(render, fastSlotKey) + this.content = this.nodes return } @@ -1432,8 +1258,8 @@ export class SlotFragment if (hasSlotFallback(boundary)) { setCurrentHydratingSlotFallbackActive(true) } - this.update(slotRender, slotKey) - const contentValid = isValidBlock(this.nodes) + this.updateContent(slotRender, slotKey) + const contentValid = isValidBlock(this.content) recheckSlotFallback(this, shouldForce) // Updates run under the temporary fallback-active marker so empty // inner branches can materialize their own anchors if fallback @@ -1443,13 +1269,13 @@ export class SlotFragment if (!hasSlotFallback(boundary) || contentValid) { setCurrentHydratingSlotFallbackActive(prev) } - this.hydrate(!isValidBlock(this.getEffectiveOutput()), true) + this.hydrate(!isValidBlock(this.nodes), true) } finally { setCurrentHydratingSlotFallbackActive(prev) } }) } else { - this.update(slotRender, slotKey) + this.updateContent(slotRender, slotKey) recheckSlotFallback(this, shouldForce) } } finally { @@ -1459,7 +1285,7 @@ export class SlotFragment } getContent(): Block { - return this.nodes + return this.content } getParentNode(): ParentNode | null { @@ -1478,6 +1304,14 @@ export class SlotFragment return this.disposed } + isContentValid(): boolean { + return isValidBlock(this.content) + } + + syncNodes(): void { + this.nodes = this.activeFallback || this.content + } + notifyFallbackValidityChange(): void { if (this.parentSlotBoundary) { this.parentSlotBoundary.markDirty() diff --git a/packages/runtime-vapor/src/vdomInterop.ts b/packages/runtime-vapor/src/vdomInterop.ts index 71aefdb17ca..a2942d74d2f 100644 --- a/packages/runtime-vapor/src/vdomInterop.ts +++ b/packages/runtime-vapor/src/vdomInterop.ts @@ -114,20 +114,16 @@ import { import { type DynamicFragment, type SlotBoundaryContext, - type SlotFallbackOutlet, + type SlotFallbackState, SlotFragment, VaporFragment, disposeSlotFallback, - findFirstSlotFallbackCarrierNode, getCurrentSlotEndAnchor, - getSlotEffectiveOutput, hasSlotFallback, insertActiveSlotFallback, isFragment, markSlotFallbackDirty, - mutateSlotFallbackCarrier, recheckSlotFallback, - syncActiveSlotFallback, trackSlotBoundaryDirtying, withHydratingSlotBoundary, withHydratingSlotFallbackActive, @@ -774,7 +770,7 @@ function resolveVNodeRange(vnode: VNode): [Node, Node] | undefined { function resolveVNodeNodes(vnode: VNode): Block { // Vapor component VNodes expose only their first root on `vnode.el`. - // Use the mounted block so multi-root output keeps slot carriers and other + // Use the mounted block so multi-root output keeps slot anchors and other // trailing anchors in the resolved block shape. if (!isHydrating && vnode.component && isVaporComponent(vnode.component)) { const block = (vnode.component as any).block as Block | undefined @@ -834,7 +830,11 @@ function appendVnodeBeforeUpdateHook(vnode: VNode, hook: () => void): void { : hook } -function trackFragmentVNodeUpdates(frag: VaporFragment, vnode: VNode): void { +function trackFragmentVNodeUpdates( + frag: VaporFragment, + vnode: VNode, + syncNodes: () => void, +): void { const beforeUpdate = () => { if (frag.onBeforeUpdate) { for (let i = 0; i < frag.onBeforeUpdate.length; i++) { @@ -843,8 +843,7 @@ function trackFragmentVNodeUpdates(frag: VaporFragment, vnode: VNode): void { } } const updated = () => { - frag.nodes = resolveVNodeNodes(vnode) - frag.validityPending = false + syncNodes() if (frag.onUpdated) frag.onUpdated.forEach(m => m()) } appendVnodeBeforeUpdateHook(vnode, beforeUpdate) @@ -862,10 +861,15 @@ function mountVNode( const suspense = currentParentSuspense || (parentComponent && parentComponent.suspense) const frag = new VaporFragment([]) - frag.validityPending = !isHydrating frag.vnode = vnode frag.$key = vnode.key - trackFragmentVNodeUpdates(frag, vnode) + let validityPending = !isHydrating + const syncNodes = () => { + frag.nodes = resolveVNodeNodes(vnode) + validityPending = false + } + frag.isBlockValid = () => (validityPending ? true : isValidBlock(frag.nodes)) + trackFragmentVNodeUpdates(frag, vnode, syncNodes) let isMounted = false const unmount = (parentNode?: ParentNode, transition?: TransitionHooks) => { @@ -895,8 +899,7 @@ function mountVNode( hydrateVNode(vnode, parentComponent as any) onScopeDispose(unmount, true) isMounted = true - frag.nodes = resolveVNodeNodes(vnode) - frag.validityPending = false + syncNodes() } frag.insert = (parentNode, anchor, transition) => { @@ -946,8 +949,7 @@ function mountVNode( } simpleSetCurrentInstance(prev) } - frag.nodes = resolveVNodeNodes(vnode) - frag.validityPending = false + syncNodes() if (isMounted && frag.onUpdated) frag.onUpdated.forEach(m => m()) } @@ -971,13 +973,18 @@ function createVDOMComponent( const useBridge = shouldUseRendererBridge(component) const comp = useBridge ? ensureRendererBridge(component) : component const frag = new VaporFragment([]) - frag.validityPending = !isHydrating const vnode = (frag.vnode = createVNode( comp, rawProps && extend({}, new Proxy(rawProps, rawPropsProxyHandlers)), )) frag.$key = vnode.key - trackFragmentVNodeUpdates(frag, vnode) + let validityPending = !isHydrating + const syncNodes = () => { + frag.nodes = resolveVNodeNodes(vnode) + validityPending = false + } + frag.isBlockValid = () => (validityPending ? true : isValidBlock(frag.nodes)) + trackFragmentVNodeUpdates(frag, vnode, syncNodes) if ( !isCollectingVdomSlotVNodes && @@ -1093,8 +1100,7 @@ function createVDOMComponent( if (!isHydrating) return hydrateVNode(vnode, parentComponent as any) isMounted = true - frag.nodes = resolveVNodeNodes(vnode) - frag.validityPending = false + syncNodes() } vnode.scopeId = getCurrentScopeId() || null @@ -1143,8 +1149,7 @@ function createVDOMComponent( simpleSetCurrentInstance(prev) } - frag.nodes = resolveVNodeNodes(vnode) - frag.validityPending = false + syncNodes() if (isMounted && frag.onUpdated) frag.onUpdated.forEach(m => m()) } @@ -1371,11 +1376,13 @@ function renderVDOMSlot( parentComponent: VaporComponentInstance, fallback?: VaporSlot, once?: boolean, + slotRoot?: boolean, ): VaporFragment { const suspense = currentParentSuspense || parentComponent.suspense const frag = new VaporFragment([]) - trackSlotBoundaryDirtying(frag) - frag.validityPending = !isHydrating + if (slotRoot) trackSlotBoundaryDirtying(frag) + let validityPending = !isHydrating + frag.isBlockValid = () => (validityPending ? true : isValidBlock(frag.nodes)) const instance = currentInstance let isMounted = false @@ -1384,35 +1391,35 @@ function renderVDOMSlot( valid: false, rendered: null as VNode | Block | null, } - let currentParentNode: ParentNode | undefined - let currentAnchor: Node | null | undefined + let currentParentNode: ParentNode | null = null + let currentAnchor: Node | null = null let disposed = false const scope = effectScope() const inheritedBoundary = frag.inheritedSlotBoundary let isContentUpdateRecheck = false let localFallback: BlockFn | undefined - let outlet!: SlotFallbackOutlet + let fallbackState!: SlotFallbackState const boundary: SlotBoundaryContext = { get parent() { return inheritedBoundary }, getFallback: (): BlockFn | undefined => localFallback, run: fn => runWithFragmentRenderCtx(frag, fn), - markDirty: () => markSlotFallbackDirty(outlet), + markDirty: () => markSlotFallbackDirty(fallbackState), } - outlet = { + fallbackState = { boundary, activeFallback: null, pendingRecheck: false, isRenderingFallback: false, getContent: () => contentState.nodes, - getParentNode: () => - getSlotFallbackParentNode(currentParentNode, contentState.nodes), - getAnchor: () => currentAnchor || null, + getParentNode: () => currentParentNode, + getAnchor: () => currentAnchor, + isBusy: () => false, isDisposed: () => disposed, isContentValid: () => contentState.valid, - syncEffectiveOutput: () => { - frag.nodes = getSlotEffectiveOutput(outlet) + syncNodes: () => { + frag.nodes = fallbackState.activeFallback || contentState.nodes }, notifyFallbackValidityChange: () => { if (!isContentUpdateRecheck && inheritedBoundary) { @@ -1438,7 +1445,7 @@ function renderVDOMSlot( contentState.nodes = [] contentState.valid = false } - frag.validityPending = false + validityPending = false } const notifyUpdated = (): void => { @@ -1448,7 +1455,7 @@ function renderVDOMSlot( const recheckAfterContentUpdate = (forceFallbackRecheck = false): void => { isContentUpdateRecheck = true try { - recheckSlotFallback(outlet, forceFallbackRecheck) + recheckSlotFallback(fallbackState, forceFallbackRecheck) } finally { isContentUpdateRecheck = false } @@ -1461,7 +1468,7 @@ function renderVDOMSlot( frag.insert = (parentNode, anchor) => { if (isHydrating) return - currentParentNode = parentNode || undefined + currentParentNode = parentNode currentAnchor = anchor if (!isMounted) { @@ -1482,15 +1489,16 @@ function renderVDOMSlot( insert(contentState.rendered, parentNode, anchor) } - insertActiveSlotFallback(outlet) + insertActiveSlotFallback(fallbackState) } notifyUpdated() } frag.remove = parentNode => { - currentParentNode = parentNode || currentParentNode || undefined - currentAnchor = currentAnchor || null + if (parentNode) { + currentParentNode = parentNode + } scope.stop() disposed = true if (isVNode(contentState.rendered)) { @@ -1505,7 +1513,7 @@ function renderVDOMSlot( } else if (contentState.rendered) { remove(contentState.rendered, parentNode) } - disposeSlotFallback(outlet) + disposeSlotFallback(fallbackState) } const render = () => { @@ -1600,11 +1608,17 @@ function renderVDOMSlot( } }) const prevRendered = contentState.rendered - if (prevRendered && !isVNode(prevRendered)) { - remove(prevRendered, currentParentNode) + const prevIsVNode = isVNode(prevRendered) + const prevVNode = + prevIsVNode && + (!fallbackState.activeFallback || contentState.valid) + ? prevRendered + : null + if (prevRendered && !prevIsVNode) { + remove(prevRendered, currentParentNode!) } internals.p( - isVNode(prevRendered) ? prevRendered : null, + prevVNode, slotContent, currentParentNode!, currentAnchor, @@ -1625,7 +1639,7 @@ function renderVDOMSlot( if (isVNode(prevRendered)) { internals.um(prevRendered, parentComponent as any, null, true) } else if (prevRendered) { - remove(prevRendered, currentParentNode) + remove(prevRendered, currentParentNode!) } insert(slotContent, currentParentNode!, currentAnchor) setRenderedContent(slotContent) @@ -1641,7 +1655,7 @@ function renderVDOMSlot( true, ) } else if (contentState.rendered) { - remove(contentState.rendered, currentParentNode) + remove(contentState.rendered, currentParentNode!) } frag.vnode = null frag.$key = undefined @@ -1659,8 +1673,8 @@ function renderVDOMSlot( frag.hydrate = () => { if (!isHydrating) return scope.run(render) - currentParentNode = currentHydrationNode!.parentNode as ParentNode - currentAnchor = currentHydrationNode + currentAnchor = getCurrentSlotEndAnchor() || currentHydrationNode + currentParentNode = currentAnchor!.parentNode as ParentNode isMounted = true } @@ -1727,7 +1741,10 @@ function createFallback( if (isVNodeFallback()) { const frag = createVNodeChildrenFragment( internals, - () => (fallback() as VNodeArrayChildren).map(normalizeVNode), + () => { + const children = fallback() + return children == null ? [] : normalizeInteropSlotValue(children) + }, parentComponent, ) if (isHydrating && frag.hydrate) { @@ -1761,17 +1778,6 @@ function runWithFragmentRenderCtx(fragment: VaporFragment, fn: () => R): R { } } -function getSlotFallbackParentNode( - currentParentNode: ParentNode | null | undefined, - content: Block, -): ParentNode | null { - if (currentParentNode) { - return currentParentNode - } - const carrierAnchor = findFirstSlotFallbackCarrierNode(content) - return carrierAnchor ? (carrierAnchor.parentNode as ParentNode | null) : null -} - type InteropSlotFallback = { (): any __vdom?: boolean @@ -1858,7 +1864,9 @@ function renderVaporSlot( // fallback lifecycle. Forcing the interop wrapper to own that branch breaks // fallback blocks that can later resolve to an empty vnode list. const frag = new VaporFragment([]) - frag.validityPending = !isHydrating + let validityPending = !isHydrating + frag.isBlockValid = () => + validityPending ? true : isValidBlock(frag.nodes) const inheritedBoundary = frag.inheritedSlotBoundary let contentNodes: Block = [] let isResolvingContent = false @@ -1868,13 +1876,13 @@ function renderVaporSlot( let currentAnchor: Node | null = null let slotScope: ReturnType | undefined let disposed = false - let outlet!: SlotFallbackOutlet + let fallbackState!: SlotFallbackState let ownedSlotFragment: SlotFragment | undefined let ownedSlotFragmentDirtyQueued = false const markInteropFallbackDirty = (): void => { const target = ownedSlotFragment if (!target) { - markSlotFallbackDirty(outlet) + markSlotFallbackDirty(fallbackState) return } if (ownedSlotFragmentDirtyQueued) { @@ -1884,7 +1892,6 @@ function renderVaporSlot( queuePostFlushCb(() => { ownedSlotFragmentDirtyQueued = false markSlotFallbackDirty(target) - syncActiveSlotFallback(target) }) } const outletFallbackBoundary: SlotBoundaryContext = { @@ -1905,20 +1912,20 @@ function renderVaporSlot( run: fn => runWithFragmentRenderCtx(frag, fn), markDirty: markInteropFallbackDirty, } - outlet = { + fallbackState = { boundary: localFallbackBoundary, activeFallback: null, pendingRecheck: false, isRenderingFallback: false, getContent: () => contentNodes, - getParentNode: () => - getSlotFallbackParentNode(currentParentNode, contentNodes), + getParentNode: () => currentParentNode, getAnchor: () => currentAnchor, isBusy: () => isResolvingContent, isDisposed: () => disposed, - syncEffectiveOutput: () => { - frag.nodes = getSlotEffectiveOutput(outlet) - frag.validityPending = false + isContentValid: () => isValidBlock(contentNodes), + syncNodes: () => { + frag.nodes = fallbackState.activeFallback || contentNodes + validityPending = false }, notifyFallbackValidityChange: () => { if (inheritedBoundary) { @@ -1927,16 +1934,18 @@ function renderVaporSlot( }, } const takePendingRecheck = (): boolean => { - const shouldRecheck = outlet.pendingRecheck - outlet.pendingRecheck = false + const shouldRecheck = fallbackState.pendingRecheck + fallbackState.pendingRecheck = false return shouldRecheck } const dispose = (parentNode?: ParentNode): void => { if (disposed) return - currentParentNode = parentNode || currentParentNode + if (parentNode) { + currentParentNode = parentNode + } disposed = true - disposeSlotFallback(outlet) + disposeSlotFallback(fallbackState) slotScope = undefined currentParentNode = null currentAnchor = null @@ -1957,20 +1966,17 @@ function renderVaporSlot( !!slotState.outletFallback.value && !!slotState.outletFallback.value.__vdom, ) - const preferSlotFragmentOwnership = + const hasInteropFallback = !!slotState.localFallback.value || !!slotState.outletFallback.value - outlet.pendingRecheck = false + fallbackState.pendingRecheck = false const finalizeResolvedContent = ( resolvedContent: Block | undefined, ): Block | undefined => { - if ( - preferSlotFragmentOwnership && - resolvedContent instanceof SlotFragment - ) { + if (hasInteropFallback && resolvedContent instanceof SlotFragment) { return resolvedContent } contentNodes = resolvedContent || [] - recheckSlotFallback(outlet, takePendingRecheck()) + recheckSlotFallback(fallbackState, takePendingRecheck()) return resolvedContent } let resolvedContent: Block | undefined @@ -2009,10 +2015,7 @@ function renderVaporSlot( onScopeDispose(() => dispose(), true) }) } - if ( - preferSlotFragmentOwnership && - resolvedContent instanceof SlotFragment - ) { + if (hasInteropFallback && resolvedContent instanceof SlotFragment) { ownedSlotFragment = resolvedContent trackInteropFallbackChanges(vnode.vs!.scope, slotState, () => markInteropFallbackDirty(), @@ -2021,34 +2024,31 @@ function renderVaporSlot( return resolvedContent } - outlet.pendingRecheck = false + fallbackState.pendingRecheck = false frag.insert = (parentNode, anchor) => { currentParentNode = parentNode currentAnchor = anchor - if (outlet.activeFallback) { - insertActiveSlotFallback(outlet) - mutateSlotFallbackCarrier(contentNodes, block => - insert(block, parentNode, anchor), - ) + if (fallbackState.activeFallback) { + insertActiveSlotFallback(fallbackState) } else { insert(frag.nodes, parentNode, anchor) } } frag.remove = parentNode => { - if (outlet.activeFallback) { - mutateSlotFallbackCarrier(contentNodes, block => - remove(block, parentNode), - ) - } else { + if (!fallbackState.activeFallback) { remove(frag.nodes, parentNode) } dispose(parentNode) } trackInteropFallbackChanges(vnode.vs!.scope, slotState, () => { - recheckSlotFallback(outlet, true) - syncActiveSlotFallback(outlet) + recheckSlotFallback(fallbackState, true) }) + if (isHydrating && currentHydrationNode) { + currentAnchor = currentHydrationNode + currentParentNode = currentAnchor.parentNode as ParentNode | null + } + return frag } catch (e) { dispose() @@ -2174,12 +2174,8 @@ function createVNodeChildrenFragment( currentParentSuspense || (parentComponent && parentComponent.suspense) const frag = new VaporFragment([]) let contentValid = false - frag.validityPending = !isHydrating - ;( - frag as VaporFragment & { - isBlockValid: () => boolean - } - ).isBlockValid = () => (frag.validityPending ? true : contentValid) + let validityPending = !isHydrating + frag.isBlockValid = () => (validityPending ? true : contentValid) let currentVNode: VNode | null = null let currentChildren: VNode[] = [] let currentParentNode: ParentNode | null = null @@ -2189,7 +2185,7 @@ function createVNodeChildrenFragment( const scope = effectScope() const syncResolvedNodes = (children: VNode[] = currentChildren): boolean => { - const prevValid = frag.validityPending ? true : contentValid + const prevValid = validityPending ? true : contentValid contentValid = !!ensureValidVNode(children) if (children.length === 0) { frag.nodes = [] @@ -2198,7 +2194,7 @@ function createVNodeChildrenFragment( } else { frag.nodes = children.map(resolveVNodeNodes) as Block[] } - frag.validityPending = false + validityPending = false return prevValid !== contentValid } @@ -2224,11 +2220,10 @@ function createVNodeChildrenFragment( currentVNode = createVNode(Fragment, null, nextChildren) currentParentNode = currentHydrationNode!.parentNode as ParentNode currentAnchor = currentHydrationNode - // Slot fallback hydration can preserve a local carrier anchor from an - // inner empty branch (for example `v-if` / `v-for`) immediately before - // the enclosing slot end anchor. Fragment patching needs the boundary - // insertion point after that local anchor; otherwise later fallback - // siblings patch in front of the carrier instead of after it. + // Slot fallback hydration can leave an inner empty-branch anchor + // immediately before the enclosing slot end anchor. Fragment + // patching needs the boundary insertion point after that local + // anchor; otherwise later fallback siblings patch in front of it. if ( frag.inheritedSlotBoundary && currentAnchor && @@ -2241,8 +2236,11 @@ function createVNodeChildrenFragment( } else if (!isMounted) { currentChildren = nextChildren currentVNode = createVNode(Fragment, null, nextChildren) - contentValid = !!ensureValidVNode(nextChildren) - frag.validityPending = false + const wasPending = validityPending + const validityChanged = syncResolvedNodes(nextChildren) + if (!wasPending) { + notifyUpdated(validityChanged) + } return } else if (!currentVNode) { currentChildren = nextChildren diff --git a/packages/shared/src/vaporFlags.ts b/packages/shared/src/vaporFlags.ts index d6601a5c37b..72d9882fef8 100644 --- a/packages/shared/src/vaporFlags.ts +++ b/packages/shared/src/vaporFlags.ts @@ -28,6 +28,11 @@ export enum VaporVForFlags { * fragment-specific insert/remove helpers. */ IS_FRAGMENT = 1 << 4, + /** + * v-for sits on a slot content/fallback root chain and can change slot + * validity. + */ + SLOT_ROOT = 1 << 5, } export enum VaporBlockShape { @@ -44,11 +49,12 @@ export enum VaporBlockShape { * - bit 4: v-once * - bit 5: true branch does not need EffectScope * - bit 6: false branch does not need EffectScope - * - bits 7+: branch index + 1 for keyed dynamic fragments + * - bit 7: v-if sits on a slot content/fallback root chain + * - bits 8+: branch index + 1 for keyed dynamic fragments * * Examples: * - v-once, true single-root, no false branch: 1 | ONCE = 17 - * - keyed index 0, true/false single-root: 1 | (1 << 2) | (1 << 7) = 133 + * - keyed index 0, true/false single-root: 1 | (1 << 2) | (1 << 8) = 261 */ export enum VaporIfFlags { /** @@ -70,11 +76,16 @@ export enum VaporIfFlags { * effects or disposers. */ FALSE_NO_SCOPE = 1 << 6, + /** + * v-if sits on a slot content/fallback root chain and can change slot + * validity. + */ + SLOT_ROOT = 1 << 7, /** * Shift for keyed branch index. The encoded value is index + 1, so decoded * zero means "not keyed" and source index 0 still round-trips. */ - INDEX_SHIFT = 7, + INDEX_SHIFT = 8, } /** @@ -93,4 +104,11 @@ export enum TemplateFlags { export enum VaporSlotFlags { NO_SLOTTED = 1, ONCE = 1 << 1, + SLOT_ROOT = 1 << 2, +} + +export enum VaporDynamicComponentFlags { + SINGLE_ROOT = 1, + ONCE = 1 << 1, + SLOT_ROOT = 1 << 2, } From 3ab2a887bad77008d46d3ee6f925b9ddffd7d137 Mon Sep 17 00:00:00 2001 From: daiwei Date: Mon, 1 Jun 2026 12:00:16 +0800 Subject: [PATCH 02/13] wip: fix unit tests --- .../runtime-vapor/__tests__/components/KeepAlive.spec.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/runtime-vapor/__tests__/components/KeepAlive.spec.ts b/packages/runtime-vapor/__tests__/components/KeepAlive.spec.ts index 5af0f2ede57..0a473035b84 100644 --- a/packages/runtime-vapor/__tests__/components/KeepAlive.spec.ts +++ b/packages/runtime-vapor/__tests__/components/KeepAlive.spec.ts @@ -14,7 +14,7 @@ import { vModelText, withDirectives, } from 'vue' -import { VaporBlockShape } from '@vue/shared' +import { VaporBlockShape, VaporVForFlags } from '@vue/shared' import type { LooseRawProps, VaporComponent } from '../../src/component' import { ifFlags, makeRender } from '../_utils' import { VaporKeepAlive } from '../../src/components/KeepAlive' @@ -432,6 +432,7 @@ describe('VaporKeepAlive', () => { return n0 }, item => item, + VaporVForFlags.SLOT_ROOT, ), }) @@ -473,9 +474,7 @@ describe('VaporKeepAlive', () => { toggle.value = true await nextTick() - expect(html()).toBe( - '
fallback
', - ) + expect(html()).toBe('
fallback
') itemsA.value = [3] await nextTick() From 0258daa15357a8bf37d80e42d962f9c060b4f3cb Mon Sep 17 00:00:00 2001 From: daiwei Date: Mon, 1 Jun 2026 13:59:17 +0800 Subject: [PATCH 03/13] fix(runtime-vapor): gate slot parent dirtying by slot root flag --- .../__tests__/componentSlots.spec.ts | 41 ++++++++++++++++++- packages/runtime-vapor/src/componentSlots.ts | 5 ++- packages/runtime-vapor/src/fragment.ts | 6 ++- 3 files changed, 47 insertions(+), 5 deletions(-) diff --git a/packages/runtime-vapor/__tests__/componentSlots.spec.ts b/packages/runtime-vapor/__tests__/componentSlots.spec.ts index 4028564af16..7c229f4d2fd 100644 --- a/packages/runtime-vapor/__tests__/componentSlots.spec.ts +++ b/packages/runtime-vapor/__tests__/componentSlots.spec.ts @@ -656,6 +656,45 @@ describe('component: slots', () => { expect(markDirty).toHaveBeenCalledTimes(1) }) + test('root vapor slot validity change dirties parent boundary', async () => { + const markDirty = vi.fn() + const show = ref(true) + const boundary: SlotBoundaryContext = { + parent: null, + getFallback: () => undefined, + run: fn => fn(), + markDirty, + } + const Child = defineVaporComponent(() => + withOwnedSlotBoundary(boundary, () => + createSlot('default', null, undefined, VaporSlotFlags.SLOT_ROOT), + ), + ) + + define(() => + createComponent(Child, null, { + $: [ + () => + show.value + ? { + name: 'default', + fn: withVaporCtx(() => document.createTextNode('content')), + } + : { + name: 'default', + fn: withVaporCtx(() => []), + }, + ], + }), + ).render() + markDirty.mockClear() + + show.value = false + await nextTick() + + expect(markDirty).toHaveBeenCalledTimes(1) + }) + test('slot fallback state ignores dirty notifications after dispose', () => { let disposed = false const state = createTestSlotFallbackState({ @@ -1038,7 +1077,7 @@ describe('component: slots', () => { default: () => [], }) const renderInnerFallback = () => { - const child = new SlotFragment() + const child = new SlotFragment(true) renderEffect(() => { child.updateSlot( showInnerFallback.value diff --git a/packages/runtime-vapor/src/componentSlots.ts b/packages/runtime-vapor/src/componentSlots.ts index 3af117735b4..3ec2ef1b527 100644 --- a/packages/runtime-vapor/src/componentSlots.ts +++ b/packages/runtime-vapor/src/componentSlots.ts @@ -222,6 +222,7 @@ export function createSlot( !(flags & VaporSlotFlags.NO_SLOTTED) && instance.type.__scopeId const slotScopeIds = scopeId ? [`${scopeId}-s`] : null const once = !!(flags & VaporSlotFlags.ONCE) + const slotRoot = !!(flags & VaporSlotFlags.SLOT_ROOT) const slotProps = rawProps ? new Proxy( once ? snapshotRawProps(rawProps as RawProps) : rawProps, @@ -243,11 +244,11 @@ export function createSlot( instance, fallback, once, - !!(flags & VaporSlotFlags.SLOT_ROOT), + slotRoot, ) } else { if (isHydrating) hydrationCursor = captureHydrationCursor() - const slotFragment = (fragment = new SlotFragment()) + const slotFragment = (fragment = new SlotFragment(slotRoot)) // mark the slot as forwarded slotFragment.forwarded = currentSlotOwner != null && currentSlotOwner !== currentInstance diff --git a/packages/runtime-vapor/src/fragment.ts b/packages/runtime-vapor/src/fragment.ts index daaaf301a0f..ff11f26d5a5 100644 --- a/packages/runtime-vapor/src/fragment.ts +++ b/packages/runtime-vapor/src/fragment.ts @@ -1159,9 +1159,11 @@ export class SlotFragment extends DynamicFragment implements SlotFallbackState { private localFallback?: BlockFn private isUpdatingSlot = false private _slotFallbackBoundary?: SlotBoundaryContext + private notifyParent: boolean - constructor() { + constructor(notifyParent: boolean = false) { super(isHydrating || __DEV__ ? 'slot' : undefined, false, false, false) + this.notifyParent = notifyParent if (!isHydrating) { this.insert = (parent, anchor) => this.insertSlot(parent, anchor) } @@ -1313,7 +1315,7 @@ export class SlotFragment extends DynamicFragment implements SlotFallbackState { } notifyFallbackValidityChange(): void { - if (this.parentSlotBoundary) { + if (this.notifyParent && this.parentSlotBoundary) { this.parentSlotBoundary.markDirty() } } From 692427bef76621ed8e12fa256b8164da1fd219b2 Mon Sep 17 00:00:00 2001 From: daiwei Date: Mon, 1 Jun 2026 14:38:03 +0800 Subject: [PATCH 04/13] perf(runtime-vapor): use dynamic fragments for plain slots --- .../__tests__/componentSlots.spec.ts | 27 ++++++++- packages/runtime-vapor/src/component.ts | 2 +- packages/runtime-vapor/src/componentSlots.ts | 58 ++++++++++++++----- .../src/components/Transition.ts | 6 +- .../src/components/TransitionGroup.ts | 6 +- packages/runtime-vapor/src/fragment.ts | 33 ++++------- 6 files changed, 88 insertions(+), 44 deletions(-) diff --git a/packages/runtime-vapor/__tests__/componentSlots.spec.ts b/packages/runtime-vapor/__tests__/componentSlots.spec.ts index 7c229f4d2fd..d07b749e93a 100644 --- a/packages/runtime-vapor/__tests__/componentSlots.spec.ts +++ b/packages/runtime-vapor/__tests__/componentSlots.spec.ts @@ -1191,9 +1191,17 @@ describe('component: slots', () => { ) }) - test('plain slot without fallback does not enter fallback boundary', () => { + test('plain slot without fallback uses dynamic fragment', () => { + let slotBlock!: Block let observedBoundary: SlotBoundaryContext | null | undefined - const Comp = defineVaporComponent(() => createSlot()) + const Comp = defineVaporComponent(() => { + return (slotBlock = createSlot( + 'default', + null, + undefined, + VaporSlotFlags.SLOT_ROOT, + )) + }) define(() => createComponent(Comp, null, { @@ -1204,9 +1212,24 @@ describe('component: slots', () => { }), ).render() + expect(slotBlock).toBeInstanceOf(DynamicFragment) + expect(slotBlock).not.toBeInstanceOf(SlotFragment) expect(observedBoundary).toBe(null) }) + test('slot with fallback uses slot fragment', () => { + let slotBlock!: Block + const Comp = defineVaporComponent(() => { + return (slotBlock = createSlot('default', null, () => + template('fallback')(), + )) + }) + + define(() => createComponent(Comp, null, {})).render() + + expect(slotBlock).toBeInstanceOf(SlotFragment) + }) + test('slot props should be isolated per fragment in v-for', async () => { const items = ref([0, 1, 2]) diff --git a/packages/runtime-vapor/src/component.ts b/packages/runtime-vapor/src/component.ts index 21c94aeae42..2499373525c 100644 --- a/packages/runtime-vapor/src/component.ts +++ b/packages/runtime-vapor/src/component.ts @@ -1344,7 +1344,7 @@ function registerDynamicFragmentFallthroughAttrs( __DEV__ && // preventing attrs fallthrough on slots // consistent with VDOM slots behavior - (frag.anchorLabel === 'slot' || (isArray(nodes) && nodes.length)) + (frag.isSlot || (isArray(nodes) && nodes.length)) ) { warnExtraneousAttributes(attrs) } diff --git a/packages/runtime-vapor/src/componentSlots.ts b/packages/runtime-vapor/src/componentSlots.ts index 3ec2ef1b527..1c0e2e0bea2 100644 --- a/packages/runtime-vapor/src/componentSlots.ts +++ b/packages/runtime-vapor/src/componentSlots.ts @@ -34,8 +34,10 @@ import { isHydrating, } from './dom/hydration' import { + DynamicFragment, SlotFragment, type VaporFragment, + getCurrentSlotBoundary, withOwnedSlotBoundary, } from './fragment' import { createElement } from './dom/node' @@ -248,10 +250,37 @@ export function createSlot( ) } else { if (isHydrating) hydrationCursor = captureHydrationCursor() - const slotFragment = (fragment = new SlotFragment(slotRoot)) - // mark the slot as forwarded - slotFragment.forwarded = - currentSlotOwner != null && currentSlotOwner !== currentInstance + const isCustomElementSlot = !!( + (instance as GenericComponentInstance).ce || + (instance.parent && isAsyncWrapper(instance.parent) && instance.parent.ce) + ) + const needsSlotFragment = + isHydrating || + !!fallback || + !!getCurrentSlotBoundary() || + isCustomElementSlot + const slotFragment = needsSlotFragment + ? new SlotFragment(slotRoot) + : undefined + let dynamicFragment: DynamicFragment | undefined + if (slotFragment) { + fragment = slotFragment + if (isHydrating) { + // Hydration uses forwarded slots to decide close marker ownership. + slotFragment.forwarded = + currentSlotOwner != null && currentSlotOwner !== currentInstance + } + } else { + // Fast path: plain slots without fallback/boundary semantics only need a + // DynamicFragment. SlotFragment is reserved for fallback owners. + dynamicFragment = new DynamicFragment( + __DEV__ ? 'slot' : undefined, + false, + false, + ) + dynamicFragment.isSlot = true + fragment = dynamicFragment + } const isDynamicName = isFunction(name) const renderSlot = () => { @@ -260,12 +289,7 @@ export function createSlot( // in custom element mode, render as actual slot outlets // because in shadowRoot: false mode the slot element gets // replaced by injected content - if ( - (instance as GenericComponentInstance).ce || - (instance.parent && - isAsyncWrapper(instance.parent) && - instance.parent.ce) - ) { + if (isCustomElementSlot) { const el = createElement('slot') const setSlotProps = () => { setDynamicProps(el, [ @@ -278,12 +302,12 @@ export function createSlot( if (once) setSlotProps() else renderEffect(setSlotProps) if (fallback) { - withOwnedSlotBoundary(slotFragment.parentSlotBoundary, () => { + withOwnedSlotBoundary(slotFragment!.parentSlotBoundary, () => { const fallbackBlock = fallback() // Keep the live fallback block on the SlotFragment itself. The // native slot outlet is temporary and gets removed by CE slot // replacement, but the fragment remains Vapor's long-lived owner. - slotFragment.customElementFallback = fallbackBlock + slotFragment!.customElementFallback = fallbackBlock insert(fallbackBlock, el) }) } @@ -293,9 +317,15 @@ export function createSlot( const slot = getSlot(rawSlots, slotName) if (slot) { - slotFragment.updateSlot(getBoundSlot(slot), fallback) - } else { + if (slotFragment) { + slotFragment.updateSlot(getBoundSlot(slot), fallback) + } else { + dynamicFragment!.update(getBoundSlot(slot)) + } + } else if (slotFragment) { slotFragment.updateSlot(undefined, fallback) + } else { + dynamicFragment!.update() } } diff --git a/packages/runtime-vapor/src/components/Transition.ts b/packages/runtime-vapor/src/components/Transition.ts index d6f257a977e..22088318660 100644 --- a/packages/runtime-vapor/src/components/Transition.ts +++ b/packages/runtime-vapor/src/components/Transition.ts @@ -40,9 +40,9 @@ import { renderEffect } from '../renderEffect' import { DynamicFragment, ForFragment, - SlotFragment, type VaporFragment, isFragment, + isSlotFragment, } from '../fragment' import { currentHydrationNode, @@ -333,8 +333,8 @@ function applyResolvedTransitionHooks( if ( hooks.applyGroup && (block instanceof ForFragment || - block instanceof SlotFragment || - (isVaporComponent(block) && block.block instanceof SlotFragment)) + isSlotFragment(block) || + (isVaporComponent(block) && isSlotFragment(block.block))) ) { hooks.applyGroup(block, hooks.props, hooks.state, hooks.instance) return { hooks } diff --git a/packages/runtime-vapor/src/components/TransitionGroup.ts b/packages/runtime-vapor/src/components/TransitionGroup.ts index 92d6a8f8503..8acb0edbdda 100644 --- a/packages/runtime-vapor/src/components/TransitionGroup.ts +++ b/packages/runtime-vapor/src/components/TransitionGroup.ts @@ -44,9 +44,9 @@ import { isForBlock, setForHydrationAnchorResolver } from '../apiCreateFor' import { createComment, createElement, createTextNode } from '../dom/node' import { DynamicFragment, - SlotFragment, type VaporFragment, isFragment, + isSlotFragment, } from '../fragment' import { type DefineVaporComponent, @@ -410,8 +410,8 @@ function getTransitionBlocks( // A normal component child can move when parent-driven props update its // root layout without re-running the surrounding v-for fragment. // When the component root is a slot, the TransitionGroup children are the - // slotted blocks, so track the SlotFragment instead of the component. - const isRootSlot = block.block instanceof SlotFragment + // slotted blocks, so track the slot fragment instead of the component. + const isRootSlot = block.block && isSlotFragment(block.block) if (onUpdateOwner && !isRootSlot) onUpdateOwner(block) const blocks = getTransitionBlocks( block.block, diff --git a/packages/runtime-vapor/src/fragment.ts b/packages/runtime-vapor/src/fragment.ts index ff11f26d5a5..1d8e4fa49b5 100644 --- a/packages/runtime-vapor/src/fragment.ts +++ b/packages/runtime-vapor/src/fragment.ts @@ -254,6 +254,7 @@ export class DynamicFragment extends VaporFragment { pending?: { render?: BlockFn; key: any; noScope: boolean } anchorLabel?: string keyed?: boolean + isSlot?: boolean inTransition?: boolean // Fallthrough attrs hooks register branch-owned effects on insert. hasFallthroughAttrs?: true @@ -292,7 +293,7 @@ export class DynamicFragment extends VaporFragment { if (key === this.current) { // On initial hydration, `key === current` means `render` is empty, // so this fragment hydrates as empty content. - if (isHydrating && this.anchorLabel !== 'slot') this.hydrate(true) + if (isHydrating && !this.isSlot) this.hydrate(true) return } @@ -384,7 +385,7 @@ export class DynamicFragment extends VaporFragment { const isRevivingDeferredBranch = isInDeferredHydrationBoundary() && !!render && - this.anchorLabel !== 'slot' && + !this.isSlot && !isValidBlock(this.nodes) reusingDeferredAnchor = @@ -418,7 +419,7 @@ export class DynamicFragment extends VaporFragment { ) setActiveSub(prevSub) - if (isHydrating && this.anchorLabel !== 'slot' && !reusingDeferredAnchor) { + if (isHydrating && !this.isSlot && !reusingDeferredAnchor) { this.hydrate(render == null) } } @@ -1144,6 +1145,7 @@ function isReusableDynamicFragmentAnchor( } export class SlotFragment extends DynamicFragment implements SlotFallbackState { + isSlot = true private disposed = false forwarded = false parentSlotBoundary: SlotBoundaryContext | null = getCurrentSlotBoundary() @@ -1229,30 +1231,15 @@ export class SlotFragment extends DynamicFragment implements SlotFallbackState { ): void { const prevLocalFallback = this.localFallback this.localFallback = fallback - const fallbackChanged = prevLocalFallback !== fallback - const fastSlotKey = key === undefined ? render : key - - if ( - !isHydrating && - !fallback && - !this.parentSlotBoundary && - !this._slotFallbackBoundary - ) { - this.update(render, fastSlotKey) - this.content = this.nodes - return - } - const boundary = this.slotFallbackBoundary const slotRender = render ? () => withOwnedSlotBoundary(boundary, render) : () => [] - const slotKey = key === undefined ? slotRender : key this.isUpdatingSlot = true this.pendingRecheck = false try { - const shouldForce = fallbackChanged + const shouldForce = prevLocalFallback !== fallback if (isHydrating) { withHydratingSlotBoundary(() => { const prev = isHydratingSlotFallbackActive() @@ -1260,7 +1247,7 @@ export class SlotFragment extends DynamicFragment implements SlotFallbackState { if (hasSlotFallback(boundary)) { setCurrentHydratingSlotFallbackActive(true) } - this.updateContent(slotRender, slotKey) + this.updateContent(slotRender, key) const contentValid = isValidBlock(this.content) recheckSlotFallback(this, shouldForce) // Updates run under the temporary fallback-active marker so empty @@ -1277,7 +1264,7 @@ export class SlotFragment extends DynamicFragment implements SlotFallbackState { } }) } else { - this.updateContent(slotRender, slotKey) + this.updateContent(slotRender, key) recheckSlotFallback(this, shouldForce) } } finally { @@ -1330,3 +1317,7 @@ export function isDynamicFragment( ): val is DynamicFragment { return val instanceof DynamicFragment } + +export function isSlotFragment(val: unknown): val is DynamicFragment { + return val instanceof DynamicFragment && !!val.isSlot +} From d0517a97be088f1d842e364b39e45c66ddc0cdf1 Mon Sep 17 00:00:00 2001 From: daiwei Date: Mon, 1 Jun 2026 14:43:19 +0800 Subject: [PATCH 05/13] test(runtime-vapor): cover vdom slot content after active fallback --- .../__tests__/vdomInterop.spec.ts | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/packages/runtime-vapor/__tests__/vdomInterop.spec.ts b/packages/runtime-vapor/__tests__/vdomInterop.spec.ts index d7bfde2bde0..d58c14d699f 100644 --- a/packages/runtime-vapor/__tests__/vdomInterop.spec.ts +++ b/packages/runtime-vapor/__tests__/vdomInterop.spec.ts @@ -225,6 +225,77 @@ describe('vdomInterop', () => { ) expect(onUpdated).toHaveBeenCalled() }) + + test('mounts vnode slot content after active fallback without reusing invalid vnode content', async () => { + const show = ref(false) + const childRef = ref(null) + const mounted = vi.fn() + const unmounted = vi.fn() + + const VDomChild = defineComponent({ + setup() { + onMounted(mounted) + onUnmounted(unmounted) + return () => h('div', 'child') + }, + }) + + const VaporChild = defineVaporComponent({ + setup() { + return createSlot('default', null, () => + template('fallback')(), + ) as any + }, + }) + + const Parent = defineComponent({ + setup() { + return () => + h(VaporChild as any, null, { + default: () => + show.value ? [h(VDomChild, { ref: childRef })] : [], + }) + }, + }) + + const app = createApp(Parent) + app.use(vaporInteropPlugin) + const root = document.createElement('div') + app.mount(root) + + expect(root.innerHTML).toBe('fallback') + expect(childRef.value).toBe(null) + expect(mounted).not.toHaveBeenCalled() + expect(unmounted).not.toHaveBeenCalled() + + show.value = true + await nextTick() + + expect(root.innerHTML).toBe('
child
') + expect(childRef.value).not.toBe(null) + expect(mounted).toHaveBeenCalledTimes(1) + expect(unmounted).not.toHaveBeenCalled() + + show.value = false + await nextTick() + + expect(root.innerHTML).toBe('fallback') + expect(childRef.value).toBe(null) + expect(mounted).toHaveBeenCalledTimes(1) + expect(unmounted).toHaveBeenCalledTimes(1) + + show.value = true + await nextTick() + + expect(root.innerHTML).toBe('
child
') + expect(childRef.value).not.toBe(null) + expect(mounted).toHaveBeenCalledTimes(2) + expect(unmounted).toHaveBeenCalledTimes(1) + + app.unmount() + expect(childRef.value).toBe(null) + expect(unmounted).toHaveBeenCalledTimes(2) + }) }) describe('props', () => { From c147741ac891f8beea89d3c4e98ee3c767dc601d Mon Sep 17 00:00:00 2001 From: daiwei Date: Mon, 1 Jun 2026 15:49:15 +0800 Subject: [PATCH 06/13] fix(runtime-vapor): only dirty slot fallback boundaries on validity changes --- .../__tests__/componentSlots.spec.ts | 31 ++++++++-- packages/runtime-vapor/src/fragment.ts | 62 +++++++++++++------ packages/runtime-vapor/src/vdomInterop.ts | 46 ++++++++++---- 3 files changed, 103 insertions(+), 36 deletions(-) diff --git a/packages/runtime-vapor/__tests__/componentSlots.spec.ts b/packages/runtime-vapor/__tests__/componentSlots.spec.ts index d07b749e93a..48a5d18bdc3 100644 --- a/packages/runtime-vapor/__tests__/componentSlots.spec.ts +++ b/packages/runtime-vapor/__tests__/componentSlots.spec.ts @@ -627,6 +627,11 @@ describe('component: slots', () => { await nextTick() expect(markDirty).toHaveBeenCalledTimes(1) + + show.value = true + await nextTick() + + expect(markDirty).toHaveBeenCalledTimes(2) }) test('slot boundary dirtying tracks root v-for updates', async () => { @@ -654,6 +659,11 @@ describe('component: slots', () => { await nextTick() expect(markDirty).toHaveBeenCalledTimes(1) + + items.value = [1] + await nextTick() + + expect(markDirty).toHaveBeenCalledTimes(2) }) test('root vapor slot validity change dirties parent boundary', async () => { @@ -693,6 +703,11 @@ describe('component: slots', () => { await nextTick() expect(markDirty).toHaveBeenCalledTimes(1) + + show.value = true + await nextTick() + + expect(markDirty).toHaveBeenCalledTimes(2) }) test('slot fallback state ignores dirty notifications after dispose', () => { @@ -917,7 +932,7 @@ describe('component: slots', () => { expect(state.activeFallback).toBe(fallback) }) - test('vdom slot dirties parent boundary once when content stays valid', async () => { + test('vdom slot does not dirty parent boundary when content stays valid', async () => { const text = ref('A') const boundary = { parent: null, @@ -952,7 +967,7 @@ describe('component: slots', () => { await nextTick() expect(host.innerHTML).toContain('
B
') - expect(boundary.markDirty).toHaveBeenCalledTimes(1) + expect(boundary.markDirty).not.toHaveBeenCalled() }) test('vdom slot ignores non-root updates inside slot boundary', async () => { @@ -985,7 +1000,7 @@ describe('component: slots', () => { expect(boundary.markDirty).not.toHaveBeenCalled() }) - test('vdom slot dirties parent boundary once when switching from valid content to local fallback', async () => { + test('vdom slot does not dirty parent boundary when local fallback keeps output valid', async () => { const show = ref(true) const boundary = { parent: null, @@ -1020,10 +1035,10 @@ describe('component: slots', () => { await nextTick() expect(host.innerHTML).toBe('fallback') - expect(boundary.markDirty).toHaveBeenCalledTimes(1) + expect(boundary.markDirty).not.toHaveBeenCalled() }) - test('vdom slot dirties parent boundary once when valid content becomes empty', async () => { + test('vdom slot dirties parent boundary when content validity changes', async () => { const show = ref(true) const boundary = { parent: null, @@ -1059,6 +1074,12 @@ describe('component: slots', () => { expect(host.innerHTML).toBe('') expect(boundary.markDirty).toHaveBeenCalledTimes(1) + + show.value = true + await nextTick() + + expect(host.innerHTML).toContain('
content
') + expect(boundary.markDirty).toHaveBeenCalledTimes(2) }) test('vdom slot dirties parent boundary when nested fallback validity flips', async () => { diff --git a/packages/runtime-vapor/src/fragment.ts b/packages/runtime-vapor/src/fragment.ts index 1d8e4fa49b5..74afe6488cc 100644 --- a/packages/runtime-vapor/src/fragment.ts +++ b/packages/runtime-vapor/src/fragment.ts @@ -298,6 +298,15 @@ export class DynamicFragment extends VaporFragment { } const transition = isTransitionEnabled ? this.$transition : undefined + const wasMounted = this.current !== undefined + if (wasMounted) { + const onBeforeUpdate = this.onBeforeUpdate + if (onBeforeUpdate) { + for (let i = 0; i < onBeforeUpdate.length; i++) { + onBeforeUpdate[i]() + } + } + } // currently leaving: defer mounting the next branch until // the leave finishes. if (transition && transition.state.isLeaving) { @@ -319,7 +328,6 @@ export class DynamicFragment extends VaporFragment { const instance = currentInstance const prevSub = setActiveSub() const parent = !isHydrating && shouldInsert ? this.anchor.parentNode : null - const wasMounted = this.current !== undefined // teardown previous branch if (wasMounted) { const scope = this.scope @@ -788,11 +796,21 @@ function getRedirectedBoundary( } // Dynamic children (`v-if`, `v-for`, interop fragments) created under a slot -// boundary dirty the boundary on later updates. +// boundary dirty the boundary only when their rendered validity changes. export function trackSlotBoundaryDirtying(fragment: VaporFragment): void { const boundary = currentSlotBoundary if (!boundary) return - ;(fragment.onUpdated ||= []).push(() => boundary.markDirty()) + let prevValid = isValidBlock(fragment) + ;(fragment.onBeforeUpdate ||= []).push(() => { + prevValid = isValidBlock(fragment) + }) + ;(fragment.onUpdated ||= []).push(() => { + const valid = isValidBlock(fragment) + if (valid !== prevValid) { + boundary.markDirty() + } + prevValid = valid + }) } export function hasSlotFallback( @@ -971,6 +989,21 @@ function commitSlotFallback( insertActiveSlotFallback(state) } +function renderAndCommitSlotFallback( + state: SlotFallbackState, + hadFallback: boolean, +): void { + const result = renderSlotFallbackState(state) + clearSlotFallback(state) + if (result) { + commitSlotFallback(state, result.block, result.scope, !hadFallback) + if (state.pendingRecheck) { + state.pendingRecheck = false + recheckSlotFallback(state, true) + } + } +} + export function disposeSlotFallback(state: SlotFallbackState): void { clearSlotFallback(state) state.pendingRecheck = false @@ -1011,9 +1044,8 @@ export function recheckSlotFallback( insert(content, parentNode, state.getAnchor()) } } - } else { + } else if (fallback) { if ( - fallback && prevNodesValid && !fallbackValid && !hasSlotFallback(state.boundary.parent) @@ -1022,22 +1054,15 @@ export function recheckSlotFallback( if (parentNode) { detachBlock(fallback, parentNode) } - } else if (fallback && !prevNodesValid && fallbackValid) { + } else if (!prevNodesValid && fallbackValid) { insertActiveSlotFallback(state) - } else if (force || !fallback) { - const hadFallback = !!fallback - const result = renderSlotFallbackState(state) - clearSlotFallback(state) - if (result) { - commitSlotFallback(state, result.block, result.scope, !hadFallback) - if (state.pendingRecheck) { - state.pendingRecheck = false - recheckSlotFallback(state, true) - } - } + } else if (force) { + renderAndCommitSlotFallback(state, true) } else { insertActiveSlotFallback(state) } + } else { + renderAndCommitSlotFallback(state, false) } const nextFallback = state.activeFallback @@ -1221,7 +1246,6 @@ export class SlotFragment extends DynamicFragment implements SlotFallbackState { // can return to the DOM. this.update(render, key, false, !this.activeFallback) this.content = this.nodes - this.nodes = this.activeFallback || this.content } updateSlot( @@ -1234,7 +1258,7 @@ export class SlotFragment extends DynamicFragment implements SlotFallbackState { const boundary = this.slotFallbackBoundary const slotRender = render ? () => withOwnedSlotBoundary(boundary, render) - : () => [] + : () => EMPTY_BLOCK this.isUpdatingSlot = true this.pendingRecheck = false diff --git a/packages/runtime-vapor/src/vdomInterop.ts b/packages/runtime-vapor/src/vdomInterop.ts index a2942d74d2f..f4bf7f230d4 100644 --- a/packages/runtime-vapor/src/vdomInterop.ts +++ b/packages/runtime-vapor/src/vdomInterop.ts @@ -1235,13 +1235,6 @@ function ensureRendererBridge( return bridge } -function trackSlotVNodeUpdates(frag: VaporFragment, vnode: VNode): void { - trackSlotVNodeUpdatesWithRefresh(vnode, () => { - frag.nodes = resolveVNodeNodes(vnode) - if (frag.onUpdated) frag.onUpdated.forEach(m => m()) - }) -} - function hasValidVNodeContent(vnode: VNode): boolean { return !!ensureValidVNode( vnode.type === Fragment && isArray(vnode.children) @@ -1350,10 +1343,12 @@ function hydrateForwardedEmptySlotFragment( function trackSlotVNodeUpdatesWithRefresh( vnode: VNode, refresh: () => void, + beforeUpdate?: () => void, ): void { const onUpdated = () => refresh() const track = (node: VNode) => { + if (beforeUpdate) appendVnodeBeforeUpdateHook(node, beforeUpdate) appendVnodeUpdatedHook(node, onUpdated) if (node.type === Fragment && isArray(node.children)) { node.children.forEach(child => { @@ -1380,9 +1375,7 @@ function renderVDOMSlot( ): VaporFragment { const suspense = currentParentSuspense || parentComponent.suspense const frag = new VaporFragment([]) - if (slotRoot) trackSlotBoundaryDirtying(frag) let validityPending = !isHydrating - frag.isBlockValid = () => (validityPending ? true : isValidBlock(frag.nodes)) const instance = currentInstance let isMounted = false @@ -1399,6 +1392,12 @@ function renderVDOMSlot( let isContentUpdateRecheck = false let localFallback: BlockFn | undefined let fallbackState!: SlotFallbackState + frag.isBlockValid = () => { + if (validityPending) return true + return fallbackState.activeFallback + ? isValidBlock(fallbackState.activeFallback) + : contentState.valid + } const boundary: SlotBoundaryContext = { get parent() { return inheritedBoundary @@ -1427,6 +1426,7 @@ function renderVDOMSlot( } }, } + if (slotRoot) trackSlotBoundaryDirtying(frag) localFallback = fallback ? once ? () => withOnceSlot(() => fallback(internals, parentComponent)) @@ -1452,6 +1452,14 @@ function renderVDOMSlot( if (isMounted && frag.onUpdated) frag.onUpdated.forEach(m => m()) } + const notifyBeforeUpdate = (): void => { + if (isMounted && frag.onBeforeUpdate) { + for (let i = 0; i < frag.onBeforeUpdate.length; i++) { + frag.onBeforeUpdate[i]() + } + } + } + const recheckAfterContentUpdate = (forceFallbackRecheck = false): void => { isContentUpdateRecheck = true try { @@ -1521,6 +1529,7 @@ function renderVDOMSlot( simpleSetCurrentInstance(instance) try { const renderSlotContent = () => { + notifyBeforeUpdate() runWithFragmentRenderCtx(frag, () => withOwnedSlotBoundary(boundary, () => { let slotContent: VNode | Block | undefined @@ -1566,7 +1575,15 @@ function renderVDOMSlot( if (isVNode(hydratedContent)) { frag.vnode = hydratedContent frag.$key = getVNodeKey(hydratedContent) - trackSlotVNodeUpdates(frag, hydratedContent) + const refreshSlotVNode = () => { + frag.nodes = resolveVNodeNodes(hydratedContent) + if (frag.onUpdated) frag.onUpdated.forEach(m => m()) + } + trackSlotVNodeUpdatesWithRefresh( + hydratedContent, + refreshSlotVNode, + slotRoot ? notifyBeforeUpdate : undefined, + ) // Forwarded slot fragments that resolve to an empty SSR range // should stay on that range instead of re-entering it through // generic Fragment hydration. @@ -1595,7 +1612,7 @@ function renderVDOMSlot( if (isVNode(slotContent)) { frag.vnode = slotContent frag.$key = getVNodeKey(slotContent) - trackSlotVNodeUpdatesWithRefresh(slotContent, () => { + const refreshSlotVNode = () => { const prevValid = contentState.valid const prevOutput = frag.nodes setRenderedContent(slotContent) @@ -1606,7 +1623,12 @@ function renderVDOMSlot( ) { notifyUpdated() } - }) + } + trackSlotVNodeUpdatesWithRefresh( + slotContent, + refreshSlotVNode, + slotRoot ? notifyBeforeUpdate : undefined, + ) const prevRendered = contentState.rendered const prevIsVNode = isVNode(prevRendered) const prevVNode = From c36bfb5f4125a50ec7f561b851763cbcfe0ab02a Mon Sep 17 00:00:00 2001 From: daiwei Date: Mon, 1 Jun 2026 16:02:12 +0800 Subject: [PATCH 07/13] chore: tweaks --- packages/runtime-vapor/src/fragment.ts | 12 +++--- packages/runtime-vapor/src/vdomInterop.ts | 46 +++++++++++++---------- 2 files changed, 32 insertions(+), 26 deletions(-) diff --git a/packages/runtime-vapor/src/fragment.ts b/packages/runtime-vapor/src/fragment.ts index 74afe6488cc..0a98214871a 100644 --- a/packages/runtime-vapor/src/fragment.ts +++ b/packages/runtime-vapor/src/fragment.ts @@ -515,7 +515,7 @@ export class DynamicFragment extends VaporFragment { } // Keep this as a prototype method to avoid per-instance closure allocation. - hydrate(isEmpty = false, isSlot = false): void { + hydrate(isEmpty = false): void { // early return allows tree-shaking of hydration logic when not used if (!isHydrating) return @@ -576,7 +576,7 @@ export class DynamicFragment extends VaporFragment { } } if ( - !isSlot && + !this.isSlot && this.anchorLabel && currentHydrationNode && !isHydratingSlotFallbackActive() && @@ -657,17 +657,17 @@ export class DynamicFragment extends VaporFragment { } const currentSlotEndAnchor = getCurrentSlotEndAnchor() - const forwardedSlot = isSlot + const forwardedSlot = this.isSlot ? (this as any as SlotFragment).forwarded : false - const slotAnchor = isSlot ? currentSlotEndAnchor : null + const slotAnchor = this.isSlot ? currentSlotEndAnchor : null // Reuse SSR `` as anchor. // SSR wraps slots and multi-root `v-if` branches with `...`. // Non-forwarded slots always own the closing ``, even when empty. // Forwarded slots only own it when they rendered valid content. const closeOwner = getDynamicCloseOwner( - isSlot, + !!this.isSlot, forwardedSlot, this.anchorLabel, this.nodes, @@ -1282,7 +1282,7 @@ export class SlotFragment extends DynamicFragment implements SlotFallbackState { if (!hasSlotFallback(boundary) || contentValid) { setCurrentHydratingSlotFallbackActive(prev) } - this.hydrate(!isValidBlock(this.nodes), true) + this.hydrate(!isValidBlock(this.nodes)) } finally { setCurrentHydratingSlotFallbackActive(prev) } diff --git a/packages/runtime-vapor/src/vdomInterop.ts b/packages/runtime-vapor/src/vdomInterop.ts index f4bf7f230d4..3c46a6f70a1 100644 --- a/packages/runtime-vapor/src/vdomInterop.ts +++ b/packages/runtime-vapor/src/vdomInterop.ts @@ -74,6 +74,7 @@ import { remove, } from './block' import { + EMPTY_ARR, EMPTY_OBJ, NOOP, ShapeFlags, @@ -159,6 +160,9 @@ import { setParentSuspense, } from './suspense' +const EMPTY_BLOCK = EMPTY_ARR as unknown as Block[] +const EMPTY_VNODES = EMPTY_ARR as unknown as VNode[] + function filterReservedProps(props: VNode['props']): VNode['props'] { const filtered: VNode['props'] = {} for (const key in props) { @@ -860,7 +864,7 @@ function mountVNode( ): VaporFragment { const suspense = currentParentSuspense || (parentComponent && parentComponent.suspense) - const frag = new VaporFragment([]) + const frag = new VaporFragment(EMPTY_BLOCK) frag.vnode = vnode frag.$key = vnode.key let validityPending = !isHydrating @@ -972,7 +976,7 @@ function createVDOMComponent( currentParentSuspense || (parentComponent && parentComponent.suspense) const useBridge = shouldUseRendererBridge(component) const comp = useBridge ? ensureRendererBridge(component) : component - const frag = new VaporFragment([]) + const frag = new VaporFragment(EMPTY_BLOCK) const vnode = (frag.vnode = createVNode( comp, rawProps && extend({}, new Proxy(rawProps, rawPropsProxyHandlers)), @@ -1374,13 +1378,13 @@ function renderVDOMSlot( slotRoot?: boolean, ): VaporFragment { const suspense = currentParentSuspense || parentComponent.suspense - const frag = new VaporFragment([]) + const frag = new VaporFragment(EMPTY_BLOCK) let validityPending = !isHydrating const instance = currentInstance let isMounted = false const contentState = { - nodes: [] as Block, + nodes: EMPTY_BLOCK as Block, valid: false, rendered: null as VNode | Block | null, } @@ -1421,7 +1425,7 @@ function renderVDOMSlot( frag.nodes = fallbackState.activeFallback || contentState.nodes }, notifyFallbackValidityChange: () => { - if (!isContentUpdateRecheck && inheritedBoundary) { + if (slotRoot && !isContentUpdateRecheck && inheritedBoundary) { inheritedBoundary.markDirty() } }, @@ -1442,21 +1446,21 @@ function renderVDOMSlot( contentState.nodes = rendered contentState.valid = isValidBlock(rendered) } else { - contentState.nodes = [] + contentState.nodes = EMPTY_BLOCK contentState.valid = false } validityPending = false } const notifyUpdated = (): void => { - if (isMounted && frag.onUpdated) frag.onUpdated.forEach(m => m()) + if (isMounted && frag.onUpdated) { + frag.onUpdated.forEach(u => u()) + } } const notifyBeforeUpdate = (): void => { if (isMounted && frag.onBeforeUpdate) { - for (let i = 0; i < frag.onBeforeUpdate.length; i++) { - frag.onBeforeUpdate[i]() - } + frag.onBeforeUpdate.forEach(bu => bu()) } } @@ -1765,7 +1769,9 @@ function createFallback( internals, () => { const children = fallback() - return children == null ? [] : normalizeInteropSlotValue(children) + return children == null + ? EMPTY_VNODES + : normalizeInteropSlotValue(children) }, parentComponent, ) @@ -1778,7 +1784,7 @@ function createFallback( } } -const renderEmptyVNodes = (): VNodeArrayChildren => [] +const renderEmptyVNodes = (): VNodeArrayChildren => EMPTY_VNODES // Interop slot rendering only needs to restore slot-owner / keep-alive / // boundary context here. Reusing VaporFragment.runWithRenderCtx() also @@ -1878,19 +1884,19 @@ function renderVaporSlot( } try { if (!vnode.vs || !vnode.vs.slot) { - return [] + return EMPTY_BLOCK } const slotState = resolveInteropVaporSlotState(vnode) // Most of the interop setup is shared, but slots that start with a local // VDOM fallback still need to let an inner SlotFragment own the active // fallback lifecycle. Forcing the interop wrapper to own that branch breaks // fallback blocks that can later resolve to an empty vnode list. - const frag = new VaporFragment([]) + const frag = new VaporFragment(EMPTY_BLOCK) let validityPending = !isHydrating frag.isBlockValid = () => validityPending ? true : isValidBlock(frag.nodes) const inheritedBoundary = frag.inheritedSlotBoundary - let contentNodes: Block = [] + let contentNodes: Block = EMPTY_BLOCK let isResolvingContent = false let localFallback!: BlockFn let outletFallback!: BlockFn @@ -1997,7 +2003,7 @@ function renderVaporSlot( if (hasInteropFallback && resolvedContent instanceof SlotFragment) { return resolvedContent } - contentNodes = resolvedContent || [] + contentNodes = resolvedContent || EMPTY_BLOCK recheckSlotFallback(fallbackState, takePendingRecheck()) return resolvedContent } @@ -2194,12 +2200,12 @@ function createVNodeChildrenFragment( ): VaporFragment { const suspense = currentParentSuspense || (parentComponent && parentComponent.suspense) - const frag = new VaporFragment([]) + const frag = new VaporFragment(EMPTY_BLOCK) let contentValid = false let validityPending = !isHydrating frag.isBlockValid = () => (validityPending ? true : contentValid) let currentVNode: VNode | null = null - let currentChildren: VNode[] = [] + let currentChildren: VNode[] = EMPTY_VNODES let currentParentNode: ParentNode | null = null let currentAnchor: Node | null = null let isMounted = false @@ -2210,7 +2216,7 @@ function createVNodeChildrenFragment( const prevValid = validityPending ? true : contentValid contentValid = !!ensureValidVNode(children) if (children.length === 0) { - frag.nodes = [] + frag.nodes = EMPTY_BLOCK } else if (children.length === 1) { frag.nodes = resolveVNodeNodes(children[0]) } else { @@ -2471,7 +2477,7 @@ const interopSlotsSourceHandlers: ProxyHandler> = { const slots = target.value return slots ? Object.keys(slots).filter(key => !isInternalSlotKey(key)) - : [] + : EMPTY_ARR }, getOwnPropertyDescriptor(target, key: any) { const slots = target.value From 1729029551b519f533eeb6fc1d2232bfd119034b Mon Sep 17 00:00:00 2001 From: daiwei Date: Mon, 1 Jun 2026 16:04:24 +0800 Subject: [PATCH 08/13] chore: tweaks --- packages/runtime-vapor/__tests__/componentSlots.spec.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/runtime-vapor/__tests__/componentSlots.spec.ts b/packages/runtime-vapor/__tests__/componentSlots.spec.ts index 48a5d18bdc3..83da7bf719b 100644 --- a/packages/runtime-vapor/__tests__/componentSlots.spec.ts +++ b/packages/runtime-vapor/__tests__/componentSlots.spec.ts @@ -800,7 +800,7 @@ describe('component: slots', () => { frag = new SlotFragment() frag.forwarded = true setCurrentHydrationNode(footer) - frag.hydrate(true, true) + frag.hydrate(true) }) }) } finally { @@ -828,7 +828,7 @@ describe('component: slots', () => { hydrateNode(start, () => { withHydratingSlotBoundary(() => { frag = new SlotFragment() - frag.hydrate(true, true) + frag.hydrate(true) }) }) } finally { @@ -855,7 +855,7 @@ describe('component: slots', () => { hydrateNode(start, () => { withHydratingSlotBoundary(() => { frag = new SlotFragment() - frag.hydrate(true, true) + frag.hydrate(true) }) }) } finally { From e17ee95abff5619c6e0078c97dacc74fd26cc3b7 Mon Sep 17 00:00:00 2001 From: daiwei Date: Mon, 1 Jun 2026 16:51:33 +0800 Subject: [PATCH 09/13] fix(runtime-vapor): preserve vdom slot hydration insertion anchor --- .../runtime-vapor/__tests__/hydration.spec.ts | 36 +++++++++++++++++++ packages/runtime-vapor/src/vdomInterop.ts | 12 +++++-- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/packages/runtime-vapor/__tests__/hydration.spec.ts b/packages/runtime-vapor/__tests__/hydration.spec.ts index ad778d1e644..21779df9733 100644 --- a/packages/runtime-vapor/__tests__/hydration.spec.ts +++ b/packages/runtime-vapor/__tests__/hydration.spec.ts @@ -10528,6 +10528,42 @@ describe('VDOM interop', () => { ) }) + test('hydrate compiled VDOM slot content can switch to vapor fallback', async () => { + const data = ref({ show: true }) + const { container } = await testWithVDOMApp( + ` + `, + { + VaporChild: { + code: ``, + vapor: true, + }, + }, + data, + ) + + expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(` + " +
content
+ " + `) + + data.value.show = false + await nextTick() + expect(formatHtml(container.innerHTML)).toBe('fallback') + + data.value.show = true + await nextTick() + expect(formatHtml(container.innerHTML)).toBe('
content
') + }) + test('hydrate VDOM slot content can mount after hydrating as empty', async () => { const data = reactive({ show: false, diff --git a/packages/runtime-vapor/src/vdomInterop.ts b/packages/runtime-vapor/src/vdomInterop.ts index 3c46a6f70a1..f2bdf7c3dfc 100644 --- a/packages/runtime-vapor/src/vdomInterop.ts +++ b/packages/runtime-vapor/src/vdomInterop.ts @@ -1599,6 +1599,12 @@ function renderVDOMSlot( ) { hydrateVNode(hydratedContent, parentComponent as any) } + // Remember the slot outlet insertion point outside the hydrated VNode range. + // The hydrated content itself may be removed by later VDOM patches before the + // fallback is inserted. + const hydratedEnd = hydratedContent.anchor as Node + currentParentNode = hydratedEnd.parentNode as ParentNode + currentAnchor = hydratedEnd.nextSibling setRenderedContent(hydratedContent) } else if (hydratedContent) { frag.vnode = null @@ -1699,8 +1705,10 @@ function renderVDOMSlot( frag.hydrate = () => { if (!isHydrating) return scope.run(render) - currentAnchor = getCurrentSlotEndAnchor() || currentHydrationNode - currentParentNode = currentAnchor!.parentNode as ParentNode + if (!currentParentNode) { + currentAnchor = getCurrentSlotEndAnchor() || currentHydrationNode + currentParentNode = currentAnchor!.parentNode as ParentNode + } isMounted = true } From c32b09f20dddc23c34cef14480b815fdbec466a2 Mon Sep 17 00:00:00 2001 From: daiwei Date: Mon, 1 Jun 2026 17:13:00 +0800 Subject: [PATCH 10/13] fix(runtime-vapor): avoid reinserting active slot fallback --- .../__tests__/componentSlots.spec.ts | 45 ++++++++++++++++++- packages/runtime-vapor/src/fragment.ts | 7 ++- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/packages/runtime-vapor/__tests__/componentSlots.spec.ts b/packages/runtime-vapor/__tests__/componentSlots.spec.ts index 83da7bf719b..5e7eb2cfe46 100644 --- a/packages/runtime-vapor/__tests__/componentSlots.spec.ts +++ b/packages/runtime-vapor/__tests__/componentSlots.spec.ts @@ -43,7 +43,7 @@ import { VaporSlotFlags, VaporVForFlags, } from '@vue/shared' -import { makeRender } from './_utils' +import { compile, makeRender } from './_utils' import type { DynamicSlot } from '../src/componentSlots' import { setElementText, setText } from '../src/dom/prop' import { type Block, type BlockFn, isValidBlock } from '../src/block' @@ -1038,6 +1038,49 @@ describe('component: slots', () => { expect(boundary.markDirty).not.toHaveBeenCalled() }) + test('compiled slot does not reinsert active fallback while content stays invalid', async () => { + const data = ref('first') + const Child = compile( + ``, + data, + ) + const Parent = compile( + ` + `, + data, + { Child }, + ) + const root = document.createElement('div') + const app = createVaporApp(Parent) + app.mount(root) + + const fallback = root.querySelector('span')! + const insertBefore = vi.spyOn(Node.prototype, 'insertBefore') + + data.value = 'second' + await nextTick() + + expect(root.innerHTML).toContain('fallback') + expect( + insertBefore.mock.calls.some(([node]) => node === fallback), + ).toBe(false) + + insertBefore.mockRestore() + app.unmount() + }) + test('vdom slot dirties parent boundary when content validity changes', async () => { const show = ref(true) const boundary = { diff --git a/packages/runtime-vapor/src/fragment.ts b/packages/runtime-vapor/src/fragment.ts index 0a98214871a..1c61f095b41 100644 --- a/packages/runtime-vapor/src/fragment.ts +++ b/packages/runtime-vapor/src/fragment.ts @@ -1045,7 +1045,12 @@ export function recheckSlotFallback( } } } else if (fallback) { - if ( + const fallbackStayedValid = prevNodesValid && fallbackValid + if (fallbackStayedValid) { + if (force) { + renderAndCommitSlotFallback(state, true) + } + } else if ( prevNodesValid && !fallbackValid && !hasSlotFallback(state.boundary.parent) From b03772d610a1204f11ddf9b37b09634958a339c8 Mon Sep 17 00:00:00 2001 From: daiwei Date: Mon, 1 Jun 2026 17:56:30 +0800 Subject: [PATCH 11/13] wip: save --- packages/runtime-vapor/src/fragment.ts | 32 ++++++++++++++------------ 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/packages/runtime-vapor/src/fragment.ts b/packages/runtime-vapor/src/fragment.ts index 1c61f095b41..afd2746ef64 100644 --- a/packages/runtime-vapor/src/fragment.ts +++ b/packages/runtime-vapor/src/fragment.ts @@ -1022,6 +1022,8 @@ export function recheckSlotFallback( const fallback = state.activeFallback const fallbackValid = fallback ? isValidBlock(fallback) : false const contentValid = state.isContentValid() + // This tracks the validity of the currently exposed branch, whether it is + // slot content or fallback. const prevNodesValid = state.lastNodesValid === undefined ? fallback @@ -1034,6 +1036,8 @@ export function recheckSlotFallback( return } + // Content wins over fallback. If fallback was mounted, content may need to + // be inserted back because it can be invalid while fallback is active. if (contentValid) { const content = state.getContent() const hadFallback = !!fallback @@ -1045,26 +1049,24 @@ export function recheckSlotFallback( } } } else if (fallback) { - const fallbackStayedValid = prevNodesValid && fallbackValid - if (fallbackStayedValid) { - if (force) { + // With an active fallback, `prevNodesValid` tells whether it could already + // be in the DOM. Previously invalid fallback is inserted only after it + // becomes valid. + if (prevNodesValid) { + if (!fallbackValid && !hasSlotFallback(state.boundary.parent)) { + // No parent fallback can replace it, so invalid fallback leaves the + // slot empty. + const parentNode = state.getParentNode() + if (parentNode) { + detachBlock(fallback, parentNode) + } + } else if (force) { renderAndCommitSlotFallback(state, true) } - } else if ( - prevNodesValid && - !fallbackValid && - !hasSlotFallback(state.boundary.parent) - ) { - const parentNode = state.getParentNode() - if (parentNode) { - detachBlock(fallback, parentNode) - } - } else if (!prevNodesValid && fallbackValid) { + } else if (fallbackValid) { insertActiveSlotFallback(state) } else if (force) { renderAndCommitSlotFallback(state, true) - } else { - insertActiveSlotFallback(state) } } else { renderAndCommitSlotFallback(state, false) From d739b9e479c1cfd5606010f64a74bde3a30ce58b Mon Sep 17 00:00:00 2001 From: daiwei Date: Mon, 1 Jun 2026 17:57:22 +0800 Subject: [PATCH 12/13] wip: save --- packages/runtime-vapor/__tests__/componentSlots.spec.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/runtime-vapor/__tests__/componentSlots.spec.ts b/packages/runtime-vapor/__tests__/componentSlots.spec.ts index 5e7eb2cfe46..124af189d3e 100644 --- a/packages/runtime-vapor/__tests__/componentSlots.spec.ts +++ b/packages/runtime-vapor/__tests__/componentSlots.spec.ts @@ -1073,9 +1073,9 @@ describe('component: slots', () => { await nextTick() expect(root.innerHTML).toContain('fallback') - expect( - insertBefore.mock.calls.some(([node]) => node === fallback), - ).toBe(false) + expect(insertBefore.mock.calls.some(([node]) => node === fallback)).toBe( + false, + ) insertBefore.mockRestore() app.unmount() From 19f8ed3cb040bef9502987c20e4bdfc0d6c50293 Mon Sep 17 00:00:00 2001 From: daiwei Date: Tue, 2 Jun 2026 08:01:41 +0800 Subject: [PATCH 13/13] wip: save --- packages/runtime-vapor/src/vdomInterop.ts | 72 ++++++++++++----------- 1 file changed, 39 insertions(+), 33 deletions(-) diff --git a/packages/runtime-vapor/src/vdomInterop.ts b/packages/runtime-vapor/src/vdomInterop.ts index f2bdf7c3dfc..9a322650d2c 100644 --- a/packages/runtime-vapor/src/vdomInterop.ts +++ b/packages/runtime-vapor/src/vdomInterop.ts @@ -841,29 +841,23 @@ function trackFragmentVNodeUpdates( ): void { const beforeUpdate = () => { if (frag.onBeforeUpdate) { - for (let i = 0; i < frag.onBeforeUpdate.length; i++) { - frag.onBeforeUpdate[i]() - } + frag.onBeforeUpdate.forEach(bu => bu()) } } const updated = () => { syncNodes() - if (frag.onUpdated) frag.onUpdated.forEach(m => m()) + if (frag.onUpdated) { + frag.onUpdated.forEach(u => u()) + } } appendVnodeBeforeUpdateHook(vnode, beforeUpdate) appendVnodeUpdatedHook(vnode, updated) } -/** - * Mount VNode in vapor - */ -function mountVNode( - internals: RendererInternals, - vnode: VNode, - parentComponent: VaporComponentInstance | null, -): VaporFragment { - const suspense = - currentParentSuspense || (parentComponent && parentComponent.suspense) +function createVNodeFragment(vnode: VNode): { + frag: VaporFragment + syncNodes: () => void +} { const frag = new VaporFragment(EMPTY_BLOCK) frag.vnode = vnode frag.$key = vnode.key @@ -874,6 +868,20 @@ function mountVNode( } frag.isBlockValid = () => (validityPending ? true : isValidBlock(frag.nodes)) trackFragmentVNodeUpdates(frag, vnode, syncNodes) + return { frag, syncNodes } +} + +/** + * Mount VNode in vapor + */ +function mountVNode( + internals: RendererInternals, + vnode: VNode, + parentComponent: VaporComponentInstance | null, +): VaporFragment { + const suspense = + currentParentSuspense || (parentComponent && parentComponent.suspense) + const { frag, syncNodes } = createVNodeFragment(vnode) let isMounted = false const unmount = (parentNode?: ParentNode, transition?: TransitionHooks) => { @@ -976,19 +984,11 @@ function createVDOMComponent( currentParentSuspense || (parentComponent && parentComponent.suspense) const useBridge = shouldUseRendererBridge(component) const comp = useBridge ? ensureRendererBridge(component) : component - const frag = new VaporFragment(EMPTY_BLOCK) - const vnode = (frag.vnode = createVNode( + const vnode = createVNode( comp, rawProps && extend({}, new Proxy(rawProps, rawPropsProxyHandlers)), - )) - frag.$key = vnode.key - let validityPending = !isHydrating - const syncNodes = () => { - frag.nodes = resolveVNodeNodes(vnode) - validityPending = false - } - frag.isBlockValid = () => (validityPending ? true : isValidBlock(frag.nodes)) - trackFragmentVNodeUpdates(frag, vnode, syncNodes) + ) + const { frag, syncNodes } = createVNodeFragment(vnode) if ( !isCollectingVdomSlotVNodes && @@ -1272,6 +1272,7 @@ function isSlotOutletOnlyVNode(vnode: VNode): boolean { function hydrateForwardedEmptySlotFragment( vnode: VNode, parentComponent: VaporComponentInstance | null, + contentValid: boolean, ): boolean { if (vnode.type !== Fragment || !isArray(vnode.children)) { return false @@ -1287,7 +1288,6 @@ function hydrateForwardedEmptySlotFragment( : null const slotEndAnchor = getCurrentSlotEndAnchor() || inheritedEmptySlotEndAnchor const slotStartAnchor = slotEndAnchor && slotEndAnchor.previousSibling - const contentValid = hasValidVNodeContent(vnode) // Case 2: this forwarded fragment still owns an empty `` // range, but the resolved content remains empty. Reuse that range as-is so // later updates patch inside the existing SSR anchors. @@ -1437,14 +1437,19 @@ function renderVDOMSlot( : () => fallback(internals, parentComponent) : undefined - const setRenderedContent = (rendered: VNode | Block | null): void => { + const setRenderedContent = ( + rendered: VNode | Block | null, + knownValid?: boolean, + ): void => { contentState.rendered = rendered if (isVNode(rendered)) { contentState.nodes = resolveVNodeNodes(rendered) - contentState.valid = hasValidVNodeContent(rendered) + contentState.valid = + knownValid === undefined ? hasValidVNodeContent(rendered) : knownValid } else if (rendered) { contentState.nodes = rendered - contentState.valid = isValidBlock(rendered) + contentState.valid = + knownValid === undefined ? isValidBlock(rendered) : knownValid } else { contentState.nodes = EMPTY_BLOCK contentState.valid = false @@ -1595,6 +1600,7 @@ function renderVDOMSlot( !hydrateForwardedEmptySlotFragment( hydratedContent, parentComponent, + slotContentValid, ) ) { hydrateVNode(hydratedContent, parentComponent as any) @@ -1605,11 +1611,11 @@ function renderVDOMSlot( const hydratedEnd = hydratedContent.anchor as Node currentParentNode = hydratedEnd.parentNode as ParentNode currentAnchor = hydratedEnd.nextSibling - setRenderedContent(hydratedContent) + setRenderedContent(hydratedContent, slotContentValid) } else if (hydratedContent) { frag.vnode = null frag.$key = undefined - setRenderedContent(hydratedContent as Block) + setRenderedContent(hydratedContent as Block, slotContentValid) } else { frag.vnode = null frag.$key = undefined @@ -1659,7 +1665,7 @@ function renderVDOMSlot( undefined, // namespace slotContent.slotScopeIds, // pass slotScopeIds for :slotted styles ) - setRenderedContent(slotContent) + setRenderedContent(slotContent, slotContentValid) finishContentUpdate() return } @@ -1674,7 +1680,7 @@ function renderVDOMSlot( remove(prevRendered, currentParentNode!) } insert(slotContent, currentParentNode!, currentAnchor) - setRenderedContent(slotContent) + setRenderedContent(slotContent, slotContentValid) finishContentUpdate() return }