From 3d681f8bcd7bfdfde49aaa77e726808f6d20a78c Mon Sep 17 00:00:00 2001 From: Evan You Date: Fri, 30 Aug 2019 12:16:09 -0400 Subject: [PATCH] feat: error handling for lifecycle hooks --- packages/runtime-core/__tests__/vnode.spec.ts | 2 +- packages/runtime-core/src/apiLifecycle.ts | 102 +++++++++++++----- packages/runtime-core/src/component.ts | 76 ++++++++----- packages/runtime-core/src/errorHandling.ts | 99 ++++++++++++++++- 4 files changed, 225 insertions(+), 54 deletions(-) diff --git a/packages/runtime-core/__tests__/vnode.spec.ts b/packages/runtime-core/__tests__/vnode.spec.ts index e3b8ad9fe..6cdc6f7b6 100644 --- a/packages/runtime-core/__tests__/vnode.spec.ts +++ b/packages/runtime-core/__tests__/vnode.spec.ts @@ -15,7 +15,7 @@ describe('vnode', () => { test.todo('normalizeVNode') - test.todo('node type inference') + test.todo('node type/shapeFlag inference') test.todo('cloneVNode') diff --git a/packages/runtime-core/src/apiLifecycle.ts b/packages/runtime-core/src/apiLifecycle.ts index c59760158..d1e95cfc7 100644 --- a/packages/runtime-core/src/apiLifecycle.ts +++ b/packages/runtime-core/src/apiLifecycle.ts @@ -1,51 +1,101 @@ -import { ComponentInstance, LifecycleHooks, currentInstance } from './component' +import { + ComponentInstance, + LifecycleHooks, + currentInstance, + setCurrentInstance +} from './component' +import { applyErrorHandling, ErrorTypeStrings } from './errorHandling' +import { warn } from './warning' +import { capitalize } from '@vue/shared' function injectHook( - name: keyof LifecycleHooks, + type: LifecycleHooks, hook: Function, - target: ComponentInstance | null | void = currentInstance + target: ComponentInstance | null = currentInstance ) { if (target) { - // TODO inject a error-handling wrapped version of the hook - // TODO also set currentInstance when calling the hook - ;(target[name] || (target[name] = [])).push(hook) - } else { - // TODO warn + // wrap user hook with error handling logic + const withErrorHandling = applyErrorHandling(hook, target, type) + ;(target[type] || (target[type] = [])).push((...args: any[]) => { + // Set currentInstance during hook invocation. + // This assumes the hook does not synchronously trigger other hooks, which + // can only be false when the user does something really funky. + setCurrentInstance(target) + const res = withErrorHandling(...args) + setCurrentInstance(null) + return res + }) + } else if (__DEV__) { + const apiName = `on${capitalize( + ErrorTypeStrings[name].replace(/ hook$/, '') + )}` + warn( + `${apiName} is called when there is no active component instance to be ` + + `associated with. ` + + `Lifecycle injection APIs can only be used during execution of setup().` + ) } } -export function onBeforeMount(hook: Function, target?: ComponentInstance) { - injectHook('bm', hook, target) +export function onBeforeMount( + hook: Function, + target: ComponentInstance | null = currentInstance +) { + injectHook(LifecycleHooks.BEFORE_MOUNT, hook, target) } -export function onMounted(hook: Function, target?: ComponentInstance) { - injectHook('m', hook, target) +export function onMounted( + hook: Function, + target: ComponentInstance | null = currentInstance +) { + injectHook(LifecycleHooks.MOUNTED, hook, target) } -export function onBeforeUpdate(hook: Function, target?: ComponentInstance) { - injectHook('bu', hook, target) +export function onBeforeUpdate( + hook: Function, + target: ComponentInstance | null = currentInstance +) { + injectHook(LifecycleHooks.BEFORE_UPDATE, hook, target) } -export function onUpdated(hook: Function, target?: ComponentInstance) { - injectHook('u', hook, target) +export function onUpdated( + hook: Function, + target: ComponentInstance | null = currentInstance +) { + injectHook(LifecycleHooks.UPDATED, hook, target) } -export function onBeforeUnmount(hook: Function, target?: ComponentInstance) { - injectHook('bum', hook, target) +export function onBeforeUnmount( + hook: Function, + target: ComponentInstance | null = currentInstance +) { + injectHook(LifecycleHooks.BEFORE_UNMOUNT, hook, target) } -export function onUnmounted(hook: Function, target?: ComponentInstance) { - injectHook('um', hook, target) +export function onUnmounted( + hook: Function, + target: ComponentInstance | null = currentInstance +) { + injectHook(LifecycleHooks.UNMOUNTED, hook, target) } -export function onRenderTriggered(hook: Function, target?: ComponentInstance) { - injectHook('rtg', hook, target) +export function onRenderTriggered( + hook: Function, + target: ComponentInstance | null = currentInstance +) { + injectHook(LifecycleHooks.RENDER_TRIGGERED, hook, target) } -export function onRenderTracked(hook: Function, target?: ComponentInstance) { - injectHook('rtc', hook, target) +export function onRenderTracked( + hook: Function, + target: ComponentInstance | null = currentInstance +) { + injectHook(LifecycleHooks.RENDER_TRACKED, hook, target) } -export function onErrorCaptured(hook: Function, target?: ComponentInstance) { - injectHook('ec', hook, target) +export function onErrorCaptured( + hook: Function, + target: ComponentInstance | null = currentInstance +) { + injectHook(LifecycleHooks.ERROR_CAPTURED, hook, target) } diff --git a/packages/runtime-core/src/component.ts b/packages/runtime-core/src/component.ts index 8b27fe863..f4e0d204a 100644 --- a/packages/runtime-core/src/component.ts +++ b/packages/runtime-core/src/component.ts @@ -74,18 +74,20 @@ export interface FunctionalComponent

{ type LifecycleHook = Function[] | null -export interface LifecycleHooks { - bm: LifecycleHook // beforeMount - m: LifecycleHook // mounted - bu: LifecycleHook // beforeUpdate - u: LifecycleHook // updated - bum: LifecycleHook // beforeUnmount - um: LifecycleHook // unmounted - da: LifecycleHook // deactivated - a: LifecycleHook // activated - rtg: LifecycleHook // renderTriggered - rtc: LifecycleHook // renderTracked - ec: LifecycleHook // errorCaptured +export const enum LifecycleHooks { + BEFORE_CREATE = 'bc', + CREATED = 'c', + BEFORE_MOUNT = 'bm', + MOUNTED = 'm', + BEFORE_UPDATE = 'bu', + UPDATED = 'u', + BEFORE_UNMOUNT = 'bum', + UNMOUNTED = 'um', + DEACTIVATED = 'da', + ACTIVATED = 'a', + RENDER_TRIGGERED = 'rtg', + RENDER_TRACKED = 'rtc', + ERROR_CAPTURED = 'ec' } interface SetupContext { @@ -116,8 +118,22 @@ export type ComponentInstance

= { // user namespace user: { [key: string]: any } -} & SetupContext & - LifecycleHooks + + // lifecycle + [LifecycleHooks.BEFORE_CREATE]: LifecycleHook + [LifecycleHooks.CREATED]: LifecycleHook + [LifecycleHooks.BEFORE_MOUNT]: LifecycleHook + [LifecycleHooks.MOUNTED]: LifecycleHook + [LifecycleHooks.BEFORE_UPDATE]: LifecycleHook + [LifecycleHooks.UPDATED]: LifecycleHook + [LifecycleHooks.BEFORE_UNMOUNT]: LifecycleHook + [LifecycleHooks.UNMOUNTED]: LifecycleHook + [LifecycleHooks.RENDER_TRACKED]: LifecycleHook + [LifecycleHooks.RENDER_TRIGGERED]: LifecycleHook + [LifecycleHooks.ACTIVATED]: LifecycleHook + [LifecycleHooks.DEACTIVATED]: LifecycleHook + [LifecycleHooks.ERROR_CAPTURED]: LifecycleHook +} & SetupContext // createComponent // overload 1: direct setup function @@ -177,7 +193,23 @@ export function createComponentInstance( renderProxy: null, propsProxy: null, setupContext: null, + effects: null, + provides: parent ? parent.provides : {}, + // setup context properties + data: EMPTY_OBJ, + props: EMPTY_OBJ, + attrs: EMPTY_OBJ, + slots: EMPTY_OBJ, + refs: EMPTY_OBJ, + + // user namespace for storing whatever the user assigns to `this` + user: {}, + + // lifecycle hooks + // not using enums here because it results in computed properties + bc: null, + c: null, bm: null, m: null, bu: null, @@ -189,18 +221,6 @@ export function createComponentInstance( rtg: null, rtc: null, ec: null, - effects: null, - provides: parent ? parent.provides : {}, - - // public properties - data: EMPTY_OBJ, - props: EMPTY_OBJ, - attrs: EMPTY_OBJ, - slots: EMPTY_OBJ, - refs: EMPTY_OBJ, - - // user namespace for storing whatever the user assigns to `this` - user: {}, emit: (event: string, ...args: unknown[]) => { const props = instance.vnode.props || EMPTY_OBJ @@ -220,6 +240,10 @@ export let currentInstance: ComponentInstance | null = null export const getCurrentInstance: () => ComponentInstance | null = () => currentInstance +export const setCurrentInstance = (instance: ComponentInstance | null) => { + currentInstance = instance +} + export function setupStatefulComponent(instance: ComponentInstance) { const Component = instance.type as ComponentOptions // 1. create render proxy diff --git a/packages/runtime-core/src/errorHandling.ts b/packages/runtime-core/src/errorHandling.ts index 70b786d12..202051328 100644 --- a/packages/runtime-core/src/errorHandling.ts +++ b/packages/runtime-core/src/errorHandling.ts @@ -1 +1,98 @@ -// TODO +import { VNode } from './vnode' +import { ComponentInstance, LifecycleHooks } from './component' +import { warn, pushWarningContext, popWarningContext } from './warning' + +// contexts where user provided function may be executed, in addition to +// lifecycle hooks. +export const enum UserExecutionContexts { + RENDER_FUNCTION = 1, + WATCH_CALLBACK, + NATIVE_EVENT_HANDLER, + COMPONENT_EVENT_HANDLER, + SCHEDULER +} + +export const ErrorTypeStrings: Record = { + [LifecycleHooks.BEFORE_CREATE]: 'beforeCreate hook', + [LifecycleHooks.CREATED]: 'created hook', + [LifecycleHooks.BEFORE_MOUNT]: 'beforeMount hook', + [LifecycleHooks.MOUNTED]: 'mounted hook', + [LifecycleHooks.BEFORE_UPDATE]: 'beforeUpdate hook', + [LifecycleHooks.UPDATED]: 'updated', + [LifecycleHooks.BEFORE_UNMOUNT]: 'beforeUnmount hook', + [LifecycleHooks.UNMOUNTED]: 'unmounted hook', + [LifecycleHooks.ACTIVATED]: 'activated hook', + [LifecycleHooks.DEACTIVATED]: 'deactivated hook', + [LifecycleHooks.ERROR_CAPTURED]: 'errorCaptured hook', + [LifecycleHooks.RENDER_TRACKED]: 'renderTracked hook', + [LifecycleHooks.RENDER_TRIGGERED]: 'renderTriggered hook', + [UserExecutionContexts.RENDER_FUNCTION]: 'render function', + [UserExecutionContexts.WATCH_CALLBACK]: 'watcher callback', + [UserExecutionContexts.NATIVE_EVENT_HANDLER]: 'native event handler', + [UserExecutionContexts.COMPONENT_EVENT_HANDLER]: 'component event handler', + [UserExecutionContexts.SCHEDULER]: + 'scheduler flush. This may be a Vue internals bug. ' + + 'Please open an issue at https://new-issue.vuejs.org/?repo=vuejs/vue' +} + +type ErrorTypes = LifecycleHooks | UserExecutionContexts + +// takes a user-provided function and returns a verison that handles potential +// errors (including async) +export function applyErrorHandling( + fn: T, + instance: ComponentInstance | null, + type: ErrorTypes +): T { + return function errorHandlingWrapper(...args: any[]) { + let res: any + try { + res = fn(...args) + if (res && !res._isVue && typeof res.then === 'function') { + ;(res as Promise).catch(err => { + handleError(err, instance, type) + }) + } + } catch (err) { + handleError(err, instance, type) + } + return res + } as any +} + +export function handleError( + err: Error, + instance: ComponentInstance | null, + type: ErrorTypes +) { + 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, type, contextVNode)) { + return + } + } + } + cur = cur.parent + } + logError(err, type, contextVNode) +} + +function logError(err: Error, type: ErrorTypes, contextVNode: VNode | null) { + if (__DEV__) { + const info = ErrorTypeStrings[type] + if (contextVNode) { + pushWarningContext(contextVNode) + } + warn(`Unhandled error${info ? ` during execution of ${info}` : ``}`) + console.error(err) + if (contextVNode) { + popWarningContext() + } + } else { + throw err + } +}