feat: error handling for setup / render / watch / event handlers

This commit is contained in:
Evan You 2019-08-30 15:05:39 -04:00
parent 1d55b368e8
commit 966d7b5487
11 changed files with 219 additions and 72 deletions

View File

@ -0,0 +1,17 @@
describe('error handling', () => {
test.todo('in lifecycle hooks')
test.todo('in onErrorCaptured')
test.todo('in setup function')
test.todo('in render function')
test.todo('in watch (simple usage)')
test.todo('in watch (with source)')
test.todo('in native event handler')
test.todo('in component event handler')
})

View File

@ -5,7 +5,9 @@ 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) {
// TODO warn if (__DEV__) {
warn(`provide() is used without an active component instance.`)
}
} else { } else {
let provides = currentInstance.provides let provides = currentInstance.provides
// by default an instance inherits its parent's provides object // by default an instance inherits its parent's provides object

View File

@ -4,7 +4,7 @@ import {
currentInstance, currentInstance,
setCurrentInstance setCurrentInstance
} from './component' } from './component'
import { callUserFnWithErrorHandling, ErrorTypeStrings } from './errorHandling' import { callWithAsyncErrorHandling, ErrorTypeStrings } from './errorHandling'
import { warn } from './warning' import { warn } from './warning'
import { capitalize } from '@vue/shared' import { capitalize } from '@vue/shared'
@ -19,7 +19,7 @@ function injectHook(
// This assumes the hook does not synchronously trigger other hooks, which // This assumes the hook does not synchronously trigger other hooks, which
// can only be false when the user does something really funky. // can only be false when the user does something really funky.
setCurrentInstance(target) setCurrentInstance(target)
const res = callUserFnWithErrorHandling(hook, target, type, args) const res = callWithAsyncErrorHandling(hook, target, type, args)
setCurrentInstance(null) setCurrentInstance(null)
return res return res
}) })

View File

@ -9,6 +9,11 @@ import { queueJob, queuePostFlushCb } from './scheduler'
import { EMPTY_OBJ, isObject, isArray, isFunction } from '@vue/shared' import { EMPTY_OBJ, isObject, isArray, isFunction } from '@vue/shared'
import { recordEffect } from './apiReactivity' import { recordEffect } from './apiReactivity'
import { getCurrentInstance } from './component' import { getCurrentInstance } from './component'
import {
UserExecutionContexts,
callWithErrorHandling,
callWithAsyncErrorHandling
} from './errorHandling'
export interface WatchOptions { export interface WatchOptions {
lazy?: boolean lazy?: boolean
@ -78,22 +83,57 @@ function doWatch(
| null, | null,
{ lazy, deep, flush, onTrack, onTrigger }: WatchOptions = EMPTY_OBJ { lazy, deep, flush, onTrack, onTrigger }: WatchOptions = EMPTY_OBJ
): StopHandle { ): StopHandle {
const baseGetter = isArray(source) const instance = getCurrentInstance()
? () => source.map(s => (isRef(s) ? s.value : s()))
: isRef(source)
? () => source.value
: () => {
if (cleanup) {
cleanup()
}
return source(registerCleanup)
}
const getter = deep ? () => traverse(baseGetter()) : baseGetter
let cleanup: any let getter: Function
if (isArray(source)) {
getter = () =>
source.map(
s =>
isRef(s)
? s.value
: callWithErrorHandling(
s,
instance,
UserExecutionContexts.WATCH_GETTER
)
)
} else if (isRef(source)) {
getter = () => source.value
} else if (cb) {
// getter with cb
getter = () =>
callWithErrorHandling(
source,
instance,
UserExecutionContexts.WATCH_GETTER
)
} else {
// no cb -> simple effect
getter = () => {
if (cleanup) {
cleanup()
}
return callWithErrorHandling(
source,
instance,
UserExecutionContexts.WATCH_CALLBACK,
[registerCleanup]
)
}
}
if (deep) {
const baseGetter = getter
getter = () => traverse(baseGetter())
}
let cleanup: Function
const registerCleanup: CleanupRegistrator = (fn: () => void) => { const registerCleanup: CleanupRegistrator = (fn: () => void) => {
// TODO wrap the cleanup fn for error handling // TODO wrap the cleanup fn for error handling
cleanup = runner.onStop = fn cleanup = runner.onStop = () => {
callWithErrorHandling(fn, instance, UserExecutionContexts.WATCH_CLEANUP)
}
} }
let oldValue = isArray(source) ? [] : undefined let oldValue = isArray(source) ? [] : undefined
@ -105,16 +145,17 @@ function doWatch(
if (cleanup) { if (cleanup) {
cleanup() cleanup()
} }
// TODO handle error (including ASYNC) callWithAsyncErrorHandling(
try { cb,
cb(newValue, oldValue, registerCleanup) instance,
} catch (e) {} UserExecutionContexts.WATCH_CALLBACK,
[newValue, oldValue, registerCleanup]
)
oldValue = newValue oldValue = newValue
} }
} }
: void 0 : void 0
const instance = getCurrentInstance()
const scheduler = const scheduler =
flush === 'sync' flush === 'sync'
? invoke ? invoke

View File

@ -1,11 +1,18 @@
import { VNode, normalizeVNode, VNodeChild } from './vnode' import { VNode, normalizeVNode, VNodeChild, createVNode, Empty } from './vnode'
import { ReactiveEffect, UnwrapRef, reactive, readonly } from '@vue/reactivity' import { ReactiveEffect, UnwrapRef, reactive, readonly } from '@vue/reactivity'
import { EMPTY_OBJ, isFunction, capitalize, invokeHandlers } from '@vue/shared' import { EMPTY_OBJ, isFunction, capitalize, NOOP, isArray } from '@vue/shared'
import { RenderProxyHandlers } from './componentProxy' import { RenderProxyHandlers } from './componentProxy'
import { ComponentPropsOptions, ExtractPropTypes } from './componentProps' import { ComponentPropsOptions, ExtractPropTypes } from './componentProps'
import { Slots } from './componentSlots' import { Slots } from './componentSlots'
import { PatchFlags } from './patchFlags' import { PatchFlags } from './patchFlags'
import { ShapeFlags } from './shapeFlags' import { ShapeFlags } from './shapeFlags'
import { warn } from './warning'
import {
UserExecutionContexts,
handleError,
callWithErrorHandling,
callWithAsyncErrorHandling
} from './errorHandling'
export type Data = { [key: string]: unknown } export type Data = { [key: string]: unknown }
@ -226,7 +233,23 @@ export function createComponentInstance(
const props = instance.vnode.props || EMPTY_OBJ const props = instance.vnode.props || EMPTY_OBJ
const handler = props[`on${event}`] || props[`on${capitalize(event)}`] const handler = props[`on${event}`] || props[`on${capitalize(event)}`]
if (handler) { if (handler) {
invokeHandlers(handler, args) if (isArray(handler)) {
for (let i = 0; i < handler.length; i++) {
callWithAsyncErrorHandling(
handler[i],
instance,
UserExecutionContexts.COMPONENT_EVENT_HANDLER,
args
)
}
} else {
callWithAsyncErrorHandling(
handler,
instance,
UserExecutionContexts.COMPONENT_EVENT_HANDLER,
args
)
}
} }
} }
} }
@ -261,7 +284,12 @@ export function setupStatefulComponent(instance: ComponentInstance) {
setup.length > 1 ? createSetupContext(instance) : null) setup.length > 1 ? createSetupContext(instance) : null)
currentInstance = instance currentInstance = instance
const setupResult = setup.call(null, propsProxy, setupContext) const setupResult = callWithErrorHandling(
setup,
instance,
UserExecutionContexts.SETUP_FUNCTION,
[propsProxy, setupContext]
)
currentInstance = null currentInstance = null
if (isFunction(setupResult)) { if (isFunction(setupResult)) {
@ -272,9 +300,12 @@ export function setupStatefulComponent(instance: ComponentInstance) {
// assuming a render function compiled from template is present. // assuming a render function compiled from template is present.
instance.data = reactive(setupResult || {}) instance.data = reactive(setupResult || {})
if (__DEV__ && !Component.render) { if (__DEV__ && !Component.render) {
// TODO warn missing render fn warn(
`Component is missing render function. Either provide a template or ` +
`return a render function from setup().`
)
} }
instance.render = Component.render as RenderFunction instance.render = (Component.render || NOOP) as RenderFunction
} }
} else { } else {
if (__DEV__ && !Component.render) { if (__DEV__ && !Component.render) {
@ -327,23 +358,32 @@ export function renderComponentRoot(instance: ComponentInstance): VNode {
refs, refs,
emit emit
} = instance } = instance
if (vnode.shapeFlag & ShapeFlags.STATEFUL_COMPONENT) { try {
return normalizeVNode( if (vnode.shapeFlag & ShapeFlags.STATEFUL_COMPONENT) {
(instance.render as RenderFunction).call(renderProxy, props, setupContext) return normalizeVNode(
) (instance.render as RenderFunction).call(
} else { renderProxy,
// functional props,
const render = Component as FunctionalComponent setupContext
return normalizeVNode( )
render.length > 1 )
? render(props, { } else {
attrs, // functional
slots, const render = Component as FunctionalComponent
refs, return normalizeVNode(
emit render.length > 1
}) ? render(props, {
: render(props, null as any) attrs,
) slots,
refs,
emit
})
: render(props, null as any)
)
}
} catch (err) {
handleError(err, instance, UserExecutionContexts.RENDER_FUNCTION)
return createVNode(Empty)
} }
} }

View File

@ -5,8 +5,11 @@ import { warn, pushWarningContext, popWarningContext } from './warning'
// contexts where user provided function may be executed, in addition to // contexts where user provided function may be executed, in addition to
// lifecycle hooks. // lifecycle hooks.
export const enum UserExecutionContexts { export const enum UserExecutionContexts {
RENDER_FUNCTION = 1, SETUP_FUNCTION = 1,
RENDER_FUNCTION,
WATCH_GETTER,
WATCH_CALLBACK, WATCH_CALLBACK,
WATCH_CLEANUP,
NATIVE_EVENT_HANDLER, NATIVE_EVENT_HANDLER,
COMPONENT_EVENT_HANDLER, COMPONENT_EVENT_HANDLER,
SCHEDULER SCHEDULER
@ -26,8 +29,11 @@ export const ErrorTypeStrings: Record<number | string, string> = {
[LifecycleHooks.ERROR_CAPTURED]: 'errorCaptured hook', [LifecycleHooks.ERROR_CAPTURED]: 'errorCaptured hook',
[LifecycleHooks.RENDER_TRACKED]: 'renderTracked hook', [LifecycleHooks.RENDER_TRACKED]: 'renderTracked hook',
[LifecycleHooks.RENDER_TRIGGERED]: 'renderTriggered hook', [LifecycleHooks.RENDER_TRIGGERED]: 'renderTriggered hook',
[UserExecutionContexts.SETUP_FUNCTION]: 'setup function',
[UserExecutionContexts.RENDER_FUNCTION]: 'render function', [UserExecutionContexts.RENDER_FUNCTION]: 'render function',
[UserExecutionContexts.WATCH_GETTER]: 'watcher getter',
[UserExecutionContexts.WATCH_CALLBACK]: 'watcher callback', [UserExecutionContexts.WATCH_CALLBACK]: 'watcher callback',
[UserExecutionContexts.WATCH_CLEANUP]: 'watcher cleanup function',
[UserExecutionContexts.NATIVE_EVENT_HANDLER]: 'native event handler', [UserExecutionContexts.NATIVE_EVENT_HANDLER]: 'native event handler',
[UserExecutionContexts.COMPONENT_EVENT_HANDLER]: 'component event handler', [UserExecutionContexts.COMPONENT_EVENT_HANDLER]: 'component event handler',
[UserExecutionContexts.SCHEDULER]: [UserExecutionContexts.SCHEDULER]:
@ -37,7 +43,7 @@ export const ErrorTypeStrings: Record<number | string, string> = {
type ErrorTypes = LifecycleHooks | UserExecutionContexts type ErrorTypes = LifecycleHooks | UserExecutionContexts
export function callUserFnWithErrorHandling( export function callWithErrorHandling(
fn: Function, fn: Function,
instance: ComponentInstance | null, instance: ComponentInstance | null,
type: ErrorTypes, type: ErrorTypes,
@ -46,17 +52,27 @@ export function callUserFnWithErrorHandling(
let res: any let res: any
try { try {
res = args ? fn(...args) : fn() res = args ? fn(...args) : fn()
if (res && !res._isVue && typeof res.then === 'function') {
;(res as Promise<any>).catch(err => {
handleError(err, instance, type)
})
}
} catch (err) { } catch (err) {
handleError(err, instance, type) handleError(err, instance, type)
} }
return res return res
} }
export function callWithAsyncErrorHandling(
fn: Function,
instance: ComponentInstance | null,
type: ErrorTypes,
args?: any[]
) {
const res = callWithErrorHandling(fn, instance, type, args)
if (res != null && !res._isVue && typeof res.then === 'function') {
;(res as Promise<any>).catch(err => {
handleError(err, instance, type)
})
}
return res
}
export function handleError( export function handleError(
err: Error, err: Error,
instance: ComponentInstance | null, instance: ComponentInstance | null,
@ -68,7 +84,13 @@ export function handleError(
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 (errorCapturedHooks[i](err, type, contextVNode)) { if (
errorCapturedHooks[i](
err,
instance && instance.renderProxy,
contextVNode
)
) {
return return
} }
} }

View File

@ -29,11 +29,16 @@ export { getCurrentInstance } from './component'
// For custom renderers // For custom renderers
export { createRenderer } from './createRenderer' export { createRenderer } from './createRenderer'
export {
handleError,
callWithErrorHandling,
callWithAsyncErrorHandling
} from './errorHandling'
// Types ----------------------------------------------------------------------- // Types -----------------------------------------------------------------------
export { VNode } from './vnode' export { VNode } from './vnode'
export { FunctionalComponent } 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'
export { PropType, ComponentPropsOptions } from './componentProps' export { PropType, ComponentPropsOptions } from './componentProps'

View File

@ -27,6 +27,10 @@ export function warn(msg: string, ...args: any[]) {
if (!trace.length) { if (!trace.length) {
return return
} }
// avoid spamming test output
if (typeof process !== 'undefined' && process.env.NODE_ENV === 'test') {
return
}
if (trace.length > 1 && console.groupCollapsed) { if (trace.length > 1 && console.groupCollapsed) {
console.groupCollapsed('at', ...formatTraceEntry(trace[0])) console.groupCollapsed('at', ...formatTraceEntry(trace[0]))
const logs: string[] = [] const logs: string[] = []

View File

@ -1,4 +1,9 @@
import { invokeHandlers } from '@vue/shared' import { isArray } from '@vue/shared'
import {
ComponentInstance,
callWithAsyncErrorHandling
} from '@vue/runtime-core'
import { UserExecutionContexts } from 'packages/runtime-core/src/errorHandling'
interface Invoker extends Function { interface Invoker extends Function {
value: EventValue value: EventValue
@ -39,7 +44,8 @@ export function patchEvent(
el: Element, el: Element,
name: string, name: string,
prevValue: EventValue | null, prevValue: EventValue | null,
nextValue: EventValue | null nextValue: EventValue | null,
instance: ComponentInstance | null
) { ) {
const invoker = prevValue && prevValue.invoker const invoker = prevValue && prevValue.invoker
if (nextValue) { if (nextValue) {
@ -49,14 +55,14 @@ export function patchEvent(
nextValue.invoker = invoker nextValue.invoker = invoker
invoker.lastUpdated = getNow() invoker.lastUpdated = getNow()
} else { } else {
el.addEventListener(name, createInvoker(nextValue)) el.addEventListener(name, createInvoker(nextValue, instance))
} }
} else if (invoker) { } else if (invoker) {
el.removeEventListener(name, invoker as any) el.removeEventListener(name, invoker as any)
} }
} }
function createInvoker(value: any) { function createInvoker(value: any, instance: ComponentInstance | null) {
const invoker = ((e: Event) => { const invoker = ((e: Event) => {
// async edge case #6566: inner click event triggers patch, event handler // async edge case #6566: inner click event triggers patch, event handler
// attached to outer element during patch, and triggered again. This // attached to outer element during patch, and triggered again. This
@ -65,7 +71,24 @@ function createInvoker(value: any) {
// and the handler would only fire if the event passed to it was fired // and the handler would only fire if the event passed to it was fired
// AFTER it was attached. // AFTER it was attached.
if (e.timeStamp >= invoker.lastUpdated) { if (e.timeStamp >= invoker.lastUpdated) {
invokeHandlers(invoker.value, [e]) const args = [e]
if (isArray(value)) {
for (let i = 0; i < value.length; i++) {
callWithAsyncErrorHandling(
value[i],
instance,
UserExecutionContexts.NATIVE_EVENT_HANDLER,
args
)
}
} else {
callWithAsyncErrorHandling(
value,
instance,
UserExecutionContexts.NATIVE_EVENT_HANDLER,
args
)
}
} }
}) as any }) as any
invoker.value = value invoker.value = value

View File

@ -26,7 +26,13 @@ export function patchProp(
break break
default: default:
if (isOn(key)) { if (isOn(key)) {
patchEvent(el, key.slice(2).toLowerCase(), prevValue, nextValue) patchEvent(
el,
key.slice(2).toLowerCase(),
prevValue,
nextValue,
parentComponent
)
} else if (!isSVG && key in el) { } else if (!isSVG && key in el) {
patchDOMProp( patchDOMProp(
el, el,

View File

@ -40,16 +40,3 @@ export const hyphenate = (str: string): string => {
export const capitalize = (str: string): string => { export const capitalize = (str: string): string => {
return str.charAt(0).toUpperCase() + str.slice(1) return str.charAt(0).toUpperCase() + str.slice(1)
} }
export function invokeHandlers(
handlers: Function | Function[],
args: any[] = EMPTY_ARR
) {
if (isArray(handlers)) {
for (let i = 0; i < handlers.length; i++) {
handlers[i].apply(null, args)
}
} else {
handlers.apply(null, args)
}
}