diff --git a/packages/runtime-vapor/__tests__/componentSlots.spec.ts b/packages/runtime-vapor/__tests__/componentSlots.spec.ts index c0e1b716f..d769c0f19 100644 --- a/packages/runtime-vapor/__tests__/componentSlots.spec.ts +++ b/packages/runtime-vapor/__tests__/componentSlots.spec.ts @@ -2,11 +2,16 @@ import { createComponent, + createSlot, createVaporApp, defineComponent, getCurrentInstance, + insert, nextTick, + prepend, ref, + renderEffect, + setText, template, } from '../src' import { makeRender } from './_utils' @@ -237,4 +242,190 @@ describe('component: slots', () => { 'Slot "default" invoked outside of the render function', ).not.toHaveBeenWarned() }) + + describe('createSlot', () => { + test('slot should be render correctly', () => { + const Comp = defineComponent(() => { + const n0 = template('<div></div>')() + insert(createSlot('header'), n0 as any as ParentNode) + return n0 + }) + + const { host } = define(() => { + return createComponent(Comp, {}, { header: () => template('header')() }) + }).render() + + expect(host.innerHTML).toBe('<div>header</div>') + }) + + test('slot should be render correctly with binds', async () => { + const Comp = defineComponent(() => { + const n0 = template('<div></div>')() + insert( + createSlot('header', { title: () => 'header' }), + n0 as any as ParentNode, + ) + return n0 + }) + + const { host } = define(() => { + return createComponent( + Comp, + {}, + { + header: ({ title }) => { + const el = template('<h1></h1>')() + renderEffect(() => { + setText(el, title()) + }) + return el + }, + }, + ) + }).render() + + expect(host.innerHTML).toBe('<div><h1>header</h1></div>') + }) + + test('dynamic slot should be render correctly with binds', async () => { + const Comp = defineComponent(() => { + const n0 = template('<div></div>')() + prepend( + n0 as any as ParentNode, + createSlot('header', { title: () => 'header' }), + ) + return n0 + }) + + const { host } = define(() => { + // dynamic slot + return createComponent(Comp, {}, {}, () => [ + { name: 'header', fn: ({ title }) => template(`${title()}`)() }, + ]) + }).render() + + expect(host.innerHTML).toBe('<div>header<!--slot--></div>') + }) + + test('dynamic slot outlet should be render correctly with binds', async () => { + const Comp = defineComponent(() => { + const n0 = template('<div></div>')() + prepend( + n0 as any as ParentNode, + createSlot( + () => 'header', // dynamic slot outlet name + { title: () => 'header' }, + ), + ) + return n0 + }) + + const { host } = define(() => { + return createComponent( + Comp, + {}, + { header: ({ title }) => template(`${title()}`)() }, + ) + }).render() + + expect(host.innerHTML).toBe('<div>header<!--slot--></div>') + }) + + test('fallback should be render correctly', () => { + const Comp = defineComponent(() => { + const n0 = template('<div></div>')() + insert( + createSlot('header', {}, () => template('fallback')()), + n0 as any as ParentNode, + ) + return n0 + }) + + const { host } = define(() => { + return createComponent(Comp, {}, {}) + }).render() + + expect(host.innerHTML).toBe('<div>fallback</div>') + }) + + test('dynamic slot should be updated correctly', async () => { + const flag1 = ref(true) + + const Child = defineComponent(() => { + const temp0 = template('<p></p>') + const el0 = temp0() + const el1 = temp0() + const slot1 = createSlot('one', {}, () => template('one fallback')()) + const slot2 = createSlot('two', {}, () => template('two fallback')()) + insert(slot1, el0 as any as ParentNode) + insert(slot2, el1 as any as ParentNode) + return [el0, el1] + }) + + const { host } = define(() => { + return createComponent(Child, {}, {}, () => [ + flag1.value + ? { name: 'one', fn: () => template('one content')() } + : { name: 'two', fn: () => template('two content')() }, + ]) + }).render() + + expect(host.innerHTML).toBe( + '<p>one content<!--slot--></p><p>two fallback<!--slot--></p>', + ) + + flag1.value = false + await nextTick() + + expect(host.innerHTML).toBe( + '<p>one fallback<!--slot--></p><p>two content<!--slot--></p>', + ) + + flag1.value = true + await nextTick() + + expect(host.innerHTML).toBe( + '<p>one content<!--slot--></p><p>two fallback<!--slot--></p>', + ) + }) + + test('dynamic slot outlet should be updated correctly', async () => { + const slotOutletName = ref('one') + + const Child = defineComponent(() => { + const temp0 = template('<p></p>') + const el0 = temp0() + const slot1 = createSlot( + () => slotOutletName.value, + {}, + () => template('fallback')(), + ) + insert(slot1, el0 as any as ParentNode) + return el0 + }) + + const { host } = define(() => { + return createComponent( + Child, + {}, + { + one: () => template('one content')(), + two: () => template('two content')(), + }, + ) + }).render() + + expect(host.innerHTML).toBe('<p>one content<!--slot--></p>') + + slotOutletName.value = 'two' + await nextTick() + + expect(host.innerHTML).toBe('<p>two content<!--slot--></p>') + + slotOutletName.value = 'none' + await nextTick() + + expect(host.innerHTML).toBe('<p>fallback<!--slot--></p>') + }) + }) }) diff --git a/packages/runtime-vapor/src/componentSlots.ts b/packages/runtime-vapor/src/componentSlots.ts index 48ea4509c..dc2da78ea 100644 --- a/packages/runtime-vapor/src/componentSlots.ts +++ b/packages/runtime-vapor/src/componentSlots.ts @@ -1,8 +1,23 @@ -import { type IfAny, isArray } from '@vue/shared' -import { baseWatch } from '@vue/reactivity' -import { type ComponentInternalInstance, setCurrentInstance } from './component' -import type { Block } from './apiRender' -import { createVaporPreScheduler } from './scheduler' +import { type IfAny, isArray, isFunction } from '@vue/shared' +import { + type EffectScope, + ReactiveEffect, + type SchedulerJob, + SchedulerJobFlags, + effectScope, + isReactive, + shallowReactive, +} from '@vue/reactivity' +import { + type ComponentInternalInstance, + currentInstance, + setCurrentInstance, +} from './component' +import { type Block, type Fragment, fragmentKey } from './apiRender' +import { renderEffect } from './renderEffect' +import { createComment, createTextNode, insert, remove } from './dom/element' +import { queueJob } from './scheduler' +import { VaporErrorCodes, callWithAsyncErrorHandling } from './errorHandling' // TODO: SSR @@ -29,7 +44,7 @@ export const initSlots = ( rawSlots: InternalSlots | null = null, dynamicSlots: DynamicSlots | null = null, ) => { - const slots: InternalSlots = {} + let slots: InternalSlots = {} for (const key in rawSlots) { const slot = rawSlots[key] @@ -39,50 +54,56 @@ export const initSlots = ( } if (dynamicSlots) { + slots = shallowReactive(slots) const dynamicSlotKeys: Record<string, true> = {} - baseWatch( - () => { - const _dynamicSlots = dynamicSlots() - for (let i = 0; i < _dynamicSlots.length; i++) { - const slot = _dynamicSlots[i] - // array of dynamic slot generated by <template v-for="..." #[...]> - if (isArray(slot)) { - for (let j = 0; j < slot.length; j++) { - slots[slot[j].name] = withCtx(slot[j].fn) - dynamicSlotKeys[slot[j].name] = true - } - } else if (slot) { - // conditional single slot generated by <template v-if="..." #foo> - slots[slot.name] = withCtx( - slot.key - ? (...args: any[]) => { - const res = slot.fn(...args) - // attach branch key so each conditional branch is considered a - // different fragment - if (res) (res as any).key = slot.key - return res - } - : slot.fn, - ) - dynamicSlotKeys[slot.name] = true + + const effect = new ReactiveEffect(() => { + const _dynamicSlots = callWithAsyncErrorHandling( + dynamicSlots, + instance, + VaporErrorCodes.RENDER_FUNCTION, + ) + for (let i = 0; i < _dynamicSlots.length; i++) { + const slot = _dynamicSlots[i] + // array of dynamic slot generated by <template v-for="..." #[...]> + if (isArray(slot)) { + for (let j = 0; j < slot.length; j++) { + slots[slot[j].name] = withCtx(slot[j].fn) + dynamicSlotKeys[slot[j].name] = true } + } else if (slot) { + // conditional single slot generated by <template v-if="..." #foo> + slots[slot.name] = withCtx( + slot.key + ? (...args: any[]) => { + const res = slot.fn(...args) + // attach branch key so each conditional branch is considered a + // different fragment + if (res) (res as any).key = slot.key + return res + } + : slot.fn, + ) + dynamicSlotKeys[slot.name] = true } - // delete stale slots - for (const key in dynamicSlotKeys) { - if ( - !_dynamicSlots.some(slot => - isArray(slot) - ? slot.some(s => s.name === key) - : slot?.name === key, - ) - ) { - delete slots[key] - } + } + // delete stale slots + for (const key in dynamicSlotKeys) { + if ( + !_dynamicSlots.some(slot => + isArray(slot) ? slot.some(s => s.name === key) : slot?.name === key, + ) + ) { + delete slots[key] } - }, - undefined, - { scheduler: createVaporPreScheduler(instance) }, - ) + } + }) + + const job: SchedulerJob = () => effect.run() + job.flags! |= SchedulerJobFlags.PRE + job.id = instance.uid + effect.scheduler = () => queueJob(job) + effect.run() } instance.slots = slots @@ -98,3 +119,56 @@ export const initSlots = ( } } } + +export function createSlot( + name: string | (() => string), + binds?: Record<string, (() => unknown) | undefined>, + fallback?: () => Block, +): Block { + let block: Block | undefined + let branch: Slot | undefined + let oldBranch: Slot | undefined + let parent: ParentNode | undefined | null + let scope: EffectScope | undefined + const isDynamicName = isFunction(name) + const instance = currentInstance! + const { slots } = instance + + // When not using dynamic slots, simplify the process to improve performance + if (!isDynamicName && !isReactive(slots)) { + if ((branch = slots[name] || fallback)) { + return branch(binds) + } else { + return [] + } + } + + const getSlot = isDynamicName ? () => slots[name()] : () => slots[name] + const anchor = __DEV__ ? createComment('slot') : createTextNode() + const fragment: Fragment = { + nodes: [], + anchor, + [fragmentKey]: true, + } + + // TODO lifecycle hooks + renderEffect(() => { + if ((branch = getSlot() || fallback) !== oldBranch) { + parent ||= anchor.parentNode + if (block) { + scope!.stop() + remove(block, parent!) + } + if ((oldBranch = branch)) { + scope = effectScope() + fragment.nodes = block = scope.run(() => branch!(binds))! + parent && insert(block, parent, anchor) + } else { + scope = block = undefined + fragment.nodes = [] + } + } + }) + + return fragment +} diff --git a/packages/runtime-vapor/src/index.ts b/packages/runtime-vapor/src/index.ts index b15f4c461..919e0c2c6 100644 --- a/packages/runtime-vapor/src/index.ts +++ b/packages/runtime-vapor/src/index.ts @@ -50,6 +50,7 @@ export { type FunctionalComponent, type SetupFn, } from './component' +export { createSlot } from './componentSlots' export { renderEffect } from './renderEffect' export { watch,