wip: render fallback nodes for vfor
ci / test (push) Has been cancelled Details
ci / continuous-release (push) Has been cancelled Details

This commit is contained in:
daiwei 2025-07-23 18:07:22 +08:00
parent e28b96bea8
commit a65da3aee9
3 changed files with 162 additions and 31 deletions

View File

@ -1,7 +1,9 @@
// NOTE: This test is implemented based on the case of `runtime-core/__test__/componentSlots.spec.ts`. // NOTE: This test is implemented based on the case of `runtime-core/__test__/componentSlots.spec.ts`.
import { import {
child,
createComponent, createComponent,
createFor,
createForSlots, createForSlots,
createIf, createIf,
createSlot, createSlot,
@ -12,10 +14,15 @@ import {
renderEffect, renderEffect,
template, template,
} from '../src' } from '../src'
import { currentInstance, nextTick, ref } from '@vue/runtime-dom' import {
currentInstance,
nextTick,
ref,
toDisplayString,
} from '@vue/runtime-dom'
import { makeRender } from './_utils' import { makeRender } from './_utils'
import type { DynamicSlot } from '../src/componentSlots' import type { DynamicSlot } from '../src/componentSlots'
import { setElementText } from '../src/dom/prop' import { setElementText, setText } from '../src/dom/prop'
const define = makeRender<any>() const define = makeRender<any>()
@ -562,7 +569,7 @@ describe('component: slots', () => {
expect(html()).toBe('fallback<!--if--><!--slot-->') expect(html()).toBe('fallback<!--if--><!--slot-->')
}) })
test('render fallback with nested v-if ', async () => { test('render fallback with nested v-if', async () => {
const Child = { const Child = {
setup() { setup() {
return createSlot('default', null, () => return createSlot('default', null, () =>
@ -620,5 +627,101 @@ describe('component: slots', () => {
await nextTick() await nextTick()
expect(html()).toBe('content<!--if--><!--if--><!--slot-->') expect(html()).toBe('content<!--if--><!--if--><!--slot-->')
}) })
test('render fallback with v-for', async () => {
const Child = {
setup() {
return createSlot('default', null, () =>
document.createTextNode('fallback'),
)
},
}
const items = ref<number[]>([1])
const { html } = define({
setup() {
return createComponent(Child, null, {
default: () => {
const n2 = createFor(
() => items.value,
for_item0 => {
const n4 = template('<span> </span>')() as any
const x4 = child(n4) as any
renderEffect(() =>
setText(x4, toDisplayString(for_item0.value)),
)
return n4
},
)
return n2
},
})
},
}).render()
expect(html()).toBe('<span>1</span><!--for--><!--slot-->')
items.value.pop()
await nextTick()
expect(html()).toBe('fallback<!--for--><!--slot-->')
items.value.pop()
await nextTick()
expect(html()).toBe('fallback<!--for--><!--slot-->')
items.value.push(2)
await nextTick()
expect(html()).toBe('<span>2</span><!--for--><!--slot-->')
})
test('render fallback with v-for (empty source)', async () => {
const Child = {
setup() {
return createSlot('default', null, () =>
document.createTextNode('fallback'),
)
},
}
const items = ref<number[]>([])
const { html } = define({
setup() {
return createComponent(Child, null, {
default: () => {
const n2 = createFor(
() => items.value,
for_item0 => {
const n4 = template('<span> </span>')() as any
const x4 = child(n4) as any
renderEffect(() =>
setText(x4, toDisplayString(for_item0.value)),
)
return n4
},
)
return n2
},
})
},
}).render()
expect(html()).toBe('fallback<!--for--><!--slot-->')
items.value.push(1)
await nextTick()
expect(html()).toBe('<span>1</span><!--for--><!--slot-->')
items.value.pop()
await nextTick()
expect(html()).toBe('fallback<!--for--><!--slot-->')
items.value.pop()
await nextTick()
expect(html()).toBe('fallback<!--for--><!--slot-->')
items.value.push(2)
await nextTick()
expect(html()).toBe('<span>2</span><!--for--><!--slot-->')
})
}) })
}) })

View File

@ -15,8 +15,10 @@ import { isArray, isObject, isString } from '@vue/shared'
import { createComment, createTextNode } from './dom/node' import { createComment, createTextNode } from './dom/node'
import { import {
type Block, type Block,
ForFragment,
VaporFragment, VaporFragment,
insert, insert,
remove,
remove as removeBlock, remove as removeBlock,
} from './block' } from './block'
import { warn } from '@vue/runtime-dom' import { warn } from '@vue/runtime-dom'
@ -77,7 +79,7 @@ export const createFor = (
setup?: (_: { setup?: (_: {
createSelector: (source: () => any) => (cb: () => void) => void createSelector: (source: () => any) => (cb: () => void) => void
}) => void, }) => void,
): VaporFragment => { ): ForFragment => {
const _insertionParent = insertionParent const _insertionParent = insertionParent
const _insertionAnchor = insertionAnchor const _insertionAnchor = insertionAnchor
if (isHydrating) { if (isHydrating) {
@ -94,7 +96,7 @@ export const createFor = (
let currentKey: any let currentKey: any
// TODO handle this in hydration // TODO handle this in hydration
const parentAnchor = __DEV__ ? createComment('for') : createTextNode() const parentAnchor = __DEV__ ? createComment('for') : createTextNode()
const frag = new VaporFragment(oldBlocks) const frag = new ForFragment(oldBlocks)
const instance = currentInstance! const instance = currentInstance!
const canUseFastRemove = !!(flags & VaporVForFlags.FAST_REMOVE) const canUseFastRemove = !!(flags & VaporVForFlags.FAST_REMOVE)
const isComponent = !!(flags & VaporVForFlags.IS_COMPONENT) const isComponent = !!(flags & VaporVForFlags.IS_COMPONENT)
@ -112,6 +114,7 @@ export const createFor = (
const newLength = source.values.length const newLength = source.values.length
const oldLength = oldBlocks.length const oldLength = oldBlocks.length
newBlocks = new Array(newLength) newBlocks = new Array(newLength)
let isFallback = false
const prevSub = setActiveSub() const prevSub = setActiveSub()
@ -123,6 +126,11 @@ export const createFor = (
} else { } else {
parent = parent || parentAnchor!.parentNode parent = parent || parentAnchor!.parentNode
if (!oldLength) { 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 // fast path for all new
for (let i = 0; i < newLength; i++) { for (let i = 0; i < newLength; i++) {
mount(source, i) mount(source, i)
@ -140,6 +148,13 @@ export const createFor = (
parent!.textContent = '' parent!.textContent = ''
parent!.appendChild(parentAnchor) parent!.appendChild(parentAnchor)
} }
// render fallback nodes
if (frag.fallback) {
insert((frag.nodes[0] = frag.fallback()), parent!, parentAnchor)
oldBlocks = []
isFallback = true
}
} else if (!getKey) { } else if (!getKey) {
// unkeyed fast path // unkeyed fast path
const commonLength = Math.min(newLength, oldLength) const commonLength = Math.min(newLength, oldLength)
@ -324,11 +339,12 @@ export const createFor = (
} }
} }
frag.nodes = [(oldBlocks = newBlocks)] if (!isFallback) {
if (parentAnchor) { frag.nodes = [(oldBlocks = newBlocks)]
frag.nodes.push(parentAnchor) if (parentAnchor) {
frag.nodes.push(parentAnchor)
}
} }
setActiveSub(prevSub) setActiveSub(prevSub)
} }

View File

@ -18,17 +18,24 @@ export type Block =
export type BlockFn = (...args: any[]) => Block export type BlockFn = (...args: any[]) => Block
export class VaporFragment { export class VaporFragment<T extends Block = Block> {
nodes: Block nodes: T
anchor?: Node anchor?: Node
insert?: (parent: ParentNode, anchor: Node | null) => void insert?: (parent: ParentNode, anchor: Node | null) => void
remove?: (parent?: ParentNode) => void remove?: (parent?: ParentNode) => void
fallback?: BlockFn
constructor(nodes: Block) { constructor(nodes: T) {
this.nodes = nodes this.nodes = nodes
} }
} }
export class ForFragment extends VaporFragment<Block[]> {
constructor(nodes: Block[]) {
super(nodes)
}
}
export class DynamicFragment extends VaporFragment { export class DynamicFragment extends VaporFragment {
anchor: Node anchor: Node
scope: EffectScope | undefined scope: EffectScope | undefined
@ -65,16 +72,18 @@ export class DynamicFragment extends VaporFragment {
this.nodes = [] this.nodes = []
} }
if (this.fallback && !isValidBlock(this.nodes)) { if (this.fallback) {
parent && remove(this.nodes, parent) parent && remove(this.nodes, parent)
// handle nested dynamic fragment const scope = this.scope || (this.scope = new EffectScope())
if (isFragment(this.nodes)) { scope.run(() => {
renderFallback(this.nodes, this.fallback, key) // handle nested fragment
} else { if (isFragment(this.nodes)) {
this.nodes = ensureFallback(this.nodes, this.fallback!)
(this.scope || (this.scope = new EffectScope())).run(this.fallback) || } else if (!isValidBlock(this.nodes)) {
[] this.nodes = this.fallback!() || []
} }
})
parent && insert(this.nodes, parent, this.anchor) parent && insert(this.nodes, parent, this.anchor)
} }
@ -82,19 +91,22 @@ export class DynamicFragment extends VaporFragment {
} }
} }
function renderFallback( function ensureFallback(fragment: VaporFragment, fallback: BlockFn): void {
fragment: VaporFragment, if (!fragment.fallback) fragment.fallback = fallback
fallback: BlockFn,
key: any,
): void {
if (fragment instanceof DynamicFragment) { if (fragment instanceof DynamicFragment) {
const nodes = fragment.nodes const nodes = fragment.nodes
if (isFragment(nodes)) { if (isFragment(nodes)) {
renderFallback(nodes, fallback, key) ensureFallback(nodes, fallback)
} else { } else if (!isValidBlock(nodes)) {
if (!fragment.fallback) fragment.fallback = fallback fragment.update(fragment.fallback)
fragment.update(fragment.fallback, key)
} }
} 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)) { } else if (isVaporComponent(block)) {
return isValidBlock(block.block) return isValidBlock(block.block)
} else if (isArray(block)) { } else if (isArray(block)) {
return block.length > 0 && block.every(isValidBlock) return block.length > 0 && block.some(isValidBlock)
} else { } else {
// fragment // fragment
return isValidBlock(block.nodes) return isValidBlock(block.nodes)