diff --git a/packages/runtime-vapor/__tests__/componentProps.spec.ts b/packages/runtime-vapor/__tests__/componentProps.spec.ts index 6d87041f9..3f8270b3d 100644 --- a/packages/runtime-vapor/__tests__/componentProps.spec.ts +++ b/packages/runtime-vapor/__tests__/componentProps.spec.ts @@ -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: { diff --git a/packages/runtime-vapor/__tests__/componentSlots.spec.ts b/packages/runtime-vapor/__tests__/componentSlots.spec.ts index 2cdf8fe73..03c96dbc1 100644 --- a/packages/runtime-vapor/__tests__/componentSlots.spec.ts +++ b/packages/runtime-vapor/__tests__/componentSlots.spec.ts @@ -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() + function renderWithSlots(slots: any): any { let instance: any - const Comp = defineComponent({ - render() { + const Comp = defineVaporComponent({ + setup() { const t0 = template('
') 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('
') - 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('')(), }) - // 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('')() } - : { name: 'two', fn: () => template('
')() }, - ]) + return createComponent( + Child, + {}, + { + $: [ + () => + flag1.value + ? { name: 'one', fn: () => template('')() } + : { name: 'two', fn: () => template('
')() }, + ], + }, + ) }, }) @@ -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('
')() - }, - }) - - 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('
')() - }, - }) - - 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('
')() 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('
header
') + expect(host.innerHTML).toBe('
header
') }) - 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('
')() 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('

')() - renderEffect(() => { - setText(el, ctx[0]) - }) - return el - }, - ), + return createComponent(Comp, null, { + header: props => { + const el = template('

')() + renderEffect(() => { + setText(el, props.title) + }) + return el }, - ]) + }) }).render() - expect(host.innerHTML).toBe('

header

') + expect(host.innerHTML).toBe('

header

') }) test('dynamic slot props', async () => { let props: any const bindObj = ref>({ 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>({ 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({ 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('
')() 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('
header
') + + val.value = 'footer' + await nextTick() + expect(host.innerHTML).toBe('
footer
') }) - 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('
')() 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('
header
') + + val.value = 'footer' + await nextTick() + expect(host.innerHTML).toBe('
footer
') }) test('fallback should be render correctly', () => { - const Comp = defineComponent(() => { + const Comp = defineVaporComponent(() => { const n0 = template('
')() 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('

') 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('

') 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('

')() 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('

') + expect(host.innerHTML).toBe('

') }) }) }) diff --git a/packages/runtime-vapor/src/block.ts b/packages/runtime-vapor/src/block.ts index 01cc30ceb..3951be239 100644 --- a/packages/runtime-vapor/src/block.ts +++ b/packages/runtime-vapor/src/block.ts @@ -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() + } } } diff --git a/packages/runtime-vapor/src/component.ts b/packages/runtime-vapor/src/component.ts index 2e0a98171..7c99de699 100644 --- a/packages/runtime-vapor/src/component.ts +++ b/packages/runtime-vapor/src/component.ts @@ -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 unknown) | DynamicPropsSource[]> & { +export type LooseRawProps = Record< + string, + (() => unknown) | DynamicPropsSource[] +> & { $?: DynamicPropsSource[] } diff --git a/packages/runtime-vapor/src/componentProps.ts b/packages/runtime-vapor/src/componentProps.ts index 1e615b578..2876a4e0d 100644 --- a/packages/runtime-vapor/src/componentProps.ts +++ b/packages/runtime-vapor/src/componentProps.ts @@ -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) : 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 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 { diff --git a/packages/runtime-vapor/src/componentSlots.ts b/packages/runtime-vapor/src/componentSlots.ts index 4ee382840..187565145 100644 --- a/packages/runtime-vapor/src/componentSlots.ts +++ b/packages/runtime-vapor/src/componentSlots.ts @@ -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 & { @@ -86,7 +87,16 @@ export function getSlot(target: RawSlots, key: string): Slot | undefined { const dynamicSlotsPropsProxyHandlers: ProxyHandler = { 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 = { // 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()),