test: test for app-level APIs

This commit is contained in:
Evan You 2019-09-03 18:11:04 -04:00
parent 1e4535dc78
commit 98d1406214
12 changed files with 353 additions and 120 deletions

View File

@ -86,6 +86,7 @@ describe('reactivity/readonly', () => {
observed.a = 2 observed.a = 2
expect(observed.a).toBe(1) expect(observed.a).toBe(1)
expect(dummy).toBe(1) expect(dummy).toBe(1)
expect(`target is readonly`).toHaveBeenWarned()
}) })
it('should trigger effects when unlocked', () => { it('should trigger effects when unlocked', () => {
@ -178,9 +179,11 @@ describe('reactivity/readonly', () => {
observed[0].a = 2 observed[0].a = 2
expect(observed[0].a).toBe(1) expect(observed[0].a).toBe(1)
expect(dummy).toBe(1) expect(dummy).toBe(1)
expect(`target is readonly`).toHaveBeenWarnedTimes(1)
observed[0] = { a: 2 } observed[0] = { a: 2 }
expect(observed[0].a).toBe(1) expect(observed[0].a).toBe(1)
expect(dummy).toBe(1) expect(dummy).toBe(1)
expect(`target is readonly`).toHaveBeenWarnedTimes(2)
}) })
it('should trigger effects when unlocked', () => { it('should trigger effects when unlocked', () => {

View File

@ -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(`<div>foobar!barbaz-local!</div>`)
})
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')
})

View File

@ -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')
})

View File

@ -109,6 +109,7 @@ describe('directives', () => {
render() { render() {
_prevVnode = _vnode _prevVnode = _vnode
_vnode = applyDirectives(h('div', count.value), [ _vnode = applyDirectives(h('div', count.value), [
[
{ {
beforeMount, beforeMount,
mounted, mounted,
@ -123,6 +124,7 @@ describe('directives', () => {
'foo', 'foo',
// modifiers // modifiers
{ ok: true } { ok: true }
]
]) ])
return _vnode return _vnode
} }

View File

@ -36,12 +36,12 @@ export interface AppConfig {
performance: boolean performance: boolean
errorHandler?: ( errorHandler?: (
err: Error, err: Error,
instance: ComponentRenderProxy, instance: ComponentRenderProxy | null,
info: string info: string
) => void ) => void
warnHandler?: ( warnHandler?: (
msg: string, msg: string,
instance: ComponentRenderProxy, instance: ComponentRenderProxy | null,
trace: string trace: string
) => void ) => void
} }
@ -56,7 +56,7 @@ export interface AppContext {
type PluginInstallFunction = (app: App) => any type PluginInstallFunction = (app: App) => any
type Plugin = export type Plugin =
| PluginInstallFunction | PluginInstallFunction
| { | {
install: PluginInstallFunction install: PluginInstallFunction
@ -82,6 +82,8 @@ export function createAppAPI(render: RootRenderFunction): () => App {
return function createApp(): App { return function createApp(): App {
const context = createAppContext() const context = createAppContext()
let isMounted = false
const app: App = { const app: App = {
get config() { get config() {
return context.config return context.config
@ -134,14 +136,20 @@ export function createAppAPI(render: RootRenderFunction): () => App {
} }
}, },
mount(rootComponent, rootContainer, rootProps?: Data) { mount(rootComponent, rootContainer, rootProps?: Data): any {
if (!isMounted) {
const vnode = createVNode(rootComponent, rootProps) const vnode = createVNode(rootComponent, rootProps)
// store app context on the root VNode. // store app context on the root VNode.
// this will be set on the root instance on initial mount. // this will be set on the root instance on initial mount.
vnode.appContext = context vnode.appContext = context
render(vnode, rootContainer) render(vnode, rootContainer)
return (vnode.component as ComponentInstance) isMounted = true
.renderProxy as ComponentRenderProxy return (vnode.component as ComponentInstance).renderProxy
} else if (__DEV__) {
warn(
`App has already been mounted. Create a new app instance instead.`
)
}
}, },
provide(key, value) { provide(key, value) {
@ -164,15 +172,21 @@ export function resolveAsset(type: 'components' | 'directives', name: string) {
if (instance) { if (instance) {
let camelized let camelized
let capitalized let capitalized
let res
const local = (instance.type as any)[type] const local = (instance.type as any)[type]
const global = instance.appContext[type] if (local) {
const res = res =
local[name] || local[name] ||
local[(camelized = camelize(name))] || local[(camelized = camelize(name))] ||
local[(capitalized = capitalize(name))] || local[(capitalized = capitalize(camelized))]
}
if (!res) {
const global = instance.appContext[type]
res =
global[name] || global[name] ||
global[camelized] || global[camelized || (camelized = camelize(name))] ||
global[capitalized] global[capitalized || capitalize(camelized)]
}
if (__DEV__ && !res) { if (__DEV__ && !res) {
warn(`Failed to resolve ${type.slice(0, -1)}: ${name}`) warn(`Failed to resolve ${type.slice(0, -1)}: ${name}`)
} }

View File

@ -6,7 +6,7 @@ export interface InjectionKey<T> extends Symbol {}
export function provide<T>(key: InjectionKey<T> | string, value: T) { export function provide<T>(key: InjectionKey<T> | string, value: T) {
if (!currentInstance) { if (!currentInstance) {
if (__DEV__) { if (__DEV__) {
warn(`provide() is used without an active component instance.`) warn(`provide() can only be used inside setup().`)
} }
} else { } else {
let provides = currentInstance.provides let provides = currentInstance.provides
@ -27,17 +27,16 @@ export function provide<T>(key: InjectionKey<T> | string, value: T) {
export function inject<T>(key: InjectionKey<T> | string): T | undefined export function inject<T>(key: InjectionKey<T> | string): T | undefined
export function inject<T>(key: InjectionKey<T> | string, defaultValue: T): T export function inject<T>(key: InjectionKey<T> | string, defaultValue: T): T
export function inject(key: InjectionKey<any> | string, defaultValue?: any) { export function inject(key: InjectionKey<any> | string, defaultValue?: any) {
if (!currentInstance) { if (currentInstance) {
// TODO warn const provides = currentInstance.provides
} else { if (key in provides) {
// TODO should also check for app-level provides
const provides = currentInstance.parent && currentInstance.provides
if (provides && key in provides) {
return provides[key as any] as any return provides[key as any] as any
} else if (defaultValue !== undefined) { } else if (defaultValue !== undefined) {
return defaultValue return defaultValue
} else if (__DEV__) { } else if (__DEV__) {
warn(`injection "${key}" not found.`) warn(`injection "${key}" not found.`)
} }
} else if (__DEV__) {
warn(`inject() can only be used inside setup().`)
} }
} }

View File

@ -13,7 +13,8 @@ import {
callWithErrorHandling, callWithErrorHandling,
callWithAsyncErrorHandling callWithAsyncErrorHandling
} from './errorHandling' } from './errorHandling'
import { AppContext, createAppContext, resolveAsset } from './apiCreateApp' import { AppContext, createAppContext, resolveAsset } from './apiApp'
import { Directive } from './directives'
export type Data = { [key: string]: unknown } export type Data = { [key: string]: unknown }
@ -31,41 +32,42 @@ export type ComponentRenderProxy<P = {}, S = {}, PublicProps = P> = {
} & P & } & P &
S S
type SetupFunction<Props, RawBindings> = (
props: Props,
ctx: SetupContext
) => RawBindings | (() => VNodeChild)
type RenderFunction<Props = {}, RawBindings = {}> = < type RenderFunction<Props = {}, RawBindings = {}> = <
Bindings extends UnwrapRef<RawBindings> Bindings extends UnwrapRef<RawBindings>
>( >(
this: ComponentRenderProxy<Props, Bindings> this: ComponentRenderProxy<Props, Bindings>
) => VNodeChild ) => VNodeChild
interface ComponentOptionsWithoutProps<Props = Data, RawBindings = Data> { interface ComponentOptionsBase<Props, RawBindings> {
props?: undefined setup?: (
setup?: SetupFunction<Props, RawBindings> props: Props,
ctx: SetupContext
) => RawBindings | (() => VNodeChild) | void
render?: RenderFunction<Props, RawBindings> render?: RenderFunction<Props, RawBindings>
components?: Record<string, Component>
directives?: Record<string, Directive>
// TODO full 2.x options compat
}
interface ComponentOptionsWithoutProps<Props = {}, RawBindings = {}>
extends ComponentOptionsBase<Props, RawBindings> {
props?: undefined
} }
interface ComponentOptionsWithArrayProps< interface ComponentOptionsWithArrayProps<
PropNames extends string = string, PropNames extends string = string,
RawBindings = Data, RawBindings = {},
Props = { [key in PropNames]?: unknown } Props = { [key in PropNames]?: unknown }
> { > extends ComponentOptionsBase<Props, RawBindings> {
props: PropNames[] props: PropNames[]
setup?: SetupFunction<Props, RawBindings>
render?: RenderFunction<Props, RawBindings>
} }
interface ComponentOptionsWithProps< interface ComponentOptionsWithProps<
PropsOptions = ComponentPropsOptions, PropsOptions = ComponentPropsOptions,
RawBindings = Data, RawBindings = {},
Props = ExtractPropTypes<PropsOptions> Props = ExtractPropTypes<PropsOptions>
> { > extends ComponentOptionsBase<Props, RawBindings> {
props: PropsOptions props: PropsOptions
setup?: SetupFunction<Props, RawBindings>
render?: RenderFunction<Props, RawBindings>
} }
export type ComponentOptions = export type ComponentOptions =
@ -105,7 +107,7 @@ interface SetupContext {
emit: ((event: string, ...args: unknown[]) => void) emit: ((event: string, ...args: unknown[]) => void)
} }
export type ComponentInstance<P = Data, S = Data> = { export type ComponentInstance<P = {}, S = {}> = {
type: FunctionalComponent | ComponentOptions type: FunctionalComponent | ComponentOptions
parent: ComponentInstance | null parent: ComponentInstance | null
appContext: AppContext appContext: AppContext
@ -193,12 +195,13 @@ export function createComponentInstance(
vnode: VNode, vnode: VNode,
parent: ComponentInstance | null parent: ComponentInstance | null
): ComponentInstance { ): ComponentInstance {
// inherit parent app context - or - if root, adopt from root vnode
const appContext =
(parent ? parent.appContext : vnode.appContext) || emptyAppContext
const instance = { const instance = {
vnode, vnode,
parent, parent,
// inherit parent app context - or - if root, adopt from root vnode appContext,
appContext:
(parent ? parent.appContext : vnode.appContext) || emptyAppContext,
type: vnode.type as any, type: vnode.type as any,
root: null as any, // set later so it can point to itself root: null as any, // set later so it can point to itself
next: null, next: null,
@ -209,7 +212,7 @@ export function createComponentInstance(
propsProxy: null, propsProxy: null,
setupContext: null, setupContext: null,
effects: null, effects: null,
provides: parent ? parent.provides : {}, provides: parent ? parent.provides : Object.create(appContext.provides),
// setup context properties // setup context properties
data: EMPTY_OBJ, data: EMPTY_OBJ,

View File

@ -5,11 +5,10 @@ const comp = resolveComponent('comp')
const foo = resolveDirective('foo') const foo = resolveDirective('foo')
const bar = resolveDirective('bar') const bar = resolveDirective('bar')
return applyDirectives( return applyDirectives(h(comp), [
h(comp),
[foo, this.x], [foo, this.x],
[bar, this.y] [bar, this.y]
) ])
*/ */
import { VNode, cloneVNode } from './vnode' import { VNode, cloneVNode } from './vnode'
@ -22,7 +21,7 @@ import {
} from './component' } from './component'
import { callWithAsyncErrorHandling, ErrorTypes } from './errorHandling' import { callWithAsyncErrorHandling, ErrorTypes } from './errorHandling'
import { HostNode } from './createRenderer' import { HostNode } from './createRenderer'
import { resolveAsset } from './apiCreateApp' import { resolveAsset } from './apiApp'
export interface DirectiveBinding { export interface DirectiveBinding {
instance: ComponentRenderProxy | null instance: ComponentRenderProxy | null
@ -103,10 +102,7 @@ type DirectiveArguments = Array<
| [Directive, any, string, DirectiveModifiers] | [Directive, any, string, DirectiveModifiers]
> >
export function applyDirectives( export function applyDirectives(vnode: VNode, directives: DirectiveArguments) {
vnode: VNode,
...directives: DirectiveArguments
) {
const instance = currentRenderingInstance const instance = currentRenderingInstance
if (instance !== null) { if (instance !== null) {
vnode = cloneVNode(vnode) vnode = cloneVNode(vnode)

View File

@ -13,6 +13,8 @@ export const enum ErrorTypes {
NATIVE_EVENT_HANDLER, NATIVE_EVENT_HANDLER,
COMPONENT_EVENT_HANDLER, COMPONENT_EVENT_HANDLER,
DIRECTIVE_HOOK, DIRECTIVE_HOOK,
APP_ERROR_HANDLER,
APP_WARN_HANDLER,
SCHEDULER SCHEDULER
} }
@ -38,6 +40,8 @@ export const ErrorTypeStrings: Record<number | string, string> = {
[ErrorTypes.NATIVE_EVENT_HANDLER]: 'native event handler', [ErrorTypes.NATIVE_EVENT_HANDLER]: 'native event handler',
[ErrorTypes.COMPONENT_EVENT_HANDLER]: 'component event handler', [ErrorTypes.COMPONENT_EVENT_HANDLER]: 'component event handler',
[ErrorTypes.DIRECTIVE_HOOK]: 'directive hook', [ErrorTypes.DIRECTIVE_HOOK]: 'directive hook',
[ErrorTypes.APP_ERROR_HANDLER]: 'app errorHandler',
[ErrorTypes.APP_WARN_HANDLER]: 'app warnHandler',
[ErrorTypes.SCHEDULER]: [ErrorTypes.SCHEDULER]:
'scheduler flush. This may be a Vue internals bug. ' + 'scheduler flush. This may be a Vue internals bug. ' +
'Please open an issue at https://new-issue.vuejs.org/?repo=vuejs/vue' 'Please open an issue at https://new-issue.vuejs.org/?repo=vuejs/vue'
@ -81,25 +85,35 @@ export function handleError(
type: AllErrorTypes type: AllErrorTypes
) { ) {
const contextVNode = instance ? instance.vnode : null const contextVNode = instance ? instance.vnode : null
let cur: ComponentInstance | null = instance && instance.parent 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) { while (cur) {
const errorCapturedHooks = cur.ec const errorCapturedHooks = cur.ec
if (errorCapturedHooks !== null) { if (errorCapturedHooks !== null) {
for (let i = 0; i < errorCapturedHooks.length; i++) { for (let i = 0; i < errorCapturedHooks.length; i++) {
if ( if (errorCapturedHooks[i](err, exposedInstance, errorInfo)) {
errorCapturedHooks[i](
err,
instance && instance.renderProxy,
// in production the hook receives only the error code
__DEV__ ? ErrorTypeStrings[type] : type
)
) {
return return
} }
} }
} }
cur = cur.parent 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
}
}
logError(err, type, contextVNode) logError(err, type, contextVNode)
} }

View File

@ -28,7 +28,7 @@ export { PublicShapeFlags as ShapeFlags } from './shapeFlags'
export { getCurrentInstance } from './component' export { getCurrentInstance } from './component'
// For custom renderers // For custom renderers
export { createAppAPI } from './apiCreateApp' export { createAppAPI } from './apiApp'
export { createRenderer } from './createRenderer' export { createRenderer } from './createRenderer'
export { export {
handleError, handleError,
@ -42,8 +42,8 @@ export { applyDirectives, resolveDirective } from './directives'
// Types ----------------------------------------------------------------------- // Types -----------------------------------------------------------------------
export { App } from './apiCreateApp' export { App, AppConfig, AppContext, Plugin } from './apiApp'
export { VNode } from './vnode' export { VNode, VNodeTypes } from './vnode'
export { FunctionalComponent, ComponentInstance } from './component' export { FunctionalComponent, ComponentInstance } from './component'
export { RendererOptions } from './createRenderer' export { RendererOptions } from './createRenderer'
export { Slot, Slots } from './componentSlots' export { Slot, Slots } from './componentSlots'

View File

@ -12,7 +12,7 @@ import { RawSlots } from './componentSlots'
import { PatchFlags } from './patchFlags' import { PatchFlags } from './patchFlags'
import { ShapeFlags } from './shapeFlags' import { ShapeFlags } from './shapeFlags'
import { isReactive } from '@vue/reactivity' import { isReactive } from '@vue/reactivity'
import { AppContext } from './apiCreateApp' import { AppContext } from './apiApp'
export const Fragment = Symbol('Fragment') export const Fragment = Symbol('Fragment')
export const Text = Symbol('Text') export const Text = Symbol('Text')

View File

@ -21,13 +21,24 @@ export function popWarningContext() {
} }
export function warn(msg: string, ...args: any[]) { 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) console.warn(`[Vue warn]: ${msg}`, ...args)
// avoid spamming console during tests // avoid spamming console during tests
if (typeof process !== 'undefined' && process.env.NODE_ENV === 'test') { if (typeof process !== 'undefined' && process.env.NODE_ENV === 'test') {
return return
} }
const trace = getComponentTrace()
if (!trace.length) { if (!trace.length) {
return return
} }
@ -41,16 +52,7 @@ export function warn(msg: string, ...args: any[]) {
console.log(...logs) console.log(...logs)
console.groupEnd() console.groupEnd()
} else { } else {
const logs: string[] = [] console.log(...formatTrace(trace))
trace.forEach((entry, i) => {
const formatted = formatTraceEntry(entry, i)
if (i === 0) {
logs.push('at', ...formatted)
} else {
logs.push('\n', ...formatted)
}
})
console.log(...logs)
} }
} }
@ -83,6 +85,19 @@ function getComponentTrace(): ComponentTraceStack {
return normlaizedStack 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( function formatTraceEntry(
{ vnode, recurseCount }: TraceEntry, { vnode, recurseCount }: TraceEntry,
depth: number = 0 depth: number = 0