diff --git a/packages/runtime-vapor/__tests__/componentEmits.spec.ts b/packages/runtime-vapor/__tests__/componentEmits.spec.ts index 6094c5120..ab7fb04fb 100644 --- a/packages/runtime-vapor/__tests__/componentEmits.spec.ts +++ b/packages/runtime-vapor/__tests__/componentEmits.spec.ts @@ -3,81 +3,91 @@ // Note: emits and listener fallthrough is tested in // ./rendererAttrsFallthrough.spec.ts. -import { nextTick, onBeforeUnmount } from '../src' +import { toHandlers } from '@vue/runtime-core' +import { + createComponent, + defineComponent, + nextTick, + onBeforeUnmount, +} from '../src' import { isEmitListener } from '../src/componentEmits' import { makeRender } from './_utils' -const define = makeRender() +const define = makeRender() -describe.todo('component: emit', () => { +describe('component: emit', () => { test('trigger handlers', () => { const { render } = define({ - render() {}, - setup(_: any, { emit }: any) { + setup(_, { emit }) { emit('foo') emit('bar') emit('!baz') }, }) - const onfoo = vi.fn() + const onFoo = vi.fn() const onBar = vi.fn() const onBaz = vi.fn() render({ - get onfoo() { - return onfoo - }, - get onBar() { - return onBar - }, - get ['on!baz']() { - return onBaz - }, + onfoo: () => onFoo, + onBar: () => onBar, + ['on!baz']: () => onBaz, }) - expect(onfoo).not.toHaveBeenCalled() + expect(onFoo).not.toHaveBeenCalled() + expect(onBar).toHaveBeenCalled() + expect(onBaz).toHaveBeenCalled() + }) + + test('trigger dynamic emits', () => { + const { render } = define({ + setup(_, { emit }) { + emit('foo') + emit('bar') + emit('!baz') + }, + }) + const onFoo = vi.fn() + const onBar = vi.fn() + const onBaz = vi.fn() + render(() => ({ + onfoo: onFoo, + onBar, + ['on!baz']: onBaz, + })) + + expect(onFoo).not.toHaveBeenCalled() expect(onBar).toHaveBeenCalled() expect(onBaz).toHaveBeenCalled() }) test('trigger camelCase handler', () => { const { render } = define({ - render() {}, - setup(_: any, { emit }: any) { + setup(_, { emit }) { emit('test-event') }, }) const fooSpy = vi.fn() - render({ - get onTestEvent() { - return fooSpy - }, - }) + render({ onTestEvent: () => fooSpy }) expect(fooSpy).toHaveBeenCalled() }) test('trigger kebab-case handler', () => { const { render } = define({ - render() {}, - setup(_: any, { emit }: any) { + setup(_, { emit }) { emit('test-event') }, }) const fooSpy = vi.fn() - render({ - get ['onTest-event']() { - return fooSpy - }, - }) + render({ ['onTest-event']: () => fooSpy }) expect(fooSpy).toHaveBeenCalledTimes(1) }) // #3527 - test.todo('trigger mixed case handlers', () => { + test('trigger mixed case handlers', () => { const { render } = define({ - render() {}, - setup(_: any, { emit }: any) { + setup(_, { emit }) { emit('test-event') emit('testEvent') }, @@ -86,15 +96,10 @@ describe.todo('component: emit', () => { const fooSpy = vi.fn() const barSpy = vi.fn() render( - // TODO: impl `toHandlers` - { - get ['onTest-Event']() { - return fooSpy - }, - get onTestEvent() { - return barSpy - }, - }, + toHandlers({ + 'test-event': () => fooSpy, + testEvent: () => barSpy, + }), ) expect(fooSpy).toHaveBeenCalledTimes(1) expect(barSpy).toHaveBeenCalledTimes(1) @@ -103,8 +108,7 @@ describe.todo('component: emit', () => { // for v-model:foo-bar usage in DOM templates test('trigger hyphenated events for update:xxx events', () => { const { render } = define({ - render() {}, - setup(_: any, { emit }: any) { + setup(_, { emit }) { emit('update:fooProp') emit('update:barProp') }, @@ -113,12 +117,8 @@ describe.todo('component: emit', () => { const fooSpy = vi.fn() const barSpy = vi.fn() render({ - get ['onUpdate:fooProp']() { - return fooSpy - }, - get ['onUpdate:bar-prop']() { - return barSpy - }, + ['onUpdate:fooProp']: () => fooSpy, + ['onUpdate:bar-prop']: () => barSpy, }) expect(fooSpy).toHaveBeenCalled() @@ -127,8 +127,7 @@ describe.todo('component: emit', () => { test('should trigger array of listeners', async () => { const { render } = define({ - render() {}, - setup(_: any, { emit }: any) { + setup(_, { emit }) { emit('foo', 1) }, }) @@ -136,29 +135,49 @@ describe.todo('component: emit', () => { const fn1 = vi.fn() const fn2 = vi.fn() - render({ - onFoo: () => [fn1, fn2], - }) + render({ onFoo: () => [fn1, fn2] }) expect(fn1).toHaveBeenCalledTimes(1) expect(fn1).toHaveBeenCalledWith(1) expect(fn2).toHaveBeenCalledTimes(1) expect(fn2).toHaveBeenCalledWith(1) }) - test.todo('warning for undeclared event (array)', () => { - // TODO: warning + test('warning for undeclared event (array)', () => { + const { render } = define({ + emits: ['foo'], + + setup(_, { emit }) { + emit('bar') + }, + }) + render() + expect( + `Component emitted event "bar" but it is neither declared`, + ).toHaveBeenWarned() }) - test.todo('warning for undeclared event (object)', () => { - // TODO: warning + test('warning for undeclared event (object)', () => { + const { render } = define({ + emits: { + foo: null, + }, + + setup(_, { emit }) { + emit('bar') + }, + }) + render() + expect( + `Component emitted event "bar" but it is neither declared`, + ).toHaveBeenWarned() }) test('should not warn if has equivalent onXXX prop', () => { define({ props: ['onFoo'], emits: [], - render() {}, - setup(_: any, { emit }: any) { + + setup(_, { emit }) { emit('foo') }, }).render() @@ -182,12 +201,11 @@ describe.todo('component: emit', () => { test('.once', () => { const { render } = define({ - render() {}, emits: { foo: null, bar: null, }, - setup(_: any, { emit }: any) { + setup(_, { emit }) { emit('foo') emit('foo') emit('bar') @@ -197,12 +215,8 @@ describe.todo('component: emit', () => { const fn = vi.fn() const barFn = vi.fn() render({ - get onFooOnce() { - return fn - }, - get onBarOnce() { - return barFn - }, + onFooOnce: () => fn, + onBarOnce: () => barFn, }) expect(fn).toHaveBeenCalledTimes(1) expect(barFn).toHaveBeenCalledTimes(1) @@ -210,11 +224,10 @@ describe.todo('component: emit', () => { test('.once with normal listener of the same name', () => { const { render } = define({ - render() {}, emits: { foo: null, }, - setup(_: any, { emit }: any) { + setup(_, { emit }) { emit('foo') emit('foo') }, @@ -222,12 +235,8 @@ describe.todo('component: emit', () => { const onFoo = vi.fn() const onFooOnce = vi.fn() render({ - get onFoo() { - return onFoo - }, - get onFooOnce() { - return onFooOnce - }, + onFoo: () => onFoo, + onFooOnce: () => onFooOnce, }) expect(onFoo).toHaveBeenCalledTimes(2) expect(onFooOnce).toHaveBeenCalledTimes(1) @@ -235,8 +244,7 @@ describe.todo('component: emit', () => { test('.number modifier should work with v-model on component', () => { const { render } = define({ - render() {}, - setup(_: any, { emit }: any) { + setup(_, { emit }) { emit('update:modelValue', '1') emit('update:foo', '2') }, @@ -244,24 +252,12 @@ describe.todo('component: emit', () => { const fn1 = vi.fn() const fn2 = vi.fn() render({ - modelValue() { - return null - }, - modelModifiers() { - return { number: true } - }, - ['onUpdate:modelValue']() { - return fn1 - }, - foo() { - return null - }, - fooModifiers() { - return { number: true } - }, - ['onUpdate:foo']() { - return fn2 - }, + modelValue: () => null, + modelModifiers: () => ({ number: true }), + ['onUpdate:modelValue']: () => fn1, + foo: () => null, + fooModifiers: () => ({ number: true }), + ['onUpdate:foo']: () => fn2, }) expect(fn1).toHaveBeenCalledTimes(1) expect(fn1).toHaveBeenCalledWith(1) @@ -271,8 +267,7 @@ describe.todo('component: emit', () => { test('.trim modifier should work with v-model on component', () => { const { render } = define({ - render() {}, - setup(_: any, { emit }: any) { + setup(_, { emit }) { emit('update:modelValue', ' one ') emit('update:foo', ' two ') }, @@ -307,8 +302,7 @@ describe.todo('component: emit', () => { test('.trim and .number modifiers should work with v-model on component', () => { const { render } = define({ - render() {}, - setup(_: any, { emit }: any) { + setup(_, { emit }) { emit('update:modelValue', ' +01.2 ') emit('update:foo', ' 1 ') }, @@ -343,8 +337,7 @@ describe.todo('component: emit', () => { test('only trim string parameter when work with v-model on component', () => { const { render } = define({ - render() {}, - setup(_: any, { emit }: any) { + setup(_, { emit }) { emit('update:modelValue', ' foo ', { bar: ' bar ' }) }, }) @@ -395,20 +388,21 @@ describe.todo('component: emit', () => { test('does not emit after unmount', async () => { const fn = vi.fn() - const { app } = define({ + + const Foo = defineComponent({ emits: ['closing'], - setup(_: any, { emit }: any) { + setup(_, { emit }) { onBeforeUnmount(async () => { await nextTick() emit('closing', true) }) }, - render() {}, - }).render({ - get onClosing() { - return fn - }, }) + + const { app } = define(() => + createComponent(Foo, { onClosing: () => fn }), + ).render() + await nextTick() app.unmount() await nextTick() diff --git a/packages/runtime-vapor/src/apiCreateVaporApp.ts b/packages/runtime-vapor/src/apiCreateVaporApp.ts index 2d07bbba3..7a5392424 100644 --- a/packages/runtime-vapor/src/apiCreateVaporApp.ts +++ b/packages/runtime-vapor/src/apiCreateVaporApp.ts @@ -1,4 +1,4 @@ -import { isObject } from '@vue/shared' +import { isFunction, isObject } from '@vue/shared' import { type Component, type ComponentInternalInstance, @@ -14,8 +14,9 @@ export function createVaporApp( rootComponent: Component, rootProps: RawProps | null = null, ): App { - if (rootProps != null && !isObject(rootProps)) { - __DEV__ && warn(`root props passed to app.mount() must be an object.`) + if (rootProps != null && !isObject(rootProps) && !isFunction(rootProps)) { + __DEV__ && + warn(`root props passed to app.mount() must be an object or function.`) rootProps = null } diff --git a/packages/runtime-vapor/src/componentEmits.ts b/packages/runtime-vapor/src/componentEmits.ts index bbca2044c..0b9424eff 100644 --- a/packages/runtime-vapor/src/componentEmits.ts +++ b/packages/runtime-vapor/src/componentEmits.ts @@ -1,16 +1,11 @@ -// NOTE: runtime-core/src/componentEmits.ts - -// TODO WIP -// @ts-nocheck - import { - EMPTY_OBJ, type UnionToIntersection, camelize, extend, hasOwn, hyphenate, isArray, + isFunction, isOn, isString, looseToNumber, @@ -18,6 +13,8 @@ import { } from '@vue/shared' import type { Component, ComponentInternalInstance } from './component' import { VaporErrorCodes, callWithAsyncErrorHandling } from './errorHandling' +import { type StaticProps, getDynamicPropValue } from './componentProps' +import { warn } from './warning' export type ObjectEmitsOptions = Record< string, @@ -48,21 +45,73 @@ export function emit( ...rawArgs: any[] ) { if (instance.isUnmounted) return - // TODO - // @ts-expect-error - const { rawProps } = instance - let args = rawArgs + if (__DEV__) { + const { + emitsOptions, + propsOptions: [propsOptions], + } = instance + if (emitsOptions) { + if (!(event in emitsOptions)) { + if (!propsOptions || !(toHandlerKey(event) in propsOptions)) { + warn( + `Component emitted event "${event}" but it is neither declared in ` + + `the emits option nor as an "${toHandlerKey(event)}" prop.`, + ) + } + } else { + const validator = emitsOptions[event] + if (isFunction(validator)) { + const isValid = validator(...rawArgs) + if (!isValid) { + warn( + `Invalid event arguments: event validation failed for event "${event}".`, + ) + } + } + } + } + } + + const { rawProps } = instance + const hasDynamicProps = rawProps.some(isFunction) + + let handlerName: string + let handler: any + let onceHandler: any + const isModelListener = event.startsWith('update:') + const modelArg = isModelListener && event.slice(7) + let modifiers: any + + // has v-bind or :[eventName] + if (hasDynamicProps) { + tryGet(key => getDynamicPropValue(rawProps, key)[0]) + } else { + const staticProps = rawProps[0] as StaticProps + tryGet(key => staticProps[key] && staticProps[key]()) + } + + function tryGet(getter: (key: string) => any) { + handler = + getter((handlerName = toHandlerKey(event))) || + // also try camelCase event handler (#2249) + getter((handlerName = toHandlerKey(camelize(event)))) + // for v-model update:xxx events, also trigger kebab-case equivalent + // for props passed via kebab-case + if (!handler && isModelListener) { + handler = getter((handlerName = toHandlerKey(hyphenate(event)))) + } + onceHandler = getter(`${handlerName}Once`) + modifiers = + modelArg && + getter(`${modelArg === 'modelValue' ? 'model' : modelArg}Modifiers`) + } // for v-model update:xxx events, apply modifiers on args - const modelArg = isModelListener && event.slice(7) - - if (modelArg && modelArg in rawProps) { - const modifiersKey = `${ - modelArg === 'modelValue' ? 'model' : modelArg - }Modifiers` - const { number, trim } = rawProps[modifiersKey] || EMPTY_OBJ + let args = rawArgs + if (modifiers) { + const { number, trim } = modifiers if (trim) { args = rawArgs.map(a => (isString(a) ? a.trim() : a)) } @@ -73,17 +122,6 @@ export function emit( // TODO: warn - let handlerName - let handler = - rawProps[(handlerName = toHandlerKey(event))] || - // also try camelCase event handler (#2249) - rawProps[(handlerName = toHandlerKey(camelize(event)))] - // for v-model update:xxx events, also trigger kebab-case equivalent - // for props passed via kebab-case - if (!handler && isModelListener) { - handler = rawProps[(handlerName = toHandlerKey(hyphenate(event)))] - } - if (handler) { callWithAsyncErrorHandling( handler, @@ -93,14 +131,13 @@ export function emit( ) } - const onceHandler = rawProps[`${handlerName}Once`] if (onceHandler) { if (!instance.emitted) { instance.emitted = {} - } else if (instance.emitted[handlerName]) { + } else if (instance.emitted[handlerName!]) { return } - instance.emitted[handlerName] = true + instance.emitted[handlerName!] = true callWithAsyncErrorHandling( onceHandler, instance, @@ -116,8 +153,9 @@ export function normalizeEmitsOptions( // TODO: caching? const raw = comp.emits - let normalized: ObjectEmitsOptions = {} + if (!raw) return null + let normalized: ObjectEmitsOptions = {} if (isArray(raw)) { raw.forEach(key => (normalized[key] = null)) } else { diff --git a/packages/runtime-vapor/src/componentProps.ts b/packages/runtime-vapor/src/componentProps.ts index 4745b57bb..997b1e6c4 100644 --- a/packages/runtime-vapor/src/componentProps.ts +++ b/packages/runtime-vapor/src/componentProps.ts @@ -72,10 +72,10 @@ export type NormalizedPropsOptions = | [props: NormalizedProps, needCastKeys: string[]] | [] -type StaticProps = Record unknown> +export type StaticProps = Record unknown> type DynamicProps = () => Data export type NormalizedRawProps = Array -export type RawProps = NormalizedRawProps | StaticProps | null +export type RawProps = NormalizedRawProps | StaticProps | DynamicProps | null export function initProps( instance: ComponentInternalInstance, @@ -170,7 +170,7 @@ function getRawKey(obj: Data, key: string) { } type DynamicPropResult = [value: unknown, absent: boolean] -function getDynamicPropValue( +export function getDynamicPropValue( rawProps: NormalizedRawProps, key: string, ): DynamicPropResult {