feat(runtime-vapor): `createSlot` (#170)

This commit is contained in:
Rizumu Ayaka 2024-04-14 17:41:58 +08:00 committed by GitHub
parent a0bd0e9c5f
commit 07926564fc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 312 additions and 46 deletions

View File

@ -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>')
})
})
})

View File

@ -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
}

View File

@ -50,6 +50,7 @@ export {
type FunctionalComponent,
type SetupFn,
} from './component'
export { createSlot } from './componentSlots'
export { renderEffect } from './renderEffect'
export {
watch,