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
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', () => {

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,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
}

View File

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

View File

@ -6,7 +6,7 @@ export interface InjectionKey<T> extends Symbol {}
export function provide<T>(key: InjectionKey<T> | 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<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, defaultValue: T): T
export function inject(key: InjectionKey<any> | 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().`)
}
}

View File

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

View File

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

View File

@ -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<number | string, string> = {
[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)
}

View File

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

View File

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

View File

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