diff --git a/README.md b/README.md index 07b3289e2..cd987d061 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,7 @@ The code provided here is a duplicate from `runtime-core` as Vapor cannot import - packages/runtime-vapor/src/apiWatch.ts - packages/runtime-vapor/src/component.ts +- packages/runtime-vapor/src/componentEmits.ts - packages/runtime-vapor/src/componentProps.ts - packages/runtime-vapor/src/enums.ts - packages/runtime-vapor/src/errorHandling.ts diff --git a/packages/runtime-vapor/__tests__/componentEmits.spec.ts b/packages/runtime-vapor/__tests__/componentEmits.spec.ts new file mode 100644 index 000000000..af301df95 --- /dev/null +++ b/packages/runtime-vapor/__tests__/componentEmits.spec.ts @@ -0,0 +1,488 @@ +// NOTE: this test cases are based on paclages/runtime-core/__tests__/componentEmits.spec.ts + +// Note: emits and listener fallthrough is tested in +// ./rendererAttrsFallthrough.spec.ts. + +import { + defineComponent, + nextTick, + onBeforeUnmount, + render, + unmountComponent, +} from '../src' +import { isEmitListener } from '../src/componentEmits' + +let host: HTMLElement + +const initHost = () => { + host = document.createElement('div') + host.setAttribute('id', 'host') + document.body.appendChild(host) +} +beforeEach(() => initHost()) +afterEach(() => host.remove()) + +describe('component: emit', () => { + test('trigger handlers', () => { + const Foo = defineComponent({ + render() {}, + setup(_: any, { emit }: any) { + emit('foo') + emit('bar') + emit('!baz') + }, + }) + const onfoo = vi.fn() + const onBar = vi.fn() + const onBaz = vi.fn() + render( + Foo, + { + get onfoo() { + return onfoo + }, + get onBar() { + return onBar + }, + get ['on!baz']() { + return onBaz + }, + }, + '#host', + ) + + expect(onfoo).not.toHaveBeenCalled() + expect(onBar).toHaveBeenCalled() + expect(onBaz).toHaveBeenCalled() + }) + + test('trigger camelCase handler', () => { + const Foo = defineComponent({ + render() {}, + setup(_: any, { emit }: any) { + emit('test-event') + }, + }) + + const fooSpy = vi.fn() + render( + Foo, + { + get onTestEvent() { + return fooSpy + }, + }, + '#host', + ) + expect(fooSpy).toHaveBeenCalled() + }) + + test('trigger kebab-case handler', () => { + const Foo = defineComponent({ + render() {}, + setup(_: any, { emit }: any) { + emit('test-event') + }, + }) + + const fooSpy = vi.fn() + render( + Foo, + { + get ['onTest-event']() { + return fooSpy + }, + }, + '#host', + ) + expect(fooSpy).toHaveBeenCalledTimes(1) + }) + + // #3527 + test.todo('trigger mixed case handlers', () => { + const Foo = defineComponent({ + render() {}, + setup(_: any, { emit }: any) { + emit('test-event') + emit('testEvent') + }, + }) + + const fooSpy = vi.fn() + const barSpy = vi.fn() + render( + Foo, + // TODO: impl `toHandlers` + { + get ['onTest-Event']() { + return fooSpy + }, + get onTestEvent() { + return barSpy + }, + }, + '#host', + ) + expect(fooSpy).toHaveBeenCalledTimes(1) + expect(barSpy).toHaveBeenCalledTimes(1) + }) + + // for v-model:foo-bar usage in DOM templates + test('trigger hyphenated events for update:xxx events', () => { + const Foo = defineComponent({ + render() {}, + setup(_: any, { emit }: any) { + emit('update:fooProp') + emit('update:barProp') + }, + }) + + const fooSpy = vi.fn() + const barSpy = vi.fn() + render( + Foo, + { + get ['onUpdate:fooProp']() { + return fooSpy + }, + get ['onUpdate:bar-prop']() { + return barSpy + }, + }, + '#host', + ) + + expect(fooSpy).toHaveBeenCalled() + expect(barSpy).toHaveBeenCalled() + }) + + test('should trigger array of listeners', async () => { + const App = defineComponent({ + render() {}, + setup(_: any, { emit }: any) { + emit('foo', 1) + }, + }) + + const fn1 = vi.fn() + const fn2 = vi.fn() + + render( + App, + { + get onFoo() { + return [fn1, fn2] + }, + }, + '#host', + ) + 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.todo('warning for undeclared event (object)', () => { + // TODO: warning + }) + + test('should not warn if has equivalent onXXX prop', () => { + const Foo = defineComponent({ + props: ['onFoo'], + emits: [], + render() {}, + setup(_: any, { emit }: any) { + emit('foo') + }, + }) + render(Foo, {}, '#host') + expect( + `Component emitted event "foo" but it is neither declared`, + ).not.toHaveBeenWarned() + }) + + test.todo('validator warning', () => { + // TODO: warning validator + }) + + // NOTE: not supported mixins + // test.todo('merging from mixins', () => {}) + + // #2651 + // test.todo( + // 'should not attach normalized object when mixins do not contain emits', + // () => {}, + // ) + + test('.once', () => { + const Foo = defineComponent({ + render() {}, + emits: { + foo: null, + bar: null, + }, + setup(_: any, { emit }: any) { + emit('foo') + emit('foo') + emit('bar') + emit('bar') + }, + }) + const fn = vi.fn() + const barFn = vi.fn() + render( + Foo, + { + get onFooOnce() { + return fn + }, + get onBarOnce() { + return barFn + }, + }, + '#host', + ) + expect(fn).toHaveBeenCalledTimes(1) + expect(barFn).toHaveBeenCalledTimes(1) + }) + + test('.once with normal listener of the same name', () => { + const Foo = defineComponent({ + render() {}, + emits: { + foo: null, + }, + setup(_: any, { emit }: any) { + emit('foo') + emit('foo') + }, + }) + const onFoo = vi.fn() + const onFooOnce = vi.fn() + render( + Foo, + { + get onFoo() { + return onFoo + }, + get onFooOnce() { + return onFooOnce + }, + }, + '#host', + ) + expect(onFoo).toHaveBeenCalledTimes(2) + expect(onFooOnce).toHaveBeenCalledTimes(1) + }) + + test('.number modifier should work with v-model on component', () => { + const Foo = defineComponent({ + render() {}, + setup(_: any, { emit }: any) { + emit('update:modelValue', '1') + emit('update:foo', '2') + }, + }) + const fn1 = vi.fn() + const fn2 = vi.fn() + render( + Foo, + { + get modelValue() { + return null + }, + get modelModifiers() { + return { number: true } + }, + get ['onUpdate:modelValue']() { + return fn1 + }, + get foo() { + return null + }, + get fooModifiers() { + return { number: true } + }, + get ['onUpdate:foo']() { + return fn2 + }, + }, + '#host', + ) + expect(fn1).toHaveBeenCalledTimes(1) + expect(fn1).toHaveBeenCalledWith(1) + expect(fn2).toHaveBeenCalledTimes(1) + expect(fn2).toHaveBeenCalledWith(2) + }) + + test('.trim modifier should work with v-model on component', () => { + const Foo = defineComponent({ + render() {}, + setup(_: any, { emit }: any) { + emit('update:modelValue', ' one ') + emit('update:foo', ' two ') + }, + }) + const fn1 = vi.fn() + const fn2 = vi.fn() + render( + Foo, + { + get modelValue() { + return null + }, + get modelModifiers() { + return { trim: true } + }, + get ['onUpdate:modelValue']() { + return fn1 + }, + get foo() { + return null + }, + get fooModifiers() { + return { trim: true } + }, + get 'onUpdate:foo'() { + return fn2 + }, + }, + '#host', + ) + expect(fn1).toHaveBeenCalledTimes(1) + expect(fn1).toHaveBeenCalledWith('one') + expect(fn2).toHaveBeenCalledTimes(1) + expect(fn2).toHaveBeenCalledWith('two') + }) + + test('.trim and .number modifiers should work with v-model on component', () => { + const Foo = defineComponent({ + render() {}, + setup(_: any, { emit }: any) { + emit('update:modelValue', ' +01.2 ') + emit('update:foo', ' 1 ') + }, + }) + const fn1 = vi.fn() + const fn2 = vi.fn() + render( + Foo, + { + get modelValue() { + return null + }, + get modelModifiers() { + return { trim: true, number: true } + }, + get ['onUpdate:modelValue']() { + return fn1 + }, + get foo() { + return null + }, + get fooModifiers() { + return { trim: true, number: true } + }, + get ['onUpdate:foo']() { + return fn2 + }, + }, + '#host', + ) + expect(fn1).toHaveBeenCalledTimes(1) + expect(fn1).toHaveBeenCalledWith(1.2) + expect(fn2).toHaveBeenCalledTimes(1) + expect(fn2).toHaveBeenCalledWith(1) + }) + + test('only trim string parameter when work with v-model on component', () => { + const Foo = defineComponent({ + render() {}, + setup(_: any, { emit }: any) { + emit('update:modelValue', ' foo ', { bar: ' bar ' }) + }, + }) + const fn = vi.fn() + render( + Foo, + { + get modelValue() { + return null + }, + get modelModifiers() { + return { trim: true } + }, + get ['onUpdate:modelValue']() { + return fn + }, + }, + '#host', + ) + expect(fn).toHaveBeenCalledTimes(1) + expect(fn).toHaveBeenCalledWith('foo', { bar: ' bar ' }) + }) + + test('isEmitListener', () => { + const options = { + get click() { + return null + }, + get 'test-event'() { + return null + }, + get fooBar() { + return null + }, + get FooBaz() { + return null + }, + } + expect(isEmitListener(options, 'onClick')).toBe(true) + expect(isEmitListener(options, 'onclick')).toBe(false) + expect(isEmitListener(options, 'onBlick')).toBe(false) + // .once listeners + expect(isEmitListener(options, 'onClickOnce')).toBe(true) + expect(isEmitListener(options, 'onclickOnce')).toBe(false) + // kebab-case option + expect(isEmitListener(options, 'onTestEvent')).toBe(true) + // camelCase option + expect(isEmitListener(options, 'onFooBar')).toBe(true) + // PascalCase option + expect(isEmitListener(options, 'onFooBaz')).toBe(true) + }) + + test('does not emit after unmount', async () => { + const fn = vi.fn() + const Foo = defineComponent({ + emits: ['closing'], + setup(_: any, { emit }: any) { + onBeforeUnmount(async () => { + await nextTick() + emit('closing', true) + }) + }, + render() {}, + }) + const i = render( + Foo, + { + get onClosing() { + return fn + }, + }, + '#host', + ) + await nextTick() + unmountComponent(i) + await nextTick() + expect(fn).not.toHaveBeenCalled() + }) + + // NOTE: not supported mixins + // test.todo('merge string array emits', async () => {}) + // test.todo('merge object emits', async () => {}) +}) diff --git a/packages/runtime-vapor/src/component.ts b/packages/runtime-vapor/src/component.ts index 7ed267668..570834eac 100644 --- a/packages/runtime-vapor/src/component.ts +++ b/packages/runtime-vapor/src/component.ts @@ -8,6 +8,13 @@ import { type NormalizedPropsOptions, normalizePropsOptions, } from './componentProps' +import { + type EmitFn, + type EmitsOptions, + type ObjectEmitsOptions, + emit, + normalizeEmitsOptions, +} from './componentEmits' import type { Data } from '@vue/shared' import { VaporLifecycleHooks } from './enums' @@ -17,10 +24,12 @@ export type Component = FunctionalComponent | ObjectComponent export type SetupFn = (props: any, ctx: any) => Block | Data export type FunctionalComponent = SetupFn & { props: ComponentPropsOptions + emits: EmitsOptions render(ctx: any): Block } export interface ObjectComponent { props: ComponentPropsOptions + emits: EmitsOptions setup?: SetupFn render(ctx: any): Block } @@ -37,13 +46,21 @@ export interface ComponentInternalInstance { block: Block | null scope: EffectScope component: FunctionalComponent | ObjectComponent + + // TODO: ExtraProps: key, ref, ... + rawProps: { [key: string]: any } + + // normalized options propsOptions: NormalizedPropsOptions + emitsOptions: ObjectEmitsOptions | null parent: ComponentInternalInstance | null // state props: Data setupState: Data + emit: EmitFn + emitted: Record | null refs: Data metadata: WeakMap @@ -139,6 +156,7 @@ export const unsetCurrentInstance = () => { let uid = 0 export const createComponentInstance = ( component: ObjectComponent | FunctionalComponent, + rawProps: Data, ): ComponentInternalInstance => { const instance: ComponentInternalInstance = { uid: uid++, @@ -146,13 +164,18 @@ export const createComponentInstance = ( container: null!, // set on mountComponent scope: new EffectScope(true /* detached */)!, component, + rawProps, // TODO: registory of parent parent: null, // resolved props and emits options propsOptions: normalizePropsOptions(component), - // emitsOptions: normalizeEmitsOptions(type, appContext), // TODO: + emitsOptions: normalizeEmitsOptions(component), + + // emit + emit: null!, // to be set immediately + emitted: null, // state props: EMPTY_OBJ, @@ -225,5 +248,8 @@ export const createComponentInstance = ( */ // [VaporLifecycleHooks.SERVER_PREFETCH]: null, } + + instance.emit = emit.bind(null, instance) + return instance } diff --git a/packages/runtime-vapor/src/componentEmits.ts b/packages/runtime-vapor/src/componentEmits.ts new file mode 100644 index 000000000..138097c81 --- /dev/null +++ b/packages/runtime-vapor/src/componentEmits.ts @@ -0,0 +1,142 @@ +// NOTE: runtime-core/src/componentEmits.ts + +import { + EMPTY_OBJ, + type UnionToIntersection, + camelize, + extend, + hasOwn, + hyphenate, + isArray, + isOn, + isString, + looseToNumber, + toHandlerKey, +} from '@vue/shared' +import type { Component, ComponentInternalInstance } from './component' +import { VaporErrorCodes, callWithAsyncErrorHandling } from './errorHandling' + +export type ObjectEmitsOptions = Record< + string, + ((...args: any[]) => any) | null // TODO: call validation? +> + +export type EmitsOptions = ObjectEmitsOptions | string[] + +export type EmitFn< + Options = ObjectEmitsOptions, + Event extends keyof Options = keyof Options, +> = + Options extends Array + ? (event: V, ...args: any[]) => void + : {} extends Options // if the emit is empty object (usually the default value for emit) should be converted to function + ? (event: string, ...args: any[]) => void + : UnionToIntersection< + { + [key in Event]: Options[key] extends (...args: infer Args) => any + ? (event: key, ...args: Args) => void + : (event: key, ...args: any[]) => void + }[Event] + > + +export function emit( + instance: ComponentInternalInstance, + event: string, + ...rawArgs: any[] +) { + if (instance.isUnmounted) return + const { rawProps } = instance + + let args = rawArgs + const isModelListener = event.startsWith('update:') + + // 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 + if (trim) { + args = rawArgs.map(a => (isString(a) ? a.trim() : a)) + } + if (number) { + args = rawArgs.map(looseToNumber) + } + } + + // 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, + instance, + VaporErrorCodes.COMPONENT_EVENT_HANDLER, + args, + ) + } + + const onceHandler = rawProps[`${handlerName}Once`] + if (onceHandler) { + if (!instance.emitted) { + instance.emitted = {} + } else if (instance.emitted[handlerName]) { + return + } + instance.emitted[handlerName] = true + callWithAsyncErrorHandling( + onceHandler, + instance, + VaporErrorCodes.COMPONENT_EVENT_HANDLER, + args, + ) + } +} + +export function normalizeEmitsOptions( + comp: Component, +): ObjectEmitsOptions | null { + // TODO: caching? + + const raw = comp.emits + let normalized: ObjectEmitsOptions = {} + + if (isArray(raw)) { + raw.forEach(key => (normalized[key] = null)) + } else { + extend(normalized, raw) + } + + return normalized +} + +// Check if an incoming prop key is a declared emit event listener. +// e.g. With `emits: { click: null }`, props named `onClick` and `onclick` are +// both considered matched listeners. +export function isEmitListener( + options: ObjectEmitsOptions | null, + key: string, +): boolean { + if (!options || !isOn(key)) { + return false + } + + key = key.slice(2).replace(/Once$/, '') + return ( + hasOwn(options, key[0].toLowerCase() + key.slice(1)) || + hasOwn(options, hyphenate(key)) || + hasOwn(options, key) + ) +} diff --git a/packages/runtime-vapor/src/render.ts b/packages/runtime-vapor/src/render.ts index 9beb919f2..acda15cf3 100644 --- a/packages/runtime-vapor/src/render.ts +++ b/packages/runtime-vapor/src/render.ts @@ -27,7 +27,7 @@ export function render( props: Data, container: string | ParentNode, ): ComponentInternalInstance { - const instance = createComponentInstance(comp) + const instance = createComponentInstance(comp, props) initProps(instance, props) return mountComponent(instance, (container = normalizeContainer(container))) } @@ -46,8 +46,8 @@ export function mountComponent( const reset = setCurrentInstance(instance) const block = instance.scope.run(() => { - const { component, props } = instance - const ctx = { expose: () => {} } + const { component, props, emit } = instance + const ctx = { expose: () => {}, emit } const setupFn = typeof component === 'function' ? component : component.setup