feat(runtime-vapor): component emits (#103)

Co-authored-by: 三咲智子 Kevin Deng <sxzz@sxzz.moe>
This commit is contained in:
ubugeeei 2024-02-04 22:18:57 +09:00 committed by GitHub
parent ea5f7ec076
commit cde91e4fb5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 661 additions and 4 deletions

View File

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

View File

@ -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 () => {})
})

View File

@ -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<string, boolean> | null
refs: Data
metadata: WeakMap<Node, ElementMetadata>
@ -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
}

View File

@ -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<infer V>
? (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)
)
}

View File

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