From 2ba4dc0d0720b0cc8639e04680dd157c71913299 Mon Sep 17 00:00:00 2001 From: daiwei Date: Mon, 21 Jul 2025 08:29:31 +0800 Subject: [PATCH 1/5] fix(runtime-vapor): render slot fallback if slot content is not a valid block close #13668 --- .../__tests__/componentSlots.spec.ts | 59 +++++++++++++++++++ packages/runtime-vapor/src/block.ts | 12 +++- packages/runtime-vapor/src/componentSlots.ts | 1 + 3 files changed, 69 insertions(+), 3 deletions(-) diff --git a/packages/runtime-vapor/__tests__/componentSlots.spec.ts b/packages/runtime-vapor/__tests__/componentSlots.spec.ts index 58076fff9ee..f54137724cc 100644 --- a/packages/runtime-vapor/__tests__/componentSlots.spec.ts +++ b/packages/runtime-vapor/__tests__/componentSlots.spec.ts @@ -502,5 +502,64 @@ describe('component: slots', () => { await nextTick() expect(host.innerHTML).toBe('

') }) + + test('render fallback when slot content is not valid', async () => { + const Child = { + setup() { + return createSlot('default', null, () => + document.createTextNode('fallback'), + ) + }, + } + + const { html } = define({ + setup() { + return createComponent(Child, null, { + default: () => { + return template('')() + }, + }) + }, + }).render() + + expect(html()).toBe('fallback') + }) + + test('render fallback when v-if condition is false', async () => { + const Child = { + setup() { + return createSlot('default', null, () => + document.createTextNode('fallback'), + ) + }, + } + + const toggle = ref(false) + + const { html } = define({ + setup() { + return createComponent(Child, null, { + default: () => { + return createIf( + () => toggle.value, + () => { + return document.createTextNode('content') + }, + ) + }, + }) + }, + }).render() + + expect(html()).toBe('fallback') + + toggle.value = true + await nextTick() + expect(html()).toBe('content') + + toggle.value = false + await nextTick() + expect(html()).toBe('fallback') + }) }) }) diff --git a/packages/runtime-vapor/src/block.ts b/packages/runtime-vapor/src/block.ts index e021ce84b05..50a453dc483 100644 --- a/packages/runtime-vapor/src/block.ts +++ b/packages/runtime-vapor/src/block.ts @@ -67,9 +67,15 @@ export class DynamicFragment extends VaporFragment { if (this.fallback && !isValidBlock(this.nodes)) { parent && remove(this.nodes, parent) - this.nodes = - (this.scope || (this.scope = new EffectScope())).run(this.fallback) || - [] + // if current nodes is a DynamicFragment, call its update with the fallback + // to handle nested dynamic fragment + if (this.nodes instanceof DynamicFragment) { + this.nodes.update(this.fallback) + } else { + this.nodes = + (this.scope || (this.scope = new EffectScope())).run(this.fallback) || + [] + } parent && insert(this.nodes, parent, this.anchor) } diff --git a/packages/runtime-vapor/src/componentSlots.ts b/packages/runtime-vapor/src/componentSlots.ts index 100c99cdb8a..4215548f2e6 100644 --- a/packages/runtime-vapor/src/componentSlots.ts +++ b/packages/runtime-vapor/src/componentSlots.ts @@ -126,6 +126,7 @@ export function createSlot( const renderSlot = () => { const slot = getSlot(rawSlots, isFunction(name) ? name() : name) if (slot) { + fragment.fallback = fallback // create and cache bound version of the slot to make it stable // so that we avoid unnecessary updates if it resolves to the same slot fragment.update( From e28b96bea872fe06f9b6cb0a121403063fa1eccb Mon Sep 17 00:00:00 2001 From: daiwei Date: Tue, 22 Jul 2025 10:31:16 +0800 Subject: [PATCH 2/5] chore: tweaks --- .../__tests__/componentSlots.spec.ts | 59 +++++++++++++++++++ packages/runtime-vapor/src/block.ts | 23 ++++++-- 2 files changed, 78 insertions(+), 4 deletions(-) diff --git a/packages/runtime-vapor/__tests__/componentSlots.spec.ts b/packages/runtime-vapor/__tests__/componentSlots.spec.ts index f54137724cc..608a0ff5568 100644 --- a/packages/runtime-vapor/__tests__/componentSlots.spec.ts +++ b/packages/runtime-vapor/__tests__/componentSlots.spec.ts @@ -561,5 +561,64 @@ describe('component: slots', () => { await nextTick() expect(html()).toBe('fallback') }) + + test('render fallback with nested v-if ', async () => { + const Child = { + setup() { + return createSlot('default', null, () => + document.createTextNode('fallback'), + ) + }, + } + + const outerShow = ref(false) + const innerShow = ref(false) + + const { html } = define({ + setup() { + return createComponent(Child, null, { + default: () => { + return createIf( + () => outerShow.value, + () => { + return createIf( + () => innerShow.value, + () => { + return document.createTextNode('content') + }, + ) + }, + ) + }, + }) + }, + }).render() + + expect(html()).toBe('fallback') + + outerShow.value = true + await nextTick() + expect(html()).toBe('fallback') + + innerShow.value = true + await nextTick() + expect(html()).toBe('content') + + innerShow.value = false + await nextTick() + expect(html()).toBe('fallback') + + outerShow.value = false + await nextTick() + expect(html()).toBe('fallback') + + outerShow.value = true + await nextTick() + expect(html()).toBe('fallback') + + innerShow.value = true + await nextTick() + expect(html()).toBe('content') + }) }) }) diff --git a/packages/runtime-vapor/src/block.ts b/packages/runtime-vapor/src/block.ts index 50a453dc483..2a78048f8b2 100644 --- a/packages/runtime-vapor/src/block.ts +++ b/packages/runtime-vapor/src/block.ts @@ -67,10 +67,9 @@ export class DynamicFragment extends VaporFragment { if (this.fallback && !isValidBlock(this.nodes)) { parent && remove(this.nodes, parent) - // if current nodes is a DynamicFragment, call its update with the fallback - // to handle nested dynamic fragment - if (this.nodes instanceof DynamicFragment) { - this.nodes.update(this.fallback) + // handle nested dynamic fragment + if (isFragment(this.nodes)) { + renderFallback(this.nodes, this.fallback, key) } else { this.nodes = (this.scope || (this.scope = new EffectScope())).run(this.fallback) || @@ -83,6 +82,22 @@ export class DynamicFragment extends VaporFragment { } } +function renderFallback( + fragment: VaporFragment, + fallback: BlockFn, + key: any, +): void { + if (fragment instanceof DynamicFragment) { + const nodes = fragment.nodes + if (isFragment(nodes)) { + renderFallback(nodes, fallback, key) + } else { + if (!fragment.fallback) fragment.fallback = fallback + fragment.update(fragment.fallback, key) + } + } +} + export function isFragment(val: NonNullable): val is VaporFragment { return val instanceof VaporFragment } From a65da3aee915014b9de5652481a290d87b517d3f Mon Sep 17 00:00:00 2001 From: daiwei Date: Wed, 23 Jul 2025 18:07:22 +0800 Subject: [PATCH 3/5] wip: render fallback nodes for vfor --- .../__tests__/componentSlots.spec.ts | 109 +++++++++++++++++- packages/runtime-vapor/src/apiCreateFor.ts | 28 ++++- packages/runtime-vapor/src/block.ts | 56 +++++---- 3 files changed, 162 insertions(+), 31 deletions(-) diff --git a/packages/runtime-vapor/__tests__/componentSlots.spec.ts b/packages/runtime-vapor/__tests__/componentSlots.spec.ts index 608a0ff5568..ea9a3f8c8d7 100644 --- a/packages/runtime-vapor/__tests__/componentSlots.spec.ts +++ b/packages/runtime-vapor/__tests__/componentSlots.spec.ts @@ -1,7 +1,9 @@ // NOTE: This test is implemented based on the case of `runtime-core/__test__/componentSlots.spec.ts`. import { + child, createComponent, + createFor, createForSlots, createIf, createSlot, @@ -12,10 +14,15 @@ import { renderEffect, template, } from '../src' -import { currentInstance, nextTick, ref } from '@vue/runtime-dom' +import { + currentInstance, + nextTick, + ref, + toDisplayString, +} from '@vue/runtime-dom' import { makeRender } from './_utils' import type { DynamicSlot } from '../src/componentSlots' -import { setElementText } from '../src/dom/prop' +import { setElementText, setText } from '../src/dom/prop' const define = makeRender() @@ -562,7 +569,7 @@ describe('component: slots', () => { expect(html()).toBe('fallback') }) - test('render fallback with nested v-if ', async () => { + test('render fallback with nested v-if', async () => { const Child = { setup() { return createSlot('default', null, () => @@ -620,5 +627,101 @@ describe('component: slots', () => { await nextTick() expect(html()).toBe('content') }) + + test('render fallback with v-for', async () => { + const Child = { + setup() { + return createSlot('default', null, () => + document.createTextNode('fallback'), + ) + }, + } + + const items = ref([1]) + const { html } = define({ + setup() { + return createComponent(Child, null, { + default: () => { + const n2 = createFor( + () => items.value, + for_item0 => { + const n4 = template(' ')() as any + const x4 = child(n4) as any + renderEffect(() => + setText(x4, toDisplayString(for_item0.value)), + ) + return n4 + }, + ) + return n2 + }, + }) + }, + }).render() + + expect(html()).toBe('1') + + items.value.pop() + await nextTick() + expect(html()).toBe('fallback') + + items.value.pop() + await nextTick() + expect(html()).toBe('fallback') + + items.value.push(2) + await nextTick() + expect(html()).toBe('2') + }) + + test('render fallback with v-for (empty source)', async () => { + const Child = { + setup() { + return createSlot('default', null, () => + document.createTextNode('fallback'), + ) + }, + } + + const items = ref([]) + const { html } = define({ + setup() { + return createComponent(Child, null, { + default: () => { + const n2 = createFor( + () => items.value, + for_item0 => { + const n4 = template(' ')() as any + const x4 = child(n4) as any + renderEffect(() => + setText(x4, toDisplayString(for_item0.value)), + ) + return n4 + }, + ) + return n2 + }, + }) + }, + }).render() + + expect(html()).toBe('fallback') + + items.value.push(1) + await nextTick() + expect(html()).toBe('1') + + items.value.pop() + await nextTick() + expect(html()).toBe('fallback') + + items.value.pop() + await nextTick() + expect(html()).toBe('fallback') + + items.value.push(2) + await nextTick() + expect(html()).toBe('2') + }) }) }) diff --git a/packages/runtime-vapor/src/apiCreateFor.ts b/packages/runtime-vapor/src/apiCreateFor.ts index 9ffdf6dca57..ac2a94f0926 100644 --- a/packages/runtime-vapor/src/apiCreateFor.ts +++ b/packages/runtime-vapor/src/apiCreateFor.ts @@ -15,8 +15,10 @@ import { isArray, isObject, isString } from '@vue/shared' import { createComment, createTextNode } from './dom/node' import { type Block, + ForFragment, VaporFragment, insert, + remove, remove as removeBlock, } from './block' import { warn } from '@vue/runtime-dom' @@ -77,7 +79,7 @@ export const createFor = ( setup?: (_: { createSelector: (source: () => any) => (cb: () => void) => void }) => void, -): VaporFragment => { +): ForFragment => { const _insertionParent = insertionParent const _insertionAnchor = insertionAnchor if (isHydrating) { @@ -94,7 +96,7 @@ export const createFor = ( let currentKey: any // TODO handle this in hydration const parentAnchor = __DEV__ ? createComment('for') : createTextNode() - const frag = new VaporFragment(oldBlocks) + const frag = new ForFragment(oldBlocks) const instance = currentInstance! const canUseFastRemove = !!(flags & VaporVForFlags.FAST_REMOVE) const isComponent = !!(flags & VaporVForFlags.IS_COMPONENT) @@ -112,6 +114,7 @@ export const createFor = ( const newLength = source.values.length const oldLength = oldBlocks.length newBlocks = new Array(newLength) + let isFallback = false const prevSub = setActiveSub() @@ -123,6 +126,11 @@ export const createFor = ( } else { parent = parent || parentAnchor!.parentNode if (!oldLength) { + // remove fallback nodes + if (frag.fallback && (frag.nodes[0] as Block[]).length > 0) { + remove(frag.nodes[0], parent!) + } + // fast path for all new for (let i = 0; i < newLength; i++) { mount(source, i) @@ -140,6 +148,13 @@ export const createFor = ( parent!.textContent = '' parent!.appendChild(parentAnchor) } + + // render fallback nodes + if (frag.fallback) { + insert((frag.nodes[0] = frag.fallback()), parent!, parentAnchor) + oldBlocks = [] + isFallback = true + } } else if (!getKey) { // unkeyed fast path const commonLength = Math.min(newLength, oldLength) @@ -324,11 +339,12 @@ export const createFor = ( } } - frag.nodes = [(oldBlocks = newBlocks)] - if (parentAnchor) { - frag.nodes.push(parentAnchor) + if (!isFallback) { + frag.nodes = [(oldBlocks = newBlocks)] + if (parentAnchor) { + frag.nodes.push(parentAnchor) + } } - setActiveSub(prevSub) } diff --git a/packages/runtime-vapor/src/block.ts b/packages/runtime-vapor/src/block.ts index 2a78048f8b2..0271c1c0087 100644 --- a/packages/runtime-vapor/src/block.ts +++ b/packages/runtime-vapor/src/block.ts @@ -18,17 +18,24 @@ export type Block = export type BlockFn = (...args: any[]) => Block -export class VaporFragment { - nodes: Block +export class VaporFragment { + nodes: T anchor?: Node insert?: (parent: ParentNode, anchor: Node | null) => void remove?: (parent?: ParentNode) => void + fallback?: BlockFn - constructor(nodes: Block) { + constructor(nodes: T) { this.nodes = nodes } } +export class ForFragment extends VaporFragment { + constructor(nodes: Block[]) { + super(nodes) + } +} + export class DynamicFragment extends VaporFragment { anchor: Node scope: EffectScope | undefined @@ -65,16 +72,18 @@ export class DynamicFragment extends VaporFragment { this.nodes = [] } - if (this.fallback && !isValidBlock(this.nodes)) { + if (this.fallback) { parent && remove(this.nodes, parent) - // handle nested dynamic fragment - if (isFragment(this.nodes)) { - renderFallback(this.nodes, this.fallback, key) - } else { - this.nodes = - (this.scope || (this.scope = new EffectScope())).run(this.fallback) || - [] - } + const scope = this.scope || (this.scope = new EffectScope()) + scope.run(() => { + // handle nested fragment + if (isFragment(this.nodes)) { + ensureFallback(this.nodes, this.fallback!) + } else if (!isValidBlock(this.nodes)) { + this.nodes = this.fallback!() || [] + } + }) + parent && insert(this.nodes, parent, this.anchor) } @@ -82,19 +91,22 @@ export class DynamicFragment extends VaporFragment { } } -function renderFallback( - fragment: VaporFragment, - fallback: BlockFn, - key: any, -): void { +function ensureFallback(fragment: VaporFragment, fallback: BlockFn): void { + if (!fragment.fallback) fragment.fallback = fallback + if (fragment instanceof DynamicFragment) { const nodes = fragment.nodes if (isFragment(nodes)) { - renderFallback(nodes, fallback, key) - } else { - if (!fragment.fallback) fragment.fallback = fallback - fragment.update(fragment.fallback, key) + ensureFallback(nodes, fallback) + } else if (!isValidBlock(nodes)) { + fragment.update(fragment.fallback) } + } else if (fragment instanceof ForFragment) { + if (!isValidBlock(fragment.nodes[0])) { + fragment.nodes[0] = [fallback() || []] as Block[] + } + } else { + // vdom slots } } @@ -117,7 +129,7 @@ export function isValidBlock(block: Block): boolean { } else if (isVaporComponent(block)) { return isValidBlock(block.block) } else if (isArray(block)) { - return block.length > 0 && block.every(isValidBlock) + return block.length > 0 && block.some(isValidBlock) } else { // fragment return isValidBlock(block.nodes) From b8ceb89a4634b46e619354610dc6df395dff0259 Mon Sep 17 00:00:00 2001 From: daiwei Date: Fri, 25 Jul 2025 10:16:29 +0800 Subject: [PATCH 4/5] chore: tweaks --- packages/runtime-vapor/src/apiCreateFor.ts | 7 ++- packages/runtime-vapor/src/block.ts | 57 ++++++++++++-------- packages/runtime-vapor/src/componentSlots.ts | 11 +--- 3 files changed, 40 insertions(+), 35 deletions(-) diff --git a/packages/runtime-vapor/src/apiCreateFor.ts b/packages/runtime-vapor/src/apiCreateFor.ts index ac2a94f0926..0f032e685e9 100644 --- a/packages/runtime-vapor/src/apiCreateFor.ts +++ b/packages/runtime-vapor/src/apiCreateFor.ts @@ -152,7 +152,6 @@ export const createFor = ( // render fallback nodes if (frag.fallback) { insert((frag.nodes[0] = frag.fallback()), parent!, parentAnchor) - oldBlocks = [] isFallback = true } } else if (!getKey) { @@ -341,9 +340,9 @@ export const createFor = ( if (!isFallback) { frag.nodes = [(oldBlocks = newBlocks)] - if (parentAnchor) { - frag.nodes.push(parentAnchor) - } + if (parentAnchor) frag.nodes.push(parentAnchor) + } else { + oldBlocks = [] } setActiveSub(prevSub) } diff --git a/packages/runtime-vapor/src/block.ts b/packages/runtime-vapor/src/block.ts index 0271c1c0087..7c178e894b0 100644 --- a/packages/runtime-vapor/src/block.ts +++ b/packages/runtime-vapor/src/block.ts @@ -73,38 +73,53 @@ export class DynamicFragment extends VaporFragment { } if (this.fallback) { - parent && remove(this.nodes, parent) - const scope = this.scope || (this.scope = new EffectScope()) - scope.run(() => { - // handle nested fragment - if (isFragment(this.nodes)) { - ensureFallback(this.nodes, this.fallback!) - } else if (!isValidBlock(this.nodes)) { - this.nodes = this.fallback!() || [] - } - }) - - parent && insert(this.nodes, parent, this.anchor) + // set fallback for nested fragments + const isFrag = isFragment(this.nodes) + if (isFrag) { + setFragmentFallback(this.nodes as VaporFragment, this.fallback) + } + + if (!isValidBlock(this.nodes)) { + parent && remove(this.nodes, parent) + const scope = this.scope || (this.scope = new EffectScope()) + scope.run(() => { + if (isFrag) { + // render fragment's fallback + renderFragmentFallback(this.nodes as VaporFragment) + } else { + this.nodes = this.fallback!() || [] + } + }) + parent && insert(this.nodes, parent, this.anchor) + } } setActiveSub(prevSub) } } -function ensureFallback(fragment: VaporFragment, fallback: BlockFn): void { - if (!fragment.fallback) fragment.fallback = fallback +function setFragmentFallback( + fragment: VaporFragment, + fallback: BlockFn | undefined, +): void { + if (!fragment.fallback) { + fragment.fallback = fallback + } + if (isFragment(fragment.nodes)) { + setFragmentFallback(fragment.nodes, fallback) + } +} - if (fragment instanceof DynamicFragment) { +function renderFragmentFallback(fragment: VaporFragment): void { + if (fragment instanceof ForFragment) { + fragment.nodes[0] = [fragment.fallback!() || []] as Block[] + } else if (fragment instanceof DynamicFragment) { const nodes = fragment.nodes if (isFragment(nodes)) { - ensureFallback(nodes, fallback) - } else if (!isValidBlock(nodes)) { + renderFragmentFallback(nodes) + } else { fragment.update(fragment.fallback) } - } else if (fragment instanceof ForFragment) { - if (!isValidBlock(fragment.nodes[0])) { - fragment.nodes[0] = [fallback() || []] as Block[] - } } else { // vdom slots } diff --git a/packages/runtime-vapor/src/componentSlots.ts b/packages/runtime-vapor/src/componentSlots.ts index 4215548f2e6..036cf670e40 100644 --- a/packages/runtime-vapor/src/componentSlots.ts +++ b/packages/runtime-vapor/src/componentSlots.ts @@ -129,16 +129,7 @@ export function createSlot( fragment.fallback = fallback // create and cache bound version of the slot to make it stable // so that we avoid unnecessary updates if it resolves to the same slot - fragment.update( - slot._bound || - (slot._bound = () => { - const slotContent = slot(slotProps) - if (slotContent instanceof DynamicFragment) { - slotContent.fallback = fallback - } - return slotContent - }), - ) + fragment.update(slot._bound || (slot._bound = () => slot(slotProps))) } else { fragment.update(fallback) } From 3a52e46b861e59b77e8e37fba94062a989b904e4 Mon Sep 17 00:00:00 2001 From: daiwei Date: Fri, 25 Jul 2025 21:51:07 +0800 Subject: [PATCH 5/5] fix(runtime-vapor): improve fallback handling for nested fragments --- packages/runtime-vapor/src/block.ts | 40 +++++++++++++++-------------- 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/packages/runtime-vapor/src/block.ts b/packages/runtime-vapor/src/block.ts index 7c178e894b0..d0fa32855cc 100644 --- a/packages/runtime-vapor/src/block.ts +++ b/packages/runtime-vapor/src/block.ts @@ -74,18 +74,19 @@ export class DynamicFragment extends VaporFragment { if (this.fallback) { // set fallback for nested fragments - const isFrag = isFragment(this.nodes) - if (isFrag) { + const hasNestedFragment = isFragment(this.nodes) + if (hasNestedFragment) { setFragmentFallback(this.nodes as VaporFragment, this.fallback) } - if (!isValidBlock(this.nodes)) { + const invalidFragment = findInvalidFragment(this) + if (invalidFragment) { parent && remove(this.nodes, parent) const scope = this.scope || (this.scope = new EffectScope()) scope.run(() => { - if (isFrag) { - // render fragment's fallback - renderFragmentFallback(this.nodes as VaporFragment) + // for nested fragments, render invalid fragment's fallback + if (hasNestedFragment) { + renderFragmentFallback(invalidFragment) } else { this.nodes = this.fallback!() || [] } @@ -98,13 +99,11 @@ export class DynamicFragment extends VaporFragment { } } -function setFragmentFallback( - fragment: VaporFragment, - fallback: BlockFn | undefined, -): void { - if (!fragment.fallback) { - fragment.fallback = fallback - } +function setFragmentFallback(fragment: VaporFragment, fallback: BlockFn): void { + // stop recursion if fragment has its own fallback + if (fragment.fallback) return + + fragment.fallback = fallback if (isFragment(fragment.nodes)) { setFragmentFallback(fragment.nodes, fallback) } @@ -114,17 +113,20 @@ function renderFragmentFallback(fragment: VaporFragment): void { if (fragment instanceof ForFragment) { fragment.nodes[0] = [fragment.fallback!() || []] as Block[] } else if (fragment instanceof DynamicFragment) { - const nodes = fragment.nodes - if (isFragment(nodes)) { - renderFragmentFallback(nodes) - } else { - fragment.update(fragment.fallback) - } + fragment.update(fragment.fallback) } else { // vdom slots } } +function findInvalidFragment(fragment: VaporFragment): VaporFragment | null { + if (isValidBlock(fragment.nodes)) return null + + return isFragment(fragment.nodes) + ? findInvalidFragment(fragment.nodes) || fragment + : fragment +} + export function isFragment(val: NonNullable): val is VaporFragment { return val instanceof VaporFragment }