diff --git a/packages/runtime-vapor/__tests__/componentSlots.spec.ts b/packages/runtime-vapor/__tests__/componentSlots.spec.ts index 58076fff9ee..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() @@ -502,5 +509,219 @@ 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') + }) + + 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') + }) + + 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..0f032e685e9 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,12 @@ export const createFor = ( parent!.textContent = '' parent!.appendChild(parentAnchor) } + + // render fallback nodes + if (frag.fallback) { + insert((frag.nodes[0] = frag.fallback()), parent!, parentAnchor) + isFallback = true + } } else if (!getKey) { // unkeyed fast path const commonLength = Math.min(newLength, oldLength) @@ -324,11 +338,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) + } else { + oldBlocks = [] } - setActiveSub(prevSub) } diff --git a/packages/runtime-vapor/src/block.ts b/packages/runtime-vapor/src/block.ts index e021ce84b05..d0fa32855cc 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,18 +72,61 @@ export class DynamicFragment extends VaporFragment { this.nodes = [] } - if (this.fallback && !isValidBlock(this.nodes)) { - parent && remove(this.nodes, parent) - this.nodes = - (this.scope || (this.scope = new EffectScope())).run(this.fallback) || - [] - parent && insert(this.nodes, parent, this.anchor) + if (this.fallback) { + // set fallback for nested fragments + const hasNestedFragment = isFragment(this.nodes) + if (hasNestedFragment) { + setFragmentFallback(this.nodes as VaporFragment, this.fallback) + } + + const invalidFragment = findInvalidFragment(this) + if (invalidFragment) { + parent && remove(this.nodes, parent) + const scope = this.scope || (this.scope = new EffectScope()) + scope.run(() => { + // for nested fragments, render invalid fragment's fallback + if (hasNestedFragment) { + renderFragmentFallback(invalidFragment) + } else { + this.nodes = this.fallback!() || [] + } + }) + parent && insert(this.nodes, parent, this.anchor) + } } setActiveSub(prevSub) } } +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) + } +} + +function renderFragmentFallback(fragment: VaporFragment): void { + if (fragment instanceof ForFragment) { + fragment.nodes[0] = [fragment.fallback!() || []] as Block[] + } else if (fragment instanceof DynamicFragment) { + 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 } @@ -96,7 +146,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) diff --git a/packages/runtime-vapor/src/componentSlots.ts b/packages/runtime-vapor/src/componentSlots.ts index 100c99cdb8a..036cf670e40 100644 --- a/packages/runtime-vapor/src/componentSlots.ts +++ b/packages/runtime-vapor/src/componentSlots.ts @@ -126,18 +126,10 @@ 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( - 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) }