test(vapor): componentSlots

This commit is contained in:
Evan You 2024-12-10 21:36:06 +08:00
parent 7f3b883aea
commit dff54a17fb
No known key found for this signature in database
GPG Key ID: 00E9AB7A6704CE0A
6 changed files with 166 additions and 417 deletions

View File

@ -476,11 +476,6 @@ describe('component: props', () => {
expect(changeSpy).toHaveBeenCalledTimes(1)
})
// #3371
test.todo(`avoid double-setting props when casting`, async () => {
// TODO: provide, slots
})
test('support null in required + multiple-type declarations', () => {
const { render } = define({
props: {

View File

@ -2,30 +2,29 @@
import {
createComponent,
// @ts-expect-error
createForSlots,
createSlot,
createVaporApp,
defineComponent,
getCurrentInstance,
defineVaporComponent,
insert,
nextTick,
prepend,
ref,
renderEffect,
setText,
template,
withDestructure,
} from '../src'
import { currentInstance, nextTick, ref } from '@vue/runtime-dom'
import { makeRender } from './_utils'
const define = makeRender<any>()
function renderWithSlots(slots: any): any {
let instance: any
const Comp = defineComponent({
render() {
const Comp = defineVaporComponent({
setup() {
const t0 = template('<div></div>')
const n0 = t0()
instance = getCurrentInstance()
instance = currentInstance
return n0
},
})
@ -40,51 +39,12 @@ function renderWithSlots(slots: any): any {
return instance
}
describe.todo('component: slots', () => {
test('initSlots: instance.slots should be set correctly', () => {
let instance: any
const Comp = defineComponent({
render() {
const t0 = template('<div></div>')
const n0 = t0()
instance = getCurrentInstance()
return n0
},
})
const { render } = define({
render() {
return createComponent(Comp, {}, { header: () => template('header')() })
},
})
render()
expect(instance.slots.header()).toMatchObject(
document.createTextNode('header'),
)
})
// NOTE: slot normalization is not supported
test.todo(
'initSlots: should normalize object slots (when value is null, string, array)',
() => {},
)
test.todo(
'initSlots: should normalize object slots (when value is function)',
() => {},
)
// runtime-core's "initSlots: instance.slots should be set correctly (when vnode.shapeFlag is not SLOTS_CHILDREN)"
describe('component: slots', () => {
test('initSlots: instance.slots should be set correctly', () => {
const { slots } = renderWithSlots({
default: () => template('<span></span>')(),
})
// expect(
// '[Vue warn]: Non-function value encountered for default slot. Prefer function slots for better performance.',
// ).toHaveBeenWarned()
expect(slots.default()).toMatchObject(document.createElement('span'))
})
@ -93,18 +53,24 @@ describe.todo('component: slots', () => {
let instance: any
const Child = () => {
instance = getCurrentInstance()
instance = currentInstance
return template('child')()
}
const { render } = define({
render() {
return createComponent(Child, {}, [
() =>
flag1.value
? { name: 'one', fn: () => template('<span></span>')() }
: { name: 'two', fn: () => template('<div></div>')() },
])
return createComponent(
Child,
{},
{
$: [
() =>
flag1.value
? { name: 'one', fn: () => template('<span></span>')() }
: { name: 'two', fn: () => template('<div></div>')() },
],
},
)
},
})
@ -120,196 +86,27 @@ describe.todo('component: slots', () => {
expect(instance.slots).toHaveProperty('two')
})
// NOTE: it is not supported
// test('updateSlots: instance.slots should be updated correctly (when slotType is null)', () => {})
// runtime-core's "updateSlots: instance.slots should be update correctly (when vnode.shapeFlag is not SLOTS_CHILDREN)"
test('updateSlots: instance.slots should be update correctly', async () => {
const flag1 = ref(true)
let instance: any
const Child = () => {
instance = getCurrentInstance()
return template('child')()
}
const { render } = define({
setup() {
return createComponent(Child, {}, [
() =>
flag1.value
? { name: 'header', fn: () => template('header')() }
: { name: 'footer', fn: () => template('footer')() },
])
},
})
render()
expect(instance.slots).toHaveProperty('header')
flag1.value = false
await nextTick()
// expect(
// '[Vue warn]: Non-function value encountered for default slot. Prefer function slots for better performance.',
// ).toHaveBeenWarned()
expect(instance.slots).toHaveProperty('footer')
})
test('the current instance should be kept in the slot', async () => {
let instanceInDefaultSlot: any
let instanceInFooSlot: any
const Comp = defineComponent({
render() {
const instance = getCurrentInstance()
instance!.slots.default!()
instance!.slots.foo!()
return template('<div></div>')()
},
})
const { instance } = define({
render() {
return createComponent(Comp, {}, [
{
default: () => {
instanceInDefaultSlot = getCurrentInstance()
return template('content')()
},
foo: () => {
instanceInFooSlot = getCurrentInstance()
return template('content')()
},
},
])
},
}).render()
expect(instanceInDefaultSlot).toBe(instance)
expect(instanceInFooSlot).toBe(instance)
})
test('the current instance should be kept in the dynamic slots', async () => {
let instanceInDefaultSlot: any
let instanceInVForSlot: any
let instanceInVIfSlot: any
const Comp = defineComponent({
render() {
const instance = getCurrentInstance()
instance!.slots.default!()
instance!.slots.inVFor!()
instance!.slots.inVIf!()
return template('<div></div>')()
},
})
const { instance } = define({
render() {
return createComponent(Comp, {}, [
{
default: () => {
instanceInDefaultSlot = getCurrentInstance()
return template('content')()
},
},
() => ({
name: 'inVFor',
fn: () => {
instanceInVForSlot = getCurrentInstance()
return template('content')()
},
}),
() => ({
name: 'inVIf',
fn: () => {
instanceInVIfSlot = getCurrentInstance()
return template('content')()
},
}),
])
},
}).render()
expect(instanceInDefaultSlot).toBe(instance)
expect(instanceInVForSlot).toBe(instance)
expect(instanceInVIfSlot).toBe(instance)
})
test('dynamicSlots should update separately', async () => {
const flag1 = ref(true)
const flag2 = ref(true)
const slotFn1 = vitest.fn()
const slotFn2 = vitest.fn()
let instance: any
const Child = () => {
instance = getCurrentInstance()
return template('child')()
}
const { render } = define({
render() {
return createComponent(Child, {}, [
() => {
slotFn1()
return flag1.value
? { name: 'one', fn: () => template('one')() }
: { name: 'two', fn: () => template('two')() }
},
() => {
slotFn2()
return flag2.value
? { name: 'three', fn: () => template('three')() }
: { name: 'four', fn: () => template('four')() }
},
])
},
})
render()
expect(instance.slots).toHaveProperty('one')
expect(instance.slots).toHaveProperty('three')
expect(slotFn1).toHaveBeenCalledTimes(1)
expect(slotFn2).toHaveBeenCalledTimes(1)
flag1.value = false
await nextTick()
expect(instance.slots).toHaveProperty('two')
expect(instance.slots).toHaveProperty('three')
expect(slotFn1).toHaveBeenCalledTimes(2)
expect(slotFn2).toHaveBeenCalledTimes(1)
flag2.value = false
await nextTick()
expect(instance.slots).toHaveProperty('two')
expect(instance.slots).toHaveProperty('four')
expect(slotFn1).toHaveBeenCalledTimes(2)
expect(slotFn2).toHaveBeenCalledTimes(2)
})
test('should work with createFlorSlots', async () => {
test.todo('should work with createFlorSlots', async () => {
const loop = ref([1, 2, 3])
let instance: any
const Child = () => {
instance = getCurrentInstance()
instance = currentInstance
return template('child')()
}
const { render } = define({
setup() {
return createComponent(Child, {}, [
() =>
createForSlots(loop.value, (item, i) => ({
name: item,
fn: () => template(item + i)(),
})),
])
return createComponent(Child, null, {
$: [
() =>
// @ts-expect-error
createForSlots(loop.value, (item, i) => ({
name: item,
fn: () => template(item + i)(),
})),
],
})
},
})
render()
@ -325,16 +122,11 @@ describe.todo('component: slots', () => {
expect(instance.slots).not.toHaveProperty('1')
})
test.todo('should respect $stable flag', async () => {
// TODO: $stable flag?
})
// passes but no warning for slot invocation in vapor currently
test.todo('should not warn when mounting another app in setup', () => {
// TODO: warning
const Comp = defineComponent({
render() {
const i = getCurrentInstance()
return i!.slots.default!()
const Comp = defineVaporComponent({
setup(_, { slots }) {
return slots.default!()
},
})
const mountComp = () => {
@ -351,9 +143,7 @@ describe.todo('component: slots', () => {
const App = {
setup() {
mountComp()
},
render() {
return null!
return []
},
}
createVaporApp(App).mount(document.createElement('div'))
@ -363,63 +153,58 @@ describe.todo('component: slots', () => {
})
describe('createSlot', () => {
test('slot should be render correctly', () => {
const Comp = defineComponent(() => {
test('slot should be rendered correctly', () => {
const Comp = defineVaporComponent(() => {
const n0 = template('<div>')()
insert(createSlot('header'), n0 as any as ParentNode)
return n0
})
const { host } = define(() => {
return createComponent(Comp, {}, { header: () => template('header')() })
return createComponent(Comp, null, {
header: () => template('header')(),
})
}).render()
expect(host.innerHTML).toBe('<div>header</div>')
expect(host.innerHTML).toBe('<div>header<!--slot--></div>')
})
test('slot should be render correctly with binds', async () => {
const Comp = defineComponent(() => {
test('slot should be rendered correctly with slot props', async () => {
const Comp = defineVaporComponent(() => {
const n0 = template('<div></div>')()
insert(
createSlot('header', [{ title: () => 'header' }]),
createSlot('header', { title: () => 'header' }),
n0 as any as ParentNode,
)
return n0
})
const { host } = define(() => {
return createComponent(Comp, {}, [
{
header: withDestructure(
({ title }) => [title],
ctx => {
const el = template('<h1></h1>')()
renderEffect(() => {
setText(el, ctx[0])
})
return el
},
),
return createComponent(Comp, null, {
header: props => {
const el = template('<h1></h1>')()
renderEffect(() => {
setText(el, props.title)
})
return el
},
])
})
}).render()
expect(host.innerHTML).toBe('<div><h1>header</h1></div>')
expect(host.innerHTML).toBe('<div><h1>header</h1><!--slot--></div>')
})
test('dynamic slot props', async () => {
let props: any
const bindObj = ref<Record<string, any>>({ foo: 1, baz: 'qux' })
const Comp = defineComponent(() =>
createSlot('default', [() => bindObj.value]),
const Comp = defineVaporComponent(() =>
createSlot('default', { $: [() => bindObj.value] }),
)
define(() =>
createComponent(
Comp,
{},
{ default: _props => ((props = _props), []) },
),
createComponent(Comp, null, {
default: _props => ((props = _props), []),
}),
).render()
expect(props).toEqual({ foo: 1, baz: 'qux' })
@ -438,15 +223,16 @@ describe.todo('component: slots', () => {
const foo = ref(0)
const bindObj = ref<Record<string, any>>({ foo: 100, baz: 'qux' })
const Comp = defineComponent(() =>
createSlot('default', [{ foo: () => foo.value }, () => bindObj.value]),
const Comp = defineVaporComponent(() =>
createSlot('default', {
foo: () => foo.value,
$: [() => bindObj.value],
}),
)
define(() =>
createComponent(
Comp,
{},
{ default: _props => ((props = _props), []) },
),
createComponent(Comp, null, {
default: _props => ((props = _props), []),
}),
).render()
expect(props).toEqual({ foo: 100, baz: 'qux' })
@ -460,123 +246,67 @@ describe.todo('component: slots', () => {
expect(props).toEqual({ foo: 2, baz: 'qux' })
})
test('slot class binding should be merged', async () => {
let props: any
test('dynamic slot should be rendered correctly with slot props', async () => {
const val = ref('header')
const className = ref('foo')
const classObj = ref({ bar: true })
const Comp = defineComponent(() =>
createSlot('default', [
{ class: () => className.value },
() => ({ class: ['baz', 'qux'] }),
{ class: () => classObj.value },
]),
)
define(() =>
createComponent(
Comp,
{},
{ default: _props => ((props = _props), []) },
),
).render()
expect(props).toEqual({ class: 'foo baz qux bar' })
classObj.value.bar = false
await nextTick()
expect(props).toEqual({ class: 'foo baz qux' })
className.value = ''
await nextTick()
expect(props).toEqual({ class: 'baz qux' })
})
test('slot style binding should be merged', async () => {
let props: any
const style = ref<any>({ fontSize: '12px' })
const Comp = defineComponent(() =>
createSlot('default', [
{ style: () => style.value },
() => ({ style: { width: '100px', color: 'blue' } }),
{ style: () => 'color: red' },
]),
)
define(() =>
createComponent(
Comp,
{},
{ default: _props => ((props = _props), []) },
),
).render()
expect(props).toEqual({
style: {
fontSize: '12px',
width: '100px',
color: 'red',
},
})
style.value = null
await nextTick()
expect(props).toEqual({
style: {
width: '100px',
color: 'red',
},
})
})
test('dynamic slot should be render correctly with binds', async () => {
const Comp = defineComponent(() => {
const Comp = defineVaporComponent(() => {
const n0 = template('<div></div>')()
prepend(
n0 as any as ParentNode,
createSlot('header', [{ title: () => 'header' }]),
createSlot('header', { title: () => val.value }),
)
return n0
})
const { host } = define(() => {
// dynamic slot
return createComponent(Comp, {}, [
() => ({
name: 'header',
fn: (props: any) => template(props.title)(),
}),
])
return createComponent(Comp, null, {
$: [
() => ({
name: 'header',
fn: (props: any) => template(props.title)(),
}),
],
})
}).render()
expect(host.innerHTML).toBe('<div>header<!--slot--></div>')
val.value = 'footer'
await nextTick()
expect(host.innerHTML).toBe('<div>footer<!--slot--></div>')
})
test('dynamic slot outlet should be render correctly with binds', async () => {
const Comp = defineComponent(() => {
test('dynamic slot outlet should be render correctly with slot props', async () => {
const val = ref('header')
const Comp = defineVaporComponent(() => {
const n0 = template('<div></div>')()
prepend(
n0 as any as ParentNode,
createSlot(
() => 'header', // dynamic slot outlet name
[{ title: () => 'header' }],
() => val.value, // dynamic slot outlet name
),
)
return n0
})
const { host } = define(() => {
return createComponent(
Comp,
{},
{ header: props => template(props.title)() },
)
return createComponent(Comp, null, {
header: () => template('header')(),
footer: () => template('footer')(),
})
}).render()
expect(host.innerHTML).toBe('<div>header<!--slot--></div>')
val.value = 'footer'
await nextTick()
expect(host.innerHTML).toBe('<div>footer<!--slot--></div>')
})
test('fallback should be render correctly', () => {
const Comp = defineComponent(() => {
const Comp = defineVaporComponent(() => {
const n0 = template('<div></div>')()
insert(
createSlot('header', undefined, () => template('fallback')()),
@ -595,24 +325,26 @@ describe.todo('component: slots', () => {
test('dynamic slot should be updated correctly', async () => {
const flag1 = ref(true)
const Child = defineComponent(() => {
const Child = defineVaporComponent(() => {
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')())
const slot1 = createSlot('one', null, () => template('one fallback')())
const slot2 = createSlot('two', null, () => 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')() },
])
return createComponent(Child, null, {
$: [
() =>
flag1.value
? { name: 'one', fn: () => template('one content')() }
: { name: 'two', fn: () => template('two content')() },
],
})
}).render()
expect(host.innerHTML).toBe(
@ -637,7 +369,7 @@ describe.todo('component: slots', () => {
test('dynamic slot outlet should be updated correctly', async () => {
const slotOutletName = ref('one')
const Child = defineComponent(() => {
const Child = defineVaporComponent(() => {
const temp0 = template('<p>')
const el0 = temp0()
const slot1 = createSlot(
@ -674,7 +406,7 @@ describe.todo('component: slots', () => {
})
test('non-exist slot', async () => {
const Child = defineComponent(() => {
const Child = defineVaporComponent(() => {
const el0 = template('<p>')()
const slot = createSlot('not-exist', undefined)
insert(slot, el0 as any as ParentNode)
@ -685,7 +417,7 @@ describe.todo('component: slots', () => {
return createComponent(Child)
}).render()
expect(host.innerHTML).toBe('<p></p>')
expect(host.innerHTML).toBe('<p><!--slot--></p>')
})
})
})

View File

@ -8,7 +8,12 @@ import {
import { createComment } from './dom/node'
import { EffectScope } from '@vue/reactivity'
export type Block = Node | Fragment | VaporComponentInstance | Block[]
export type Block =
| Node
| Fragment
| DynamicFragment
| VaporComponentInstance
| Block[]
export type BlockFn = (...args: any[]) => Block
@ -45,13 +50,12 @@ export class DynamicFragment extends Fragment {
if (this.scope) {
this.scope.stop()
parent && remove(this.nodes, parent)
// TODO lifecycle unmount
}
if (render) {
this.scope = new EffectScope()
this.nodes = this.scope.run(render) || []
if (parent) insert(this.nodes, parent)
if (parent) insert(this.nodes, parent, this.anchor)
} else {
this.scope = undefined
this.nodes = []
@ -99,10 +103,11 @@ export function isValidBlock(block: Block): boolean {
export function insert(
block: Block,
parent: ParentNode,
anchor: Node | null | 0 = null,
anchor: Node | null | 0 = null, // 0 means prepend
): void {
anchor = anchor === 0 ? parent.firstChild : anchor
if (block instanceof Node) {
parent.insertBefore(block, anchor === 0 ? parent.firstChild : anchor)
parent.insertBefore(block, anchor)
} else if (isVaporComponent(block)) {
mountComponent(block, parent, anchor)
} else if (isArray(block)) {
@ -134,5 +139,8 @@ export function remove(block: Block, parent: ParentNode): void {
// fragment
remove(block.nodes, parent)
if (block.anchor) remove(block.anchor, parent)
if ((block as DynamicFragment).scope) {
;(block as DynamicFragment).scope!.stop()
}
}
}

View File

@ -113,7 +113,10 @@ interface SharedInternalOptions {
// 100% strict. Here we use intentionally wider types to make `createComponent`
// more ergonomic in tests and internal call sites, where we immediately cast
// them into the stricter types.
type LooseRawProps = Record<string, (() => unknown) | DynamicPropsSource[]> & {
export type LooseRawProps = Record<
string,
(() => unknown) | DynamicPropsSource[]
> & {
$?: DynamicPropsSource[]
}

View File

@ -110,6 +110,7 @@ export function getPropsProxyHandlers(
? ({
get: (target, key) => getProp(target, key),
has: (_, key) => isProp(key),
ownKeys: () => Object.keys(propsOptions),
getOwnPropertyDescriptor(target, key) {
if (isProp(key)) {
return {
@ -119,7 +120,6 @@ export function getPropsProxyHandlers(
}
}
},
ownKeys: () => Object.keys(propsOptions),
} satisfies ProxyHandler<VaporComponentInstance>)
: null
@ -147,6 +147,7 @@ export function getPropsProxyHandlers(
const attrsHandlers = {
get: (target, key: string) => getAttr(target.rawProps, key),
has: (target, key: string) => hasAttr(target.rawProps, key),
ownKeys: target => getKeysFromRawProps(target.rawProps).filter(isAttr),
getOwnPropertyDescriptor(target, key: string) {
if (hasAttr(target.rawProps, key)) {
return {
@ -156,25 +157,6 @@ export function getPropsProxyHandlers(
}
}
},
ownKeys(target) {
const rawProps = target.rawProps
const keys: string[] = []
for (const key in rawProps) {
if (isAttr(key)) keys.push(key)
}
const dynamicSources = rawProps.$
if (dynamicSources) {
let i = dynamicSources.length
let source
while (i--) {
source = resolveSource(dynamicSources[i])
for (const key in source) {
if (isAttr(key)) keys.push(key)
}
}
}
return Array.from(new Set(keys))
},
} satisfies ProxyHandler<VaporComponentInstance>
if (__DEV__) {
@ -221,6 +203,25 @@ export function hasAttrFromRawProps(rawProps: RawProps, key: string): boolean {
return hasOwn(rawProps, key)
}
export function getKeysFromRawProps(rawProps: RawProps): string[] {
const keys: string[] = []
for (const key in rawProps) {
if (key !== '$') keys.push(key)
}
const dynamicSources = rawProps.$
if (dynamicSources) {
let i = dynamicSources.length
let source
while (i--) {
source = resolveSource(dynamicSources[i])
for (const key in source) {
keys.push(key)
}
}
}
return Array.from(new Set(keys))
}
export function normalizePropsOptions(
comp: VaporComponent,
): NormalizedPropsOptions {

View File

@ -3,10 +3,11 @@ import { type Block, type BlockFn, DynamicFragment } from './block'
import {
type RawProps,
getAttrFromRawProps,
getKeysFromRawProps,
hasAttrFromRawProps,
} from './componentProps'
import { currentInstance } from '@vue/runtime-core'
import type { VaporComponentInstance } from './component'
import type { LooseRawProps, VaporComponentInstance } from './component'
import { renderEffect } from './renderEffect'
export type RawSlots = Record<string, Slot> & {
@ -86,7 +87,16 @@ export function getSlot(target: RawSlots, key: string): Slot | undefined {
const dynamicSlotsPropsProxyHandlers: ProxyHandler<RawProps> = {
get: getAttrFromRawProps,
has: hasAttrFromRawProps,
ownKeys: target => Object.keys(target).filter(k => k !== '$'),
ownKeys: getKeysFromRawProps,
getOwnPropertyDescriptor(target, key: string) {
if (hasAttrFromRawProps(target, key)) {
return {
configurable: true,
enumerable: true,
get: () => getAttrFromRawProps(target, key),
}
}
},
}
// TODO how to handle empty slot return blocks?
@ -95,11 +105,11 @@ const dynamicSlotsPropsProxyHandlers: ProxyHandler<RawProps> = {
// and make the v-if use it as fallback
export function createSlot(
name: string | (() => string),
rawProps?: RawProps,
rawProps?: LooseRawProps | null,
fallback?: Slot,
): Block {
const instance = currentInstance as VaporComponentInstance
const fragment = new DynamicFragment('slot')
const rawSlots = (currentInstance as VaporComponentInstance)!.rawSlots
const slotProps = rawProps
? new Proxy(rawProps, dynamicSlotsPropsProxyHandlers)
: EMPTY_OBJ
@ -107,7 +117,7 @@ export function createSlot(
// always create effect because a slot may contain dynamic root inside
// which affects fallback
renderEffect(() => {
const slot = getSlot(rawSlots, isFunction(name) ? name() : name)
const slot = getSlot(instance.rawSlots, isFunction(name) ? name() : name)
if (slot) {
fragment.update(
() => slot(slotProps) || (fallback && fallback()),