From: Evan You Date: Tue, 27 Apr 2021 21:34:19 +0000 (-0400) Subject: wip: tests for global api compat X-Git-Tag: v3.1.0-beta.1~59^2~3 X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=1d1af403ca5af14a7c722e8a07d7aa46688bffa9;p=thirdparty%2Fvuejs%2Fcore.git wip: tests for global api compat --- diff --git a/packages/runtime-core/src/compat/__tests__/global.spec.ts b/packages/runtime-core/src/compat/__tests__/global.spec.ts index 9bd042577f..1ce38e4166 100644 --- a/packages/runtime-core/src/compat/__tests__/global.spec.ts +++ b/packages/runtime-core/src/compat/__tests__/global.spec.ts @@ -1,21 +1,335 @@ import Vue from '@vue/compat' +import { effect, isReactive } from '@vue/reactivity' +import { + DeprecationTypes, + deprecationData, + toggleDeprecationWarning +} from '../compatConfig' -describe('compat: global API', () => { - beforeEach(() => Vue.configureCompat({ MODE: 2 })) - afterEach(() => Vue.configureCompat({ MODE: 3 })) +beforeEach(() => { + Vue.configureCompat({ MODE: 2 }) +}) + +afterEach(() => { + Vue.configureCompat({ MODE: 3 }) + toggleDeprecationWarning(false) +}) + +describe('GLOBAL_MOUNT', () => { + test('new Vue() with el', () => { + toggleDeprecationWarning(true) - test('should work', () => { const el = document.createElement('div') el.innerHTML = `{{ msg }}` new Vue({ el, + compatConfig: { GLOBAL_MOUNT: true }, data() { return { msg: 'hello' } } }) - expect('global app bootstrapping API has changed').toHaveBeenWarned() + expect( + deprecationData[DeprecationTypes.GLOBAL_MOUNT].message + ).toHaveBeenWarned() expect(el.innerHTML).toBe('hello') }) + + test('new Vue() + $mount', () => { + const el = document.createElement('div') + el.innerHTML = `{{ msg }}` + new Vue({ + data() { + return { + msg: 'hello' + } + } + }).$mount(el) + expect(el.innerHTML).toBe('hello') + }) +}) + +describe('GLOBAL_MOUNT_CONTAINER', () => { + test('should warn', () => { + toggleDeprecationWarning(true) + + const el = document.createElement('div') + el.innerHTML = `test` + el.setAttribute('v-bind:id', 'foo') + new Vue().$mount(el) + // warning only + expect( + deprecationData[DeprecationTypes.GLOBAL_MOUNT].message + ).toHaveBeenWarned() + expect( + deprecationData[DeprecationTypes.GLOBAL_MOUNT_CONTAINER].message + ).toHaveBeenWarned() + }) +}) + +describe('GLOBAL_EXTEND', () => { + // https://github.com/vuejs/vue/blob/dev/test/unit/features/global-api/extend.spec.js + it('should correctly merge options', () => { + toggleDeprecationWarning(true) + + const Test = Vue.extend({ + name: 'test', + a: 1, + b: 2 + }) + expect(Test.options.a).toBe(1) + expect(Test.options.b).toBe(2) + expect(Test.super).toBe(Vue) + const t = new Test({ + a: 2 + }) + expect(t.$options.a).toBe(2) + expect(t.$options.b).toBe(2) + // inheritance + const Test2 = Test.extend({ + a: 2 + }) + expect(Test2.options.a).toBe(2) + expect(Test2.options.b).toBe(2) + const t2 = new Test2({ + a: 3 + }) + expect(t2.$options.a).toBe(3) + expect(t2.$options.b).toBe(2) + + expect( + deprecationData[DeprecationTypes.GLOBAL_MOUNT].message + ).toHaveBeenWarned() + expect( + deprecationData[DeprecationTypes.GLOBAL_EXTEND].message + ).toHaveBeenWarned() + }) + + it('should work when used as components', () => { + const foo = Vue.extend({ + template: 'foo' + }) + const bar = Vue.extend({ + template: 'bar' + }) + const vm = new Vue({ + template: '
', + components: { foo, bar } + }).$mount() + expect(vm.$el.innerHTML).toBe('foobar') + }) + + it('should merge lifecycle hooks', () => { + const calls: number[] = [] + const A = Vue.extend({ + created() { + calls.push(1) + } + }) + const B = A.extend({ + created() { + calls.push(2) + } + }) + new B({ + created() { + calls.push(3) + } + }) + expect(calls).toEqual([1, 2, 3]) + }) + + it('should not merge nested mixins created with Vue.extend', () => { + const A = Vue.extend({ + created: () => {} + }) + const B = Vue.extend({ + mixins: [A], + created: () => {} + }) + const C = Vue.extend({ + extends: B, + created: () => {} + }) + const D = Vue.extend({ + mixins: [C], + created: () => {} + }) + expect(D.options.created!.length).toBe(4) + }) + + it('should merge methods', () => { + const A = Vue.extend({ + methods: { + a() { + return this.n + } + } + }) + const B = A.extend({ + methods: { + b() { + return this.n + 1 + } + } + }) + const b = new B({ + data: () => ({ n: 0 }), + methods: { + c() { + return this.n + 2 + } + } + }) as any + expect(b.a()).toBe(0) + expect(b.b()).toBe(1) + expect(b.c()).toBe(2) + }) + + it('should merge assets', () => { + const A = Vue.extend({ + components: { + aa: { + template: '
A
' + } + } + }) + const B = A.extend({ + components: { + bb: { + template: '
B
' + } + } + }) + const b = new B({ + template: '
' + }).$mount() + expect(b.$el.innerHTML).toBe('
A
B
') + }) + + it('caching', () => { + const options = { + template: '
' + } + const A = Vue.extend(options) + const B = Vue.extend(options) + expect(A).toBe(B) + }) + + it('extended options should use different identify from parent', () => { + const A = Vue.extend({ computed: {} }) + const B = A.extend() + B.options.computed.b = () => 'foo' + expect(B.options.computed).not.toBe(A.options.computed) + expect(A.options.computed.b).toBeUndefined() + }) +}) + +describe('GLOBAL_PROTOTYPE', () => { + test('plain properties', () => { + toggleDeprecationWarning(true) + Vue.prototype.$test = 1 + const vm = new Vue() as any + expect(vm.$test).toBe(1) + delete Vue.prototype.$test + expect( + deprecationData[DeprecationTypes.GLOBAL_MOUNT].message + ).toHaveBeenWarned() + expect( + deprecationData[DeprecationTypes.GLOBAL_PROTOTYPE].message + ).toHaveBeenWarned() + }) + + test('method this context', () => { + Vue.prototype.$test = function() { + return this.msg + } + const vm = new Vue({ + data() { + return { msg: 'method' } + } + }) as any + expect(vm.$test()).toBe('method') + delete Vue.prototype.$test + }) + + test('defined properties', () => { + Object.defineProperty(Vue.prototype, '$test', { + configurable: true, + get() { + return this.msg + } + }) + const vm = new Vue({ + data() { + return { msg: 'getter' } + } + }) as any + expect(vm.$test).toBe('getter') + delete Vue.prototype.$test + }) + + test('extended prototype', async () => { + const Foo = Vue.extend() + Foo.prototype.$test = 1 + const vm = new Foo() as any + expect(vm.$test).toBe(1) + const plain = new Vue() as any + expect(plain.$test).toBeUndefined() + }) +}) + +describe('GLOBAL_SET/DELETE', () => { + test('set', () => { + toggleDeprecationWarning(true) + const obj: any = {} + Vue.set(obj, 'foo', 1) + expect(obj.foo).toBe(1) + expect( + deprecationData[DeprecationTypes.GLOBAL_SET].message + ).toHaveBeenWarned() + }) + + test('delete', () => { + toggleDeprecationWarning(true) + const obj: any = { foo: 1 } + Vue.delete(obj, 'foo') + expect('foo' in obj).toBe(false) + expect( + deprecationData[DeprecationTypes.GLOBAL_DELETE].message + ).toHaveBeenWarned() + }) +}) + +describe('GLOBAL_OBSERVABLE', () => { + test('should work', () => { + toggleDeprecationWarning(true) + const obj = Vue.observable({}) + expect(isReactive(obj)).toBe(true) + expect( + deprecationData[DeprecationTypes.GLOBAL_OBSERVABLE].message + ).toHaveBeenWarned() + }) +}) + +describe('GLOBAL_PRIVATE_UTIL', () => { + test('defineReactive', () => { + toggleDeprecationWarning(true) + const obj: any = {} + // @ts-ignore + Vue.util.defineReactive(obj, 'test', 1) + + let n + effect(() => { + n = obj.test + }) + expect(n).toBe(1) + obj.test++ + expect(n).toBe(2) + + expect( + deprecationData[DeprecationTypes.GLOBAL_PRIVATE_UTIL].message + ).toHaveBeenWarned() + }) }) diff --git a/packages/runtime-core/src/compat/compatConfig.ts b/packages/runtime-core/src/compat/compatConfig.ts index a163bf2942..e59bdb0a23 100644 --- a/packages/runtime-core/src/compat/compatConfig.ts +++ b/packages/runtime-core/src/compat/compatConfig.ts @@ -17,7 +17,7 @@ export const enum DeprecationTypes { GLOBAL_SET = 'GLOBAL_SET', GLOBAL_DELETE = 'GLOBAL_DELETE', GLOBAL_OBSERVABLE = 'GLOBAL_OBSERVABLE', - GLOBAL_UTIL = 'GLOBAL_UTIL', + GLOBAL_PRIVATE_UTIL = 'GLOBAL_PRIVATE_UTIL', CONFIG_SILENT = 'CONFIG_SILENT', CONFIG_DEVTOOLS = 'CONFIG_DEVTOOLS', @@ -70,7 +70,7 @@ type DeprecationData = { link?: string } -const deprecationData: Record = { +export const deprecationData: Record = { [DeprecationTypes.GLOBAL_MOUNT]: { message: `The global app bootstrapping API has changed: vm.$mount() and the "el" ` + @@ -119,7 +119,7 @@ const deprecationData: Record = { link: `https://v3.vuejs.org/api/basic-reactivity.html` }, - [DeprecationTypes.GLOBAL_UTIL]: { + [DeprecationTypes.GLOBAL_PRIVATE_UTIL]: { message: `Vue.util has been removed. Please refactor to avoid its usage ` + `since it was an internal API even in Vue 2.` @@ -437,6 +437,13 @@ const deprecationData: Record = { const instanceWarned: Record = Object.create(null) const warnCount: Record = Object.create(null) +// test only +let warningEnabled = true + +export function toggleDeprecationWarning(flag: boolean) { + warningEnabled = flag +} + export function warnDeprecation( key: DeprecationTypes, instance: ComponentInternalInstance | null, @@ -445,6 +452,9 @@ export function warnDeprecation( if (!__DEV__) { return } + if (__TEST__ && !warningEnabled) { + return + } instance = instance || getCurrentInstance() @@ -463,14 +473,14 @@ export function warnDeprecation( // skip if the same warning is emitted for the same component type const componentDupKey = dupKey + compId - if (componentDupKey in instanceWarned) { + if (!__TEST__ && componentDupKey in instanceWarned) { return } instanceWarned[componentDupKey] = true // same warning, but different component. skip the long message and just // log the key and count. - if (dupKey in warnCount) { + if (!__TEST__ && dupKey in warnCount) { warn(`(deprecation ${key}) (${++warnCount[dupKey] + 1})`) return } diff --git a/packages/runtime-core/src/compat/data.ts b/packages/runtime-core/src/compat/data.ts index 87663dbd03..121c0f9a68 100644 --- a/packages/runtime-core/src/compat/data.ts +++ b/packages/runtime-core/src/compat/data.ts @@ -1,5 +1,6 @@ -import { isPlainObject } from '@vue/shared' +import { isFunction, isPlainObject } from '@vue/shared' import { ComponentInternalInstance } from '../component' +import { ComponentPublicInstance } from '../componentPublicInstance' import { DeprecationTypes, warnDeprecation } from './compatConfig' export function deepMergeData( @@ -19,3 +20,19 @@ export function deepMergeData( } } } + +export function mergeDataOption(to: any, from: any) { + if (!from) { + return to + } + if (!to) { + return from + } + return function mergedDataFn(this: ComponentPublicInstance) { + return deepMergeData( + isFunction(to) ? to.call(this, this) : to, + isFunction(from) ? from.call(this, this) : from, + this.$ + ) + } +} diff --git a/packages/runtime-core/src/compat/global.ts b/packages/runtime-core/src/compat/global.ts index 16f3a9a597..b8db418655 100644 --- a/packages/runtime-core/src/compat/global.ts +++ b/packages/runtime-core/src/compat/global.ts @@ -25,7 +25,6 @@ import { CreateAppFunction, Plugin } from '../apiCreateApp' -import { defineComponent } from '../apiDefineComponent' import { Component, ComponentOptions, @@ -40,7 +39,7 @@ import { devtoolsInitApp } from '../devtools' import { Directive } from '../directives' import { nextTick } from '../scheduler' import { version } from '..' -import { LegacyConfig } from './globalConfig' +import { LegacyConfig, legacyOptionMergeStrats } from './globalConfig' import { LegacyDirective } from './customDirective' import { warnDeprecation, @@ -50,6 +49,7 @@ import { isCompatEnabled, softAssertCompatEnabled } from './compatConfig' +import { LegacyPublicInstance } from './instance' /** * @deprecated the default `Vue` export has been removed in Vue 3. The type for @@ -57,15 +57,17 @@ import { * named imports instead - e.g. `import { createApp } from 'vue'`. */ export type CompatVue = Pick & { + configureCompat: typeof configureCompat + // no inference here since these types are not meant for actual use - they // are merely here to provide type checks for internal implementation and // information for migration. - new (options?: ComponentOptions): ComponentPublicInstance + new (options?: ComponentOptions): LegacyPublicInstance version: string config: AppConfig & LegacyConfig - extend: typeof defineComponent + extend: (options?: ComponentOptions) => CompatVue nextTick: typeof nextTick use(plugin: Plugin, ...options: any[]): CompatVue @@ -102,8 +104,10 @@ export type CompatVue = Pick & { * @internal */ options: ComponentOptions - - configureCompat: typeof configureCompat + /** + * @internal + */ + super: CompatVue } export let isCopyingConfig = false @@ -184,32 +188,55 @@ export function createCompatVue( let cid = 1 Vue.cid = cid + const extendCache = new WeakMap() + function extendCtor(this: any, extendOptions: ComponentOptions = {}) { assertCompatEnabled(DeprecationTypes.GLOBAL_EXTEND, null) if (isFunction(extendOptions)) { extendOptions = extendOptions.options } + if (extendCache.has(extendOptions)) { + return extendCache.get(extendOptions) + } + const Super = this function SubVue(inlineOptions?: ComponentOptions) { if (!inlineOptions) { - return createCompatApp(extendOptions, SubVue) + return createCompatApp(SubVue.options, SubVue) } else { return createCompatApp( - { - el: inlineOptions.el, - extends: extendOptions, - mixins: [inlineOptions] - }, + mergeOptions( + extend({}, SubVue.options), + inlineOptions, + null, + legacyOptionMergeStrats as any + ), SubVue ) } } + SubVue.super = Super SubVue.prototype = Object.create(Vue.prototype) SubVue.prototype.constructor = SubVue + + // clone non-primitive base option values for edge case of mutating + // extended options + const mergeBase: any = {} + for (const key in Super.options) { + const superValue = Super.options[key] + mergeBase[key] = isArray(superValue) + ? superValue.slice() + : isObject(superValue) + ? extend(Object.create(null), superValue) + : superValue + } + SubVue.options = mergeOptions( - extend({}, Super.options) as ComponentOptions, - extendOptions + mergeBase, + extendOptions, + null, + legacyOptionMergeStrats as any ) SubVue.options._base = SubVue @@ -217,6 +244,8 @@ export function createCompatVue( SubVue.mixin = Super.mixin SubVue.use = Super.use SubVue.cid = ++cid + + extendCache.set(extendOptions, SubVue) return SubVue } @@ -279,12 +308,17 @@ export function createCompatVue( warn: __DEV__ ? warn : NOOP, extend, mergeOptions: (parent: any, child: any, vm?: ComponentPublicInstance) => - mergeOptions(parent, child, vm && vm.$), + mergeOptions( + parent, + child, + vm && vm.$, + vm ? undefined : (legacyOptionMergeStrats as any) + ), defineReactive } Object.defineProperty(Vue, 'util', { get() { - assertCompatEnabled(DeprecationTypes.GLOBAL_UTIL, null) + assertCompatEnabled(DeprecationTypes.GLOBAL_PRIVATE_UTIL, null) return util } }) @@ -332,7 +366,7 @@ export function installCompatMount( // Note: the following assumes DOM environment since the compat build // only targets web. It essentially includes logic for app.mount from // both runtime-core AND runtime-dom. - instance.ctx._compat_mount = (selectorOrEl: string | Element) => { + instance.ctx._compat_mount = (selectorOrEl?: string | Element) => { if (isMounted) { __DEV__ && warn(`Root instance is already mounted.`) return @@ -351,14 +385,8 @@ export function installCompatMount( } container = result } else { - if (!selectorOrEl) { - __DEV__ && - warn( - `Failed to mount root instance: invalid mount target ${selectorOrEl}.` - ) - return - } - container = selectorOrEl + // eslint-disable-next-line + container = selectorOrEl || document.createElement('div') } const isSVG = container instanceof SVGElement diff --git a/packages/runtime-core/src/compat/globalConfig.ts b/packages/runtime-core/src/compat/globalConfig.ts index 168ba87cc3..ae6664f23a 100644 --- a/packages/runtime-core/src/compat/globalConfig.ts +++ b/packages/runtime-core/src/compat/globalConfig.ts @@ -1,7 +1,7 @@ import { extend, isArray, isString } from '@vue/shared' import { AppConfig } from '../apiCreateApp' import { isRuntimeOnly } from '../component' -import { deepMergeData } from './data' +import { mergeDataOption } from './data' import { DeprecationTypes, warnDeprecation, @@ -80,34 +80,36 @@ export function installLegacyConfigProperties(config: AppConfig) { // Internal merge strats which are no longer needed in v3, but we need to // expose them because some v2 plugins will reuse these internal strats to // merge their custom options. - const strats = config.optionMergeStrategies as any - strats.data = deepMergeData - // lifecycle hooks - strats.beforeCreate = mergeHook - strats.created = mergeHook - strats.beforeMount = mergeHook - strats.mounted = mergeHook - strats.beforeUpdate = mergeHook - strats.updated = mergeHook - strats.beforeDestroy = mergeHook - strats.destroyed = mergeHook - strats.activated = mergeHook - strats.deactivated = mergeHook - strats.errorCaptured = mergeHook - strats.serverPrefetch = mergeHook + extend(config.optionMergeStrategies, legacyOptionMergeStrats) +} + +export const legacyOptionMergeStrats = { + data: mergeDataOption, + beforeCreate: mergeHook, + created: mergeHook, + beforeMount: mergeHook, + mounted: mergeHook, + beforeUpdate: mergeHook, + updated: mergeHook, + beforeDestroy: mergeHook, + destroyed: mergeHook, + activated: mergeHook, + deactivated: mergeHook, + errorCaptured: mergeHook, + serverPrefetch: mergeHook, // assets - strats.components = mergeObjectOptions - strats.directives = mergeObjectOptions - strats.filters = mergeObjectOptions + components: mergeObjectOptions, + directives: mergeObjectOptions, + filters: mergeObjectOptions, // objects - strats.props = mergeObjectOptions - strats.methods = mergeObjectOptions - strats.inject = mergeObjectOptions - strats.computed = mergeObjectOptions + props: mergeObjectOptions, + methods: mergeObjectOptions, + inject: mergeObjectOptions, + computed: mergeObjectOptions, // watch has special merge behavior in v2, but isn't actually needed in v3. // since we are only exposing these for compat and nobody should be relying // on the watch-specific behavior, just expose the object merge strat. - strats.watch = mergeObjectOptions + watch: mergeObjectOptions } function mergeHook( diff --git a/packages/runtime-core/src/compat/instance.ts b/packages/runtime-core/src/compat/instance.ts index 208825cca7..10c59cebb6 100644 --- a/packages/runtime-core/src/compat/instance.ts +++ b/packages/runtime-core/src/compat/instance.ts @@ -6,7 +6,10 @@ import { toDisplayString, toNumber } from '@vue/shared' -import { PublicPropertiesMap } from '../componentPublicInstance' +import { + ComponentPublicInstance, + PublicPropertiesMap +} from '../componentPublicInstance' import { getCompatChildren } from './instanceChildren' import { DeprecationTypes, @@ -33,6 +36,23 @@ import { } from './renderHelpers' import { resolveFilter } from '../helpers/resolveAssets' import { resolveMergedOptions } from '../componentOptions' +import { Slots } from '../componentSlots' + +export type LegacyPublicInstance = ComponentPublicInstance & + LegacyPublicProperties + +export interface LegacyPublicProperties { + $set(target: object, key: string, value: any): void + $delete(target: object, key: string): void + $mount(el?: string | Element): this + $destroy(): void + $scopedSlots: Slots + $on(event: string | string[], fn: Function): this + $once(event: string, fn: Function): this + $off(event?: string, fn?: Function): this + $children: LegacyPublicProperties[] + $listeners: Record +} export function installCompatInstanceProperties(map: PublicPropertiesMap) { const set = (target: any, key: any, val: any) => { diff --git a/packages/runtime-core/src/componentOptions.ts b/packages/runtime-core/src/componentOptions.ts index a9961d753a..4222d063e1 100644 --- a/packages/runtime-core/src/componentOptions.ts +++ b/packages/runtime-core/src/componentOptions.ts @@ -893,7 +893,13 @@ function callHookWithMixinAndExtends( } } if (selfHook) { - callWithAsyncErrorHandling(selfHook.bind(instance.proxy!), instance, type) + callWithAsyncErrorHandling( + __COMPAT__ && isArray(selfHook) + ? selfHook.map(h => h.bind(instance.proxy!)) + : selfHook.bind(instance.proxy!), + instance, + type + ) } } @@ -1007,21 +1013,23 @@ export function resolveMergedOptions( export function mergeOptions( to: any, from: any, - instance?: ComponentInternalInstance + instance?: ComponentInternalInstance | null, + strats = instance && instance.appContext.config.optionMergeStrategies ) { if (__COMPAT__ && isFunction(from)) { from = from.options } - const strats = instance && instance.appContext.config.optionMergeStrategies const { mixins, extends: extendsOptions } = from - extendsOptions && mergeOptions(to, extendsOptions, instance) + extendsOptions && mergeOptions(to, extendsOptions, instance, strats) mixins && - mixins.forEach((m: ComponentOptionsMixin) => mergeOptions(to, m, instance)) + mixins.forEach((m: ComponentOptionsMixin) => + mergeOptions(to, m, instance, strats) + ) for (const key in from) { - if (strats && hasOwn(to, key) && hasOwn(strats, key)) { + if (strats && hasOwn(strats, key)) { to[key] = strats[key](to[key], from[key], instance && instance.proxy, key) } else { to[key] = from[key]