fix(runtime-core): fix props/emits resolving with global mixins

fix #1975
This commit is contained in:
Evan You 2020-08-31 18:32:07 -04:00
parent 2bbeea9a51
commit 8ed0b342d4
8 changed files with 162 additions and 101 deletions

View File

@ -178,40 +178,13 @@ describe('component: emit', () => {
expect(fn).toHaveBeenCalledTimes(1) expect(fn).toHaveBeenCalledTimes(1)
}) })
describe('isEmitListener', () => { test('isEmitListener', () => {
test('array option', () => { const options = { click: null }
const def1 = { emits: ['click'] } expect(isEmitListener(options, 'onClick')).toBe(true)
expect(isEmitListener(def1, 'onClick')).toBe(true) expect(isEmitListener(options, 'onclick')).toBe(false)
expect(isEmitListener(def1, 'onclick')).toBe(false) expect(isEmitListener(options, 'onBlick')).toBe(false)
expect(isEmitListener(def1, 'onBlick')).toBe(false) // .once listeners
}) expect(isEmitListener(options, 'onClickOnce')).toBe(true)
expect(isEmitListener(options, 'onclickOnce')).toBe(false)
test('object option', () => {
const def2 = { emits: { click: null } }
expect(isEmitListener(def2, 'onClick')).toBe(true)
expect(isEmitListener(def2, 'onclick')).toBe(false)
expect(isEmitListener(def2, 'onBlick')).toBe(false)
})
test('with mixins and extends', () => {
const mixin1 = { emits: ['foo'] }
const mixin2 = { emits: ['bar'] }
const extend = { emits: ['baz'] }
const def3 = {
mixins: [mixin1, mixin2],
extends: extend
}
expect(isEmitListener(def3, 'onFoo')).toBe(true)
expect(isEmitListener(def3, 'onBar')).toBe(true)
expect(isEmitListener(def3, 'onBaz')).toBe(true)
expect(isEmitListener(def3, 'onclick')).toBe(false)
expect(isEmitListener(def3, 'onBlick')).toBe(false)
})
test('.once listeners', () => {
const def2 = { emits: { click: null } }
expect(isEmitListener(def2, 'onClickOnce')).toBe(true)
expect(isEmitListener(def2, 'onclickOnce')).toBe(false)
})
}) })
}) })

View File

@ -7,7 +7,8 @@ import {
FunctionalComponent, FunctionalComponent,
defineComponent, defineComponent,
ref, ref,
serializeInner serializeInner,
createApp
} from '@vue/runtime-test' } from '@vue/runtime-test'
import { render as domRender, nextTick } from 'vue' import { render as domRender, nextTick } from 'vue'
@ -309,4 +310,44 @@ describe('component props', () => {
expect(setupProps).toMatchObject(props) expect(setupProps).toMatchObject(props)
expect(renderProxy.$props).toMatchObject(props) expect(renderProxy.$props).toMatchObject(props)
}) })
test('merging props from global mixins', () => {
let setupProps: any
let renderProxy: any
const M1 = {
props: ['m1']
}
const M2 = {
props: { m2: null }
}
const Comp = {
props: ['self'],
setup(props: any) {
setupProps = props
},
render(this: any) {
renderProxy = this
return h('div', [this.self, this.m1, this.m2])
}
}
const props = {
self: 'from self, ',
m1: 'from mixin 1, ',
m2: 'from mixin 2'
}
const app = createApp(Comp, props)
app.mixin(M1)
app.mixin(M2)
const root = nodeOps.createElement('div')
app.mount(root)
expect(serializeInner(root)).toMatch(
`from self, from mixin 1, from mixin 2`
)
expect(setupProps).toMatchObject(props)
expect(renderProxy.$props).toMatchObject(props)
})
}) })

View File

@ -33,6 +33,7 @@ export interface App<HostElement = any> {
provide<T>(key: InjectionKey<T> | string, value: T): this provide<T>(key: InjectionKey<T> | string, value: T): this
// internal, but we need to expose these for the server-renderer and devtools // internal, but we need to expose these for the server-renderer and devtools
_uid: number
_component: ConcreteComponent _component: ConcreteComponent
_props: Data | null _props: Data | null
_container: HostElement | null _container: HostElement | null
@ -108,6 +109,8 @@ export type CreateAppFunction<HostElement> = (
rootProps?: Data | null rootProps?: Data | null
) => App<HostElement> ) => App<HostElement>
let uid = 0
export function createAppAPI<HostElement>( export function createAppAPI<HostElement>(
render: RootRenderFunction, render: RootRenderFunction,
hydrate?: RootHydrateFunction hydrate?: RootHydrateFunction
@ -124,6 +127,7 @@ export function createAppAPI<HostElement>(
let isMounted = false let isMounted = false
const app: App = (context.app = { const app: App = (context.app = {
_uid: uid++,
_component: rootComponent as ConcreteComponent, _component: rootComponent as ConcreteComponent,
_props: rootProps, _props: rootProps,
_container: null, _container: null,

View File

@ -18,7 +18,8 @@ import {
import { import {
ComponentPropsOptions, ComponentPropsOptions,
NormalizedPropsOptions, NormalizedPropsOptions,
initProps initProps,
normalizePropsOptions
} from './componentProps' } from './componentProps'
import { Slots, initSlots, InternalSlots } from './componentSlots' import { Slots, initSlots, InternalSlots } from './componentSlots'
import { warn } from './warning' import { warn } from './warning'
@ -30,7 +31,8 @@ import {
EmitsOptions, EmitsOptions,
ObjectEmitsOptions, ObjectEmitsOptions,
EmitFn, EmitFn,
emit emit,
normalizeEmitsOptions
} from './componentEmits' } from './componentEmits'
import { import {
EMPTY_OBJ, EMPTY_OBJ,
@ -72,11 +74,11 @@ export interface ComponentInternalOptions {
/** /**
* @internal * @internal
*/ */
__props?: NormalizedPropsOptions | [] __props?: Record<number, NormalizedPropsOptions>
/** /**
* @internal * @internal
*/ */
__emits?: ObjectEmitsOptions __emits?: Record<number, ObjectEmitsOptions | null>
/** /**
* @internal * @internal
*/ */
@ -231,6 +233,16 @@ export interface ComponentInternalInstance {
* @internal * @internal
*/ */
directives: Record<string, Directive> | null directives: Record<string, Directive> | null
/**
* reoslved props options
* @internal
*/
propsOptions: NormalizedPropsOptions
/**
* resolved emits options
* @internal
*/
emitsOptions: ObjectEmitsOptions | null
// the rest are only for stateful components --------------------------------- // the rest are only for stateful components ---------------------------------
@ -254,14 +266,17 @@ export interface ComponentInternalInstance {
*/ */
ctx: Data ctx: Data
// internal state // state
data: Data data: Data
props: Data props: Data
attrs: Data attrs: Data
slots: InternalSlots slots: InternalSlots
refs: Data refs: Data
emit: EmitFn emit: EmitFn
// used for keeping track of .once event handlers on components /**
* used for keeping track of .once event handlers on components
* @internal
*/
emitted: Record<string, boolean> | null emitted: Record<string, boolean> | null
/** /**
@ -387,6 +402,14 @@ export function createComponentInstance(
components: null, components: null,
directives: null, directives: null,
// resolved props and emits options
propsOptions: normalizePropsOptions(type, appContext),
emitsOptions: normalizeEmitsOptions(type, appContext),
// emit
emit: null as any, // to be set immediately
emitted: null,
// state // state
ctx: EMPTY_OBJ, ctx: EMPTY_OBJ,
data: EMPTY_OBJ, data: EMPTY_OBJ,
@ -419,9 +442,7 @@ export function createComponentInstance(
a: null, a: null,
rtg: null, rtg: null,
rtc: null, rtc: null,
ec: null, ec: null
emit: null as any, // to be set immediately
emitted: null
} }
if (__DEV__) { if (__DEV__) {
instance.ctx = createRenderContext(instance) instance.ctx = createRenderContext(instance)

View File

@ -8,12 +8,16 @@ import {
isFunction, isFunction,
extend extend
} from '@vue/shared' } from '@vue/shared'
import { ComponentInternalInstance, ConcreteComponent } from './component' import {
ComponentInternalInstance,
ComponentOptions,
ConcreteComponent
} from './component'
import { callWithAsyncErrorHandling, ErrorCodes } from './errorHandling' import { callWithAsyncErrorHandling, ErrorCodes } from './errorHandling'
import { warn } from './warning' import { warn } from './warning'
import { normalizePropsOptions } from './componentProps'
import { UnionToIntersection } from './helpers/typeUtils' import { UnionToIntersection } from './helpers/typeUtils'
import { devtoolsComponentEmit } from './devtools' import { devtoolsComponentEmit } from './devtools'
import { AppContext } from './apiCreateApp'
export type ObjectEmitsOptions = Record< export type ObjectEmitsOptions = Record<
string, string,
@ -44,10 +48,12 @@ export function emit(
const props = instance.vnode.props || EMPTY_OBJ const props = instance.vnode.props || EMPTY_OBJ
if (__DEV__) { if (__DEV__) {
const options = normalizeEmitsOptions(instance.type) const {
if (options) { emitsOptions,
if (!(event in options)) { propsOptions: [propsOptions]
const propsOptions = normalizePropsOptions(instance.type)[0] } = instance
if (emitsOptions) {
if (!(event in emitsOptions)) {
if (!propsOptions || !(`on` + capitalize(event) in propsOptions)) { if (!propsOptions || !(`on` + capitalize(event) in propsOptions)) {
warn( warn(
`Component emitted event "${event}" but it is neither declared in ` + `Component emitted event "${event}" but it is neither declared in ` +
@ -55,7 +61,7 @@ export function emit(
) )
} }
} else { } else {
const validator = options[event] const validator = emitsOptions[event]
if (isFunction(validator)) { if (isFunction(validator)) {
const isValid = validator(...args) const isValid = validator(...args)
if (!isValid) { if (!isValid) {
@ -98,11 +104,16 @@ export function emit(
} }
} }
function normalizeEmitsOptions( export function normalizeEmitsOptions(
comp: ConcreteComponent comp: ConcreteComponent,
): ObjectEmitsOptions | undefined { appContext: AppContext,
if (hasOwn(comp, '__emits')) { asMixin = false
return comp.__emits ): ObjectEmitsOptions | null {
const appId = appContext.app ? appContext.app._uid : -1
const cache = comp.__emits || (comp.__emits = {})
const cached = cache[appId]
if (cached !== undefined) {
return cached
} }
const raw = comp.emits const raw = comp.emits
@ -111,18 +122,23 @@ function normalizeEmitsOptions(
// apply mixin/extends props // apply mixin/extends props
let hasExtends = false let hasExtends = false
if (__FEATURE_OPTIONS_API__ && !isFunction(comp)) { if (__FEATURE_OPTIONS_API__ && !isFunction(comp)) {
if (comp.extends) { const extendEmits = (raw: ComponentOptions) => {
hasExtends = true hasExtends = true
extend(normalized, normalizeEmitsOptions(comp.extends)) extend(normalized, normalizeEmitsOptions(raw, appContext, true))
}
if (!asMixin && appContext.mixins.length) {
appContext.mixins.forEach(extendEmits)
}
if (comp.extends) {
extendEmits(comp.extends)
} }
if (comp.mixins) { if (comp.mixins) {
hasExtends = true comp.mixins.forEach(extendEmits)
comp.mixins.forEach(m => extend(normalized, normalizeEmitsOptions(m)))
} }
} }
if (!raw && !hasExtends) { if (!raw && !hasExtends) {
return (comp.__emits = undefined) return (cache[appId] = null)
} }
if (isArray(raw)) { if (isArray(raw)) {
@ -130,20 +146,22 @@ function normalizeEmitsOptions(
} else { } else {
extend(normalized, raw) extend(normalized, raw)
} }
return (comp.__emits = normalized) return (cache[appId] = normalized)
} }
// Check if an incoming prop key is a declared emit event listener. // Check if an incoming prop key is a declared emit event listener.
// e.g. With `emits: { click: null }`, props named `onClick` and `onclick` are // e.g. With `emits: { click: null }`, props named `onClick` and `onclick` are
// both considered matched listeners. // both considered matched listeners.
export function isEmitListener(comp: ConcreteComponent, key: string): boolean { export function isEmitListener(
let emits: ObjectEmitsOptions | undefined options: ObjectEmitsOptions | null,
if (!isOn(key) || !(emits = normalizeEmitsOptions(comp))) { key: string
): boolean {
if (!options || !isOn(key)) {
return false return false
} }
key = key.replace(/Once$/, '') key = key.replace(/Once$/, '')
return ( return (
hasOwn(emits, key[2].toLowerCase() + key.slice(3)) || hasOwn(options, key[2].toLowerCase() + key.slice(3)) ||
hasOwn(emits, key.slice(2)) hasOwn(options, key.slice(2))
) )
} }

View File

@ -42,11 +42,7 @@ import {
WritableComputedOptions, WritableComputedOptions,
toRaw toRaw
} from '@vue/reactivity' } from '@vue/reactivity'
import { import { ComponentObjectPropsOptions, ExtractPropTypes } from './componentProps'
ComponentObjectPropsOptions,
ExtractPropTypes,
normalizePropsOptions
} from './componentProps'
import { EmitsOptions } from './componentEmits' import { EmitsOptions } from './componentEmits'
import { Directive } from './directives' import { Directive } from './directives'
import { import {
@ -431,7 +427,7 @@ export function applyOptions(
const checkDuplicateProperties = __DEV__ ? createDuplicateChecker() : null const checkDuplicateProperties = __DEV__ ? createDuplicateChecker() : null
if (__DEV__) { if (__DEV__) {
const propsOptions = normalizePropsOptions(options)[0] const [propsOptions] = instance.propsOptions
if (propsOptions) { if (propsOptions) {
for (const key in propsOptions) { for (const key in propsOptions) {
checkDuplicateProperties!(OptionTypes.PROPS, key) checkDuplicateProperties!(OptionTypes.PROPS, key)

View File

@ -31,6 +31,7 @@ import {
} from './component' } from './component'
import { isEmitListener } from './componentEmits' import { isEmitListener } from './componentEmits'
import { InternalObjectKey } from './vnode' import { InternalObjectKey } from './vnode'
import { AppContext } from './apiCreateApp'
export type ComponentPropsOptions<P = Data> = export type ComponentPropsOptions<P = Data> =
| ComponentObjectPropsOptions<P> | ComponentObjectPropsOptions<P>
@ -107,7 +108,8 @@ type NormalizedProp =
// normalized value is a tuple of the actual normalized options // normalized value is a tuple of the actual normalized options
// and an array of prop keys that need value casting (booleans and defaults) // and an array of prop keys that need value casting (booleans and defaults)
export type NormalizedPropsOptions = [Record<string, NormalizedProp>, string[]] export type NormalizedProps = Record<string, NormalizedProp>
export type NormalizedPropsOptions = [NormalizedProps, string[]] | []
export function initProps( export function initProps(
instance: ComponentInternalInstance, instance: ComponentInternalInstance,
@ -121,7 +123,7 @@ export function initProps(
setFullProps(instance, rawProps, props, attrs) setFullProps(instance, rawProps, props, attrs)
// validation // validation
if (__DEV__) { if (__DEV__) {
validateProps(props, instance.type) validateProps(props, instance)
} }
if (isStateful) { if (isStateful) {
@ -151,7 +153,7 @@ export function updateProps(
vnode: { patchFlag } vnode: { patchFlag }
} = instance } = instance
const rawCurrentProps = toRaw(props) const rawCurrentProps = toRaw(props)
const [options] = normalizePropsOptions(instance.type) const [options] = instance.propsOptions
if ( if (
// always force full diff if hmr is enabled // always force full diff if hmr is enabled
@ -236,7 +238,7 @@ export function updateProps(
trigger(instance, TriggerOpTypes.SET, '$attrs') trigger(instance, TriggerOpTypes.SET, '$attrs')
if (__DEV__ && rawProps) { if (__DEV__ && rawProps) {
validateProps(props, instance.type) validateProps(props, instance)
} }
} }
@ -246,7 +248,7 @@ function setFullProps(
props: Data, props: Data,
attrs: Data attrs: Data
) { ) {
const [options, needCastKeys] = normalizePropsOptions(instance.type) const [options, needCastKeys] = instance.propsOptions
if (rawProps) { if (rawProps) {
for (const key in rawProps) { for (const key in rawProps) {
const value = rawProps[key] const value = rawProps[key]
@ -259,7 +261,7 @@ function setFullProps(
let camelKey let camelKey
if (options && hasOwn(options, (camelKey = camelize(key)))) { if (options && hasOwn(options, (camelKey = camelize(key)))) {
props[camelKey] = value props[camelKey] = value
} else if (!isEmitListener(instance.type, key)) { } else if (!isEmitListener(instance.emitsOptions, key)) {
// Any non-declared (either as a prop or an emitted event) props are put // Any non-declared (either as a prop or an emitted event) props are put
// into a separate `attrs` object for spreading. Make sure to preserve // into a separate `attrs` object for spreading. Make sure to preserve
// original key casing // original key casing
@ -283,7 +285,7 @@ function setFullProps(
} }
function resolvePropValue( function resolvePropValue(
options: NormalizedPropsOptions[0], options: NormalizedProps,
props: Data, props: Data,
key: string, key: string,
value: unknown value: unknown
@ -315,10 +317,15 @@ function resolvePropValue(
} }
export function normalizePropsOptions( export function normalizePropsOptions(
comp: ConcreteComponent comp: ConcreteComponent,
): NormalizedPropsOptions | [] { appContext: AppContext,
if (comp.__props) { asMixin = false
return comp.__props ): NormalizedPropsOptions {
const appId = appContext.app ? appContext.app._uid : -1
const cache = comp.__props || (comp.__props = {})
const cached = cache[appId]
if (cached) {
return cached
} }
const raw = comp.props const raw = comp.props
@ -329,22 +336,24 @@ export function normalizePropsOptions(
let hasExtends = false let hasExtends = false
if (__FEATURE_OPTIONS_API__ && !isFunction(comp)) { if (__FEATURE_OPTIONS_API__ && !isFunction(comp)) {
const extendProps = (raw: ComponentOptions) => { const extendProps = (raw: ComponentOptions) => {
const [props, keys] = normalizePropsOptions(raw) hasExtends = true
const [props, keys] = normalizePropsOptions(raw, appContext, true)
extend(normalized, props) extend(normalized, props)
if (keys) needCastKeys.push(...keys) if (keys) needCastKeys.push(...keys)
} }
if (!asMixin && appContext.mixins.length) {
appContext.mixins.forEach(extendProps)
}
if (comp.extends) { if (comp.extends) {
hasExtends = true
extendProps(comp.extends) extendProps(comp.extends)
} }
if (comp.mixins) { if (comp.mixins) {
hasExtends = true
comp.mixins.forEach(extendProps) comp.mixins.forEach(extendProps)
} }
} }
if (!raw && !hasExtends) { if (!raw && !hasExtends) {
return (comp.__props = EMPTY_ARR) return (cache[appId] = EMPTY_ARR)
} }
if (isArray(raw)) { if (isArray(raw)) {
@ -381,9 +390,8 @@ export function normalizePropsOptions(
} }
} }
} }
const normalizedEntry: NormalizedPropsOptions = [normalized, needCastKeys]
comp.__props = normalizedEntry return (cache[appId] = [normalized, needCastKeys])
return normalizedEntry
} }
// use function string name to check type constructors // use function string name to check type constructors
@ -416,9 +424,9 @@ function getTypeIndex(
/** /**
* dev only * dev only
*/ */
function validateProps(props: Data, comp: ConcreteComponent) { function validateProps(props: Data, instance: ComponentInternalInstance) {
const rawValues = toRaw(props) const rawValues = toRaw(props)
const options = normalizePropsOptions(comp)[0] const options = instance.propsOptions[0]
for (const key in options) { for (const key in options) {
let opt = options[key] let opt = options[key]
if (opt == null) continue if (opt == null) continue

View File

@ -29,7 +29,6 @@ import {
resolveMergedOptions, resolveMergedOptions,
isInBeforeCreate isInBeforeCreate
} from './componentOptions' } from './componentOptions'
import { normalizePropsOptions } from './componentProps'
import { EmitsOptions, EmitFn } from './componentEmits' import { EmitsOptions, EmitFn } from './componentEmits'
import { Slots } from './componentSlots' import { Slots } from './componentSlots'
import { import {
@ -250,7 +249,7 @@ export const PublicInstanceProxyHandlers: ProxyHandler<any> = {
} else if ( } else if (
// only cache other properties when instance has declared (thus stable) // only cache other properties when instance has declared (thus stable)
// props // props
(normalizedProps = normalizePropsOptions(type)[0]) && (normalizedProps = instance.propsOptions[0]) &&
hasOwn(normalizedProps, key) hasOwn(normalizedProps, key)
) { ) {
accessCache![key] = AccessTypes.PROPS accessCache![key] = AccessTypes.PROPS
@ -354,7 +353,7 @@ export const PublicInstanceProxyHandlers: ProxyHandler<any> = {
has( has(
{ {
_: { data, setupState, accessCache, ctx, type, appContext } _: { data, setupState, accessCache, ctx, appContext, propsOptions }
}: ComponentRenderContext, }: ComponentRenderContext,
key: string key: string
) { ) {
@ -363,8 +362,7 @@ export const PublicInstanceProxyHandlers: ProxyHandler<any> = {
accessCache![key] !== undefined || accessCache![key] !== undefined ||
(data !== EMPTY_OBJ && hasOwn(data, key)) || (data !== EMPTY_OBJ && hasOwn(data, key)) ||
(setupState !== EMPTY_OBJ && hasOwn(setupState, key)) || (setupState !== EMPTY_OBJ && hasOwn(setupState, key)) ||
((normalizedProps = normalizePropsOptions(type)[0]) && ((normalizedProps = propsOptions[0]) && hasOwn(normalizedProps, key)) ||
hasOwn(normalizedProps, key)) ||
hasOwn(ctx, key) || hasOwn(ctx, key) ||
hasOwn(publicPropertiesMap, key) || hasOwn(publicPropertiesMap, key) ||
hasOwn(appContext.config.globalProperties, key) hasOwn(appContext.config.globalProperties, key)
@ -450,8 +448,10 @@ export function createRenderContext(instance: ComponentInternalInstance) {
export function exposePropsOnRenderContext( export function exposePropsOnRenderContext(
instance: ComponentInternalInstance instance: ComponentInternalInstance
) { ) {
const { ctx, type } = instance const {
const propsOptions = normalizePropsOptions(type)[0] ctx,
propsOptions: [propsOptions]
} = instance
if (propsOptions) { if (propsOptions) {
Object.keys(propsOptions).forEach(key => { Object.keys(propsOptions).forEach(key => {
Object.defineProperty(ctx, key, { Object.defineProperty(ctx, key, {