From 98d1406214dcb587c7e34ad9eb82facbf5e7c1a1 Mon Sep 17 00:00:00 2001 From: Evan You Date: Tue, 3 Sep 2019 18:11:04 -0400 Subject: [PATCH] test: test for app-level APIs --- .../reactivity/__tests__/readonly.spec.ts | 3 + .../runtime-core/__tests__/apiApp.spec.ts | 206 ++++++++++++++++++ .../__tests__/apiCreateApp.spec.ts | 19 -- .../runtime-core/__tests__/directives.spec.ts | 30 +-- .../src/{apiCreateApp.ts => apiApp.ts} | 52 +++-- packages/runtime-core/src/apiInject.ts | 13 +- packages/runtime-core/src/component.ts | 47 ++-- packages/runtime-core/src/directives.ts | 12 +- packages/runtime-core/src/errorHandling.ts | 44 ++-- packages/runtime-core/src/index.ts | 6 +- packages/runtime-core/src/vnode.ts | 2 +- packages/runtime-core/src/warning.ts | 39 +++- 12 files changed, 353 insertions(+), 120 deletions(-) create mode 100644 packages/runtime-core/__tests__/apiApp.spec.ts delete mode 100644 packages/runtime-core/__tests__/apiCreateApp.spec.ts rename packages/runtime-core/src/{apiCreateApp.ts => apiApp.ts} (78%) diff --git a/packages/reactivity/__tests__/readonly.spec.ts b/packages/reactivity/__tests__/readonly.spec.ts index 0c4ee153..c2dbb3a8 100644 --- a/packages/reactivity/__tests__/readonly.spec.ts +++ b/packages/reactivity/__tests__/readonly.spec.ts @@ -86,6 +86,7 @@ describe('reactivity/readonly', () => { observed.a = 2 expect(observed.a).toBe(1) expect(dummy).toBe(1) + expect(`target is readonly`).toHaveBeenWarned() }) it('should trigger effects when unlocked', () => { @@ -178,9 +179,11 @@ describe('reactivity/readonly', () => { observed[0].a = 2 expect(observed[0].a).toBe(1) expect(dummy).toBe(1) + expect(`target is readonly`).toHaveBeenWarnedTimes(1) observed[0] = { a: 2 } expect(observed[0].a).toBe(1) expect(dummy).toBe(1) + expect(`target is readonly`).toHaveBeenWarnedTimes(2) }) it('should trigger effects when unlocked', () => { diff --git a/packages/runtime-core/__tests__/apiApp.spec.ts b/packages/runtime-core/__tests__/apiApp.spec.ts new file mode 100644 index 00000000..de66cfef --- /dev/null +++ b/packages/runtime-core/__tests__/apiApp.spec.ts @@ -0,0 +1,206 @@ +import { + createApp, + h, + nodeOps, + serializeInner, + mockWarn, + provide, + inject, + resolveComponent, + resolveDirective, + applyDirectives, + Plugin, + ref +} from '@vue/runtime-test' + +describe('api: createApp', () => { + mockWarn() + + test('mount', () => { + const Comp = { + props: { + count: { + default: 0 + } + }, + render() { + return this.count + } + } + + const root1 = nodeOps.createElement('div') + createApp().mount(Comp, root1) + expect(serializeInner(root1)).toBe(`0`) + + // mount with props + const root2 = nodeOps.createElement('div') + const app2 = createApp() + app2.mount(Comp, root2, { count: 1 }) + expect(serializeInner(root2)).toBe(`1`) + + // remount warning + const root3 = nodeOps.createElement('div') + app2.mount(Comp, root3) + expect(serializeInner(root3)).toBe(``) + expect(`already been mounted`).toHaveBeenWarned() + }) + + test('provide', () => { + const app = createApp() + app.provide('foo', 1) + app.provide('bar', 2) + + const Root = { + setup() { + // test override + provide('foo', 3) + return () => h(Child) + } + } + + const Child = { + setup() { + const foo = inject('foo') + const bar = inject('bar') + return () => `${foo},${bar}` + } + } + + const root = nodeOps.createElement('div') + app.mount(Root, root) + expect(serializeInner(root)).toBe(`3,2`) + }) + + test('component', () => { + const app = createApp() + app.component('FooBar', () => 'foobar!') + app.component('BarBaz', () => 'barbaz!') + + const Root = { + // local override + components: { + BarBaz: () => 'barbaz-local!' + }, + setup() { + // resolve in setup + const FooBar = resolveComponent('foo-bar') as any + return () => { + // resolve in render + const BarBaz = resolveComponent('bar-baz') as any + return h('div', [h(FooBar), h(BarBaz)]) + } + } + } + + const root = nodeOps.createElement('div') + app.mount(Root, root) + expect(serializeInner(root)).toBe(`
foobar!barbaz-local!
`) + }) + + test('directive', () => { + const app = createApp() + + const spy1 = jest.fn() + const spy2 = jest.fn() + const spy3 = jest.fn() + app.directive('FooBar', { + mounted: spy1 + }) + app.directive('BarBaz', { + mounted: spy2 + }) + + const Root = { + // local override + directives: { + BarBaz: { mounted: spy3 } + }, + setup() { + // resolve in setup + const FooBar = resolveDirective('foo-bar') as any + return () => { + // resolve in render + const BarBaz = resolveDirective('bar-baz') as any + return applyDirectives(h('div'), [[FooBar], [BarBaz]]) + } + } + } + + const root = nodeOps.createElement('div') + app.mount(Root, root) + expect(spy1).toHaveBeenCalled() + expect(spy2).not.toHaveBeenCalled() + expect(spy3).toHaveBeenCalled() + }) + + test('use', () => { + const PluginA: Plugin = app => app.provide('foo', 1) + const PluginB: Plugin = { + install: app => app.provide('bar', 2) + } + + const app = createApp() + app.use(PluginA) + app.use(PluginB) + + const Root = { + setup() { + const foo = inject('foo') + const bar = inject('bar') + return () => `${foo},${bar}` + } + } + const root = nodeOps.createElement('div') + app.mount(Root, root) + expect(serializeInner(root)).toBe(`1,2`) + }) + + test('config.errorHandler', () => { + const app = createApp() + + const error = new Error() + const count = ref(0) + + const handler = (app.config.errorHandler = jest.fn( + (err, instance, info) => { + expect(err).toBe(error) + expect((instance as any).count).toBe(count.value) + expect(info).toBe(`render function`) + } + )) + + const Root = { + setup() { + const count = ref(0) + return { + count + } + }, + render() { + throw error + } + } + + app.mount(Root, nodeOps.createElement('div')) + expect(handler).toHaveBeenCalled() + }) + + test('config.warnHandler', () => { + const app = createApp() + + const handler = (app.config.warnHandler = jest.fn( + (msg, instance, trace) => {} + )) + + const Root = { + setup() {} + } + + app.mount(Root, nodeOps.createElement('div')) + expect(handler).toHaveBeenCalled() + }) + + test.todo('mixin') + + test.todo('config.optionsMergeStrategies') +}) diff --git a/packages/runtime-core/__tests__/apiCreateApp.spec.ts b/packages/runtime-core/__tests__/apiCreateApp.spec.ts deleted file mode 100644 index ff5e1576..00000000 --- a/packages/runtime-core/__tests__/apiCreateApp.spec.ts +++ /dev/null @@ -1,19 +0,0 @@ -describe('api: createApp', () => { - test('mount', () => {}) - - test('provide', () => {}) - - test('component', () => {}) - - test('directive', () => {}) - - test('use', () => {}) - - test.todo('mixin') - - test('config.errorHandler', () => {}) - - test('config.warnHandler', () => {}) - - test.todo('config.optionsMergeStrategies') -}) diff --git a/packages/runtime-core/__tests__/directives.spec.ts b/packages/runtime-core/__tests__/directives.spec.ts index 6cb43e72..586b39e3 100644 --- a/packages/runtime-core/__tests__/directives.spec.ts +++ b/packages/runtime-core/__tests__/directives.spec.ts @@ -109,20 +109,22 @@ describe('directives', () => { render() { _prevVnode = _vnode _vnode = applyDirectives(h('div', count.value), [ - { - beforeMount, - mounted, - beforeUpdate, - updated, - beforeUnmount, - unmounted - }, - // value - count.value, - // argument - 'foo', - // modifiers - { ok: true } + [ + { + beforeMount, + mounted, + beforeUpdate, + updated, + beforeUnmount, + unmounted + }, + // value + count.value, + // argument + 'foo', + // modifiers + { ok: true } + ] ]) return _vnode } diff --git a/packages/runtime-core/src/apiCreateApp.ts b/packages/runtime-core/src/apiApp.ts similarity index 78% rename from packages/runtime-core/src/apiCreateApp.ts rename to packages/runtime-core/src/apiApp.ts index b5a64c55..7e0ed972 100644 --- a/packages/runtime-core/src/apiCreateApp.ts +++ b/packages/runtime-core/src/apiApp.ts @@ -36,12 +36,12 @@ export interface AppConfig { performance: boolean errorHandler?: ( err: Error, - instance: ComponentRenderProxy, + instance: ComponentRenderProxy | null, info: string ) => void warnHandler?: ( msg: string, - instance: ComponentRenderProxy, + instance: ComponentRenderProxy | null, trace: string ) => void } @@ -56,7 +56,7 @@ export interface AppContext { type PluginInstallFunction = (app: App) => any -type Plugin = +export type Plugin = | PluginInstallFunction | { install: PluginInstallFunction @@ -82,6 +82,8 @@ export function createAppAPI(render: RootRenderFunction): () => App { return function createApp(): App { const context = createAppContext() + let isMounted = false + const app: App = { get config() { return context.config @@ -134,14 +136,20 @@ export function createAppAPI(render: RootRenderFunction): () => App { } }, - mount(rootComponent, rootContainer, rootProps?: Data) { - const vnode = createVNode(rootComponent, rootProps) - // store app context on the root VNode. - // this will be set on the root instance on initial mount. - vnode.appContext = context - render(vnode, rootContainer) - return (vnode.component as ComponentInstance) - .renderProxy as ComponentRenderProxy + mount(rootComponent, rootContainer, rootProps?: Data): any { + if (!isMounted) { + const vnode = createVNode(rootComponent, rootProps) + // store app context on the root VNode. + // this will be set on the root instance on initial mount. + vnode.appContext = context + render(vnode, rootContainer) + isMounted = true + return (vnode.component as ComponentInstance).renderProxy + } else if (__DEV__) { + warn( + `App has already been mounted. Create a new app instance instead.` + ) + } }, provide(key, value) { @@ -164,15 +172,21 @@ export function resolveAsset(type: 'components' | 'directives', name: string) { if (instance) { let camelized let capitalized + let res const local = (instance.type as any)[type] - const global = instance.appContext[type] - const res = - local[name] || - local[(camelized = camelize(name))] || - local[(capitalized = capitalize(name))] || - global[name] || - global[camelized] || - global[capitalized] + if (local) { + res = + local[name] || + local[(camelized = camelize(name))] || + local[(capitalized = capitalize(camelized))] + } + if (!res) { + const global = instance.appContext[type] + res = + global[name] || + global[camelized || (camelized = camelize(name))] || + global[capitalized || capitalize(camelized)] + } if (__DEV__ && !res) { warn(`Failed to resolve ${type.slice(0, -1)}: ${name}`) } diff --git a/packages/runtime-core/src/apiInject.ts b/packages/runtime-core/src/apiInject.ts index ba9d7dd1..3766d9f1 100644 --- a/packages/runtime-core/src/apiInject.ts +++ b/packages/runtime-core/src/apiInject.ts @@ -6,7 +6,7 @@ export interface InjectionKey extends Symbol {} export function provide(key: InjectionKey | string, value: T) { if (!currentInstance) { if (__DEV__) { - warn(`provide() is used without an active component instance.`) + warn(`provide() can only be used inside setup().`) } } else { let provides = currentInstance.provides @@ -27,17 +27,16 @@ export function provide(key: InjectionKey | string, value: T) { export function inject(key: InjectionKey | string): T | undefined export function inject(key: InjectionKey | string, defaultValue: T): T export function inject(key: InjectionKey | string, defaultValue?: any) { - if (!currentInstance) { - // TODO warn - } else { - // TODO should also check for app-level provides - const provides = currentInstance.parent && currentInstance.provides - if (provides && key in provides) { + if (currentInstance) { + const provides = currentInstance.provides + if (key in provides) { return provides[key as any] as any } else if (defaultValue !== undefined) { return defaultValue } else if (__DEV__) { warn(`injection "${key}" not found.`) } + } else if (__DEV__) { + warn(`inject() can only be used inside setup().`) } } diff --git a/packages/runtime-core/src/component.ts b/packages/runtime-core/src/component.ts index d003d2f5..8ff73262 100644 --- a/packages/runtime-core/src/component.ts +++ b/packages/runtime-core/src/component.ts @@ -13,7 +13,8 @@ import { callWithErrorHandling, callWithAsyncErrorHandling } from './errorHandling' -import { AppContext, createAppContext, resolveAsset } from './apiCreateApp' +import { AppContext, createAppContext, resolveAsset } from './apiApp' +import { Directive } from './directives' export type Data = { [key: string]: unknown } @@ -31,41 +32,42 @@ export type ComponentRenderProxy

= { } & P & S -type SetupFunction = ( - props: Props, - ctx: SetupContext -) => RawBindings | (() => VNodeChild) - type RenderFunction = < Bindings extends UnwrapRef >( this: ComponentRenderProxy ) => VNodeChild -interface ComponentOptionsWithoutProps { - props?: undefined - setup?: SetupFunction +interface ComponentOptionsBase { + setup?: ( + props: Props, + ctx: SetupContext + ) => RawBindings | (() => VNodeChild) | void render?: RenderFunction + components?: Record + directives?: Record + // TODO full 2.x options compat +} + +interface ComponentOptionsWithoutProps + extends ComponentOptionsBase { + props?: undefined } interface ComponentOptionsWithArrayProps< PropNames extends string = string, - RawBindings = Data, + RawBindings = {}, Props = { [key in PropNames]?: unknown } -> { +> extends ComponentOptionsBase { props: PropNames[] - setup?: SetupFunction - render?: RenderFunction } interface ComponentOptionsWithProps< PropsOptions = ComponentPropsOptions, - RawBindings = Data, + RawBindings = {}, Props = ExtractPropTypes -> { +> extends ComponentOptionsBase { props: PropsOptions - setup?: SetupFunction - render?: RenderFunction } export type ComponentOptions = @@ -105,7 +107,7 @@ interface SetupContext { emit: ((event: string, ...args: unknown[]) => void) } -export type ComponentInstance

= { +export type ComponentInstance

= { type: FunctionalComponent | ComponentOptions parent: ComponentInstance | null appContext: AppContext @@ -193,12 +195,13 @@ export function createComponentInstance( vnode: VNode, parent: ComponentInstance | null ): ComponentInstance { + // inherit parent app context - or - if root, adopt from root vnode + const appContext = + (parent ? parent.appContext : vnode.appContext) || emptyAppContext const instance = { vnode, parent, - // inherit parent app context - or - if root, adopt from root vnode - appContext: - (parent ? parent.appContext : vnode.appContext) || emptyAppContext, + appContext, type: vnode.type as any, root: null as any, // set later so it can point to itself next: null, @@ -209,7 +212,7 @@ export function createComponentInstance( propsProxy: null, setupContext: null, effects: null, - provides: parent ? parent.provides : {}, + provides: parent ? parent.provides : Object.create(appContext.provides), // setup context properties data: EMPTY_OBJ, diff --git a/packages/runtime-core/src/directives.ts b/packages/runtime-core/src/directives.ts index 065f7bbd..1c901de8 100644 --- a/packages/runtime-core/src/directives.ts +++ b/packages/runtime-core/src/directives.ts @@ -5,11 +5,10 @@ const comp = resolveComponent('comp') const foo = resolveDirective('foo') const bar = resolveDirective('bar') -return applyDirectives( - h(comp), +return applyDirectives(h(comp), [ [foo, this.x], [bar, this.y] -) +]) */ import { VNode, cloneVNode } from './vnode' @@ -22,7 +21,7 @@ import { } from './component' import { callWithAsyncErrorHandling, ErrorTypes } from './errorHandling' import { HostNode } from './createRenderer' -import { resolveAsset } from './apiCreateApp' +import { resolveAsset } from './apiApp' export interface DirectiveBinding { instance: ComponentRenderProxy | null @@ -103,10 +102,7 @@ type DirectiveArguments = Array< | [Directive, any, string, DirectiveModifiers] > -export function applyDirectives( - vnode: VNode, - ...directives: DirectiveArguments -) { +export function applyDirectives(vnode: VNode, directives: DirectiveArguments) { const instance = currentRenderingInstance if (instance !== null) { vnode = cloneVNode(vnode) diff --git a/packages/runtime-core/src/errorHandling.ts b/packages/runtime-core/src/errorHandling.ts index 53cadc48..42cc356d 100644 --- a/packages/runtime-core/src/errorHandling.ts +++ b/packages/runtime-core/src/errorHandling.ts @@ -13,6 +13,8 @@ export const enum ErrorTypes { NATIVE_EVENT_HANDLER, COMPONENT_EVENT_HANDLER, DIRECTIVE_HOOK, + APP_ERROR_HANDLER, + APP_WARN_HANDLER, SCHEDULER } @@ -38,6 +40,8 @@ export const ErrorTypeStrings: Record = { [ErrorTypes.NATIVE_EVENT_HANDLER]: 'native event handler', [ErrorTypes.COMPONENT_EVENT_HANDLER]: 'component event handler', [ErrorTypes.DIRECTIVE_HOOK]: 'directive hook', + [ErrorTypes.APP_ERROR_HANDLER]: 'app errorHandler', + [ErrorTypes.APP_WARN_HANDLER]: 'app warnHandler', [ErrorTypes.SCHEDULER]: 'scheduler flush. This may be a Vue internals bug. ' + 'Please open an issue at https://new-issue.vuejs.org/?repo=vuejs/vue' @@ -81,24 +85,34 @@ export function handleError( type: AllErrorTypes ) { const contextVNode = instance ? instance.vnode : null - let cur: ComponentInstance | null = instance && instance.parent - while (cur) { - const errorCapturedHooks = cur.ec - if (errorCapturedHooks !== null) { - for (let i = 0; i < errorCapturedHooks.length; i++) { - if ( - errorCapturedHooks[i]( - err, - instance && instance.renderProxy, - // in production the hook receives only the error code - __DEV__ ? ErrorTypeStrings[type] : type - ) - ) { - return + if (instance) { + let cur: ComponentInstance | null = instance.parent + // the exposed instance is the render proxy to keep it consistent with 2.x + const exposedInstance = instance.renderProxy + // in production the hook receives only the error code + const errorInfo = __DEV__ ? ErrorTypeStrings[type] : type + while (cur) { + const errorCapturedHooks = cur.ec + if (errorCapturedHooks !== null) { + for (let i = 0; i < errorCapturedHooks.length; i++) { + if (errorCapturedHooks[i](err, exposedInstance, errorInfo)) { + return + } } } + cur = cur.parent + } + // app-level handling + const appErrorHandler = instance.appContext.config.errorHandler + if (appErrorHandler) { + callWithErrorHandling( + appErrorHandler, + null, + ErrorTypes.APP_ERROR_HANDLER, + [err, exposedInstance, errorInfo] + ) + return } - cur = cur.parent } logError(err, type, contextVNode) } diff --git a/packages/runtime-core/src/index.ts b/packages/runtime-core/src/index.ts index d34cb424..59fc13bc 100644 --- a/packages/runtime-core/src/index.ts +++ b/packages/runtime-core/src/index.ts @@ -28,7 +28,7 @@ export { PublicShapeFlags as ShapeFlags } from './shapeFlags' export { getCurrentInstance } from './component' // For custom renderers -export { createAppAPI } from './apiCreateApp' +export { createAppAPI } from './apiApp' export { createRenderer } from './createRenderer' export { handleError, @@ -42,8 +42,8 @@ export { applyDirectives, resolveDirective } from './directives' // Types ----------------------------------------------------------------------- -export { App } from './apiCreateApp' -export { VNode } from './vnode' +export { App, AppConfig, AppContext, Plugin } from './apiApp' +export { VNode, VNodeTypes } from './vnode' export { FunctionalComponent, ComponentInstance } from './component' export { RendererOptions } from './createRenderer' export { Slot, Slots } from './componentSlots' diff --git a/packages/runtime-core/src/vnode.ts b/packages/runtime-core/src/vnode.ts index 1fe55681..35810645 100644 --- a/packages/runtime-core/src/vnode.ts +++ b/packages/runtime-core/src/vnode.ts @@ -12,7 +12,7 @@ import { RawSlots } from './componentSlots' import { PatchFlags } from './patchFlags' import { ShapeFlags } from './shapeFlags' import { isReactive } from '@vue/reactivity' -import { AppContext } from './apiCreateApp' +import { AppContext } from './apiApp' export const Fragment = Symbol('Fragment') export const Text = Symbol('Text') diff --git a/packages/runtime-core/src/warning.ts b/packages/runtime-core/src/warning.ts index b8301ba3..138b565b 100644 --- a/packages/runtime-core/src/warning.ts +++ b/packages/runtime-core/src/warning.ts @@ -21,13 +21,24 @@ export function popWarningContext() { } export function warn(msg: string, ...args: any[]) { - // TODO app level warn handler + const instance = stack.length ? stack[stack.length - 1].component : null + const appWarnHandler = instance && instance.appContext.config.warnHandler + const trace = getComponentTrace() + + if (appWarnHandler) { + appWarnHandler( + msg + args.join(''), + instance && instance.renderProxy, + formatTrace(trace).join('') + ) + return + } + console.warn(`[Vue warn]: ${msg}`, ...args) // avoid spamming console during tests if (typeof process !== 'undefined' && process.env.NODE_ENV === 'test') { return } - const trace = getComponentTrace() if (!trace.length) { return } @@ -41,16 +52,7 @@ export function warn(msg: string, ...args: any[]) { console.log(...logs) console.groupEnd() } else { - const logs: string[] = [] - trace.forEach((entry, i) => { - const formatted = formatTraceEntry(entry, i) - if (i === 0) { - logs.push('at', ...formatted) - } else { - logs.push('\n', ...formatted) - } - }) - console.log(...logs) + console.log(...formatTrace(trace)) } } @@ -83,6 +85,19 @@ function getComponentTrace(): ComponentTraceStack { return normlaizedStack } +function formatTrace(trace: ComponentTraceStack): string[] { + const logs: string[] = [] + trace.forEach((entry, i) => { + const formatted = formatTraceEntry(entry, i) + if (i === 0) { + logs.push('at', ...formatted) + } else { + logs.push('\n', ...formatted) + } + }) + return logs +} + function formatTraceEntry( { vnode, recurseCount }: TraceEntry, depth: number = 0