Skip to content

Commit 5605d48

Browse files
johnjenkinsrwaskiewiczJohn Jenkins
authored
feat(runtime): Add element to component error handler. Enables error boundaries (#2979)
* feat(emit error events): emit custom event on component error within lifecycle or render * test(add test for component error handling) * revert un-required changes * Added host element to loadModule error handling * chore(format): add prettier - upgrade prettier to v2.3.2 - lock version to prevent breaking changes in minor versions - add prettier.dry-run package.json script - add pipeline action to evaluate format status - add prettierignore file for faster runs STENCIL-8: Add Prettier to Stencil * format codebase * revert cherry pick * feat(emit error events): emit custom event on component error within lifecycle or render * test(add test for component error handling) * revert un-required changes * Added host element to loadModule error handling * revert cherry pick * run prettier * rm rv karma/test-components * rv extra prettier call * Flaky test? * fix lint * fixup `strictNullChecks` issues * chore: tidy * chore: formatting * chore: revert type --------- Co-authored-by: Ryan Waskiewicz <ryanwaskiewicz@gmail.com> Co-authored-by: John Jenkins <john.jenkins@nanoporetech.com>
1 parent a7d3873 commit 5605d48

File tree

9 files changed

+128
-30
lines changed

9 files changed

+128
-30
lines changed

src/client/client-load-module.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -49,11 +49,18 @@ export const loadModule = (
4949
/* webpackInclude: /\.entry\.js$/ */
5050
/* webpackExclude: /\.system\.entry\.js$/ */
5151
/* webpackMode: "lazy" */
52-
`${MODULE_IMPORT_PREFIX}${bundleId}.entry.js${BUILD.hotModuleReplacement && hmrVersionId ? '?s-hmr=' + hmrVersionId : ''}`
53-
).then((importedModule) => {
54-
if (!BUILD.hotModuleReplacement) {
55-
cmpModules.set(bundleId, importedModule);
56-
}
57-
return importedModule[exportName];
58-
}, consoleError);
52+
`${MODULE_IMPORT_PREFIX}${bundleId}.entry.js${
53+
BUILD.hotModuleReplacement && hmrVersionId ? '?s-hmr=' + hmrVersionId : ''
54+
}`
55+
).then(
56+
(importedModule) => {
57+
if (!BUILD.hotModuleReplacement) {
58+
cmpModules.set(bundleId, importedModule);
59+
}
60+
return importedModule[exportName];
61+
},
62+
(e: Error) => {
63+
consoleError(e, hostRef.$hostElement$);
64+
},
65+
);
5966
};

src/client/client-log.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type * as d from '../declarations';
44

55
let customError: d.ErrorHandler;
66

7-
export const consoleError: d.ErrorHandler = (e: any, el?: any) => (customError || console.error)(e, el);
7+
export const consoleError: d.ErrorHandler = (e: any, el?: HTMLElement) => (customError || console.error)(e, el);
88

99
export const STENCIL_DEV_MODE = BUILD.isTesting
1010
? ['STENCIL:'] // E2E testing

src/hydrate/platform/proxy-host-element.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,11 @@ export function proxyHostElement(elm: d.HostElement, cstr: d.ComponentConstructo
121121
Object.defineProperty(elm, memberName, {
122122
value(this: d.HostElement, ...args: any[]) {
123123
const ref = getHostRef(this);
124-
return ref?.$onInstancePromise$?.then(() => ref?.$lazyInstance$?.[memberName](...args)).catch(consoleError);
124+
return ref?.$onInstancePromise$
125+
?.then(() => ref?.$lazyInstance$?.[memberName](...args))
126+
.catch((e) => {
127+
consoleError(e, this);
128+
});
125129
},
126130
});
127131
}

src/runtime/connected-callback.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,9 +113,9 @@ export const connectedCallback = (elm: d.HostElement) => {
113113

114114
// fire off connectedCallback() on component instance
115115
if (hostRef?.$lazyInstance$) {
116-
fireConnectedCallback(hostRef.$lazyInstance$);
116+
fireConnectedCallback(hostRef.$lazyInstance$, elm);
117117
} else if (hostRef?.$onReadyPromise$) {
118-
hostRef.$onReadyPromise$.then(() => fireConnectedCallback(hostRef.$lazyInstance$));
118+
hostRef.$onReadyPromise$.then(() => fireConnectedCallback(hostRef.$lazyInstance$, elm));
119119
}
120120
}
121121

src/runtime/disconnected-callback.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,12 @@ import { PLATFORM_FLAGS } from './runtime-constants';
66
import { rootAppliedStyles } from './styles';
77
import { safeCall } from './update-component';
88

9-
const disconnectInstance = (instance: any) => {
9+
const disconnectInstance = (instance: any, elm?: d.HostElement) => {
1010
if (BUILD.lazyLoad && BUILD.disconnectedCallback) {
11-
safeCall(instance, 'disconnectedCallback');
11+
safeCall(instance, 'disconnectedCallback', undefined, elm || instance);
1212
}
1313
if (BUILD.cmpDidUnload) {
14-
safeCall(instance, 'componentDidUnload');
14+
safeCall(instance, 'componentDidUnload', undefined, elm || instance);
1515
}
1616
};
1717

@@ -29,9 +29,9 @@ export const disconnectedCallback = async (elm: d.HostElement) => {
2929
if (!BUILD.lazyLoad) {
3030
disconnectInstance(elm);
3131
} else if (hostRef?.$lazyInstance$) {
32-
disconnectInstance(hostRef.$lazyInstance$);
32+
disconnectInstance(hostRef.$lazyInstance$, elm);
3333
} else if (hostRef?.$onReadyPromise$) {
34-
hostRef.$onReadyPromise$.then(() => disconnectInstance(hostRef.$lazyInstance$));
34+
hostRef.$onReadyPromise$.then(() => disconnectInstance(hostRef.$lazyInstance$, elm));
3535
}
3636
}
3737

src/runtime/host-listener.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ const hostListenerProxy = (hostRef: d.HostRef, methodName: string) => (ev: Event
5454
(hostRef.$hostElement$ as any)[methodName](ev);
5555
}
5656
} catch (e) {
57-
consoleError(e);
57+
consoleError(e, hostRef.$hostElement$);
5858
}
5959
};
6060

src/runtime/initialize-component.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ export const initializeComponent = async (
7777
try {
7878
new (Cstr as any)(hostRef);
7979
} catch (e) {
80-
consoleError(e);
80+
consoleError(e, elm);
8181
}
8282

8383
if (BUILD.member) {
@@ -87,7 +87,7 @@ export const initializeComponent = async (
8787
hostRef.$flags$ |= HOST_FLAGS.isWatchReady;
8888
}
8989
endNewInstance();
90-
fireConnectedCallback(hostRef.$lazyInstance$);
90+
fireConnectedCallback(hostRef.$lazyInstance$, elm);
9191
} else {
9292
// sync constructor component
9393
Cstr = elm.constructor as any;
@@ -189,8 +189,8 @@ export const initializeComponent = async (
189189
}
190190
};
191191

192-
export const fireConnectedCallback = (instance: any) => {
192+
export const fireConnectedCallback = (instance: any, elm?: HTMLElement) => {
193193
if (BUILD.lazyLoad && BUILD.connectedCallback) {
194-
safeCall(instance, 'connectedCallback');
194+
safeCall(instance, 'connectedCallback', undefined, elm);
195195
}
196196
};
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { Component, ComponentInterface, h, Prop, setErrorHandler } from '@stencil/core';
2+
import { newSpecPage } from '@stencil/core/testing';
3+
4+
describe('component error handling', () => {
5+
it('calls a handler with an error and element during every lifecycle hook and render', async () => {
6+
@Component({ tag: 'cmp-a' })
7+
class CmpA implements ComponentInterface {
8+
@Prop() reRender = false;
9+
10+
componentWillLoad() {
11+
throw new Error('componentWillLoad');
12+
}
13+
14+
componentDidLoad() {
15+
throw new Error('componentDidLoad');
16+
}
17+
18+
componentWillRender() {
19+
throw new Error('componentWillRender');
20+
}
21+
22+
componentDidRender() {
23+
throw new Error('componentDidRender');
24+
}
25+
26+
componentWillUpdate() {
27+
throw new Error('componentWillUpdate');
28+
}
29+
30+
componentDidUpdate() {
31+
throw new Error('componentDidUpdate');
32+
}
33+
34+
render() {
35+
if (!this.reRender) return <div></div>;
36+
else throw new Error('render');
37+
}
38+
}
39+
40+
const customErrorHandler = (e: Error, el: HTMLElement) => {
41+
if (!el) return;
42+
el.dispatchEvent(
43+
new CustomEvent('componentError', {
44+
bubbles: true,
45+
cancelable: true,
46+
composed: true,
47+
detail: e,
48+
}),
49+
);
50+
};
51+
setErrorHandler(customErrorHandler);
52+
53+
const { doc, waitForChanges } = await newSpecPage({
54+
components: [CmpA],
55+
html: ``,
56+
});
57+
58+
const handler = jest.fn();
59+
doc.addEventListener('componentError', handler);
60+
const cmpA = document.createElement('cmp-a') as any;
61+
doc.body.appendChild(cmpA);
62+
try {
63+
await waitForChanges();
64+
} catch (e) {}
65+
66+
cmpA.reRender = true;
67+
try {
68+
await waitForChanges();
69+
} catch (e) {}
70+
71+
return Promise.resolve().then(() => {
72+
expect(handler).toHaveBeenCalledTimes(9);
73+
expect(handler.mock.calls[0][0].bubbles).toBe(true);
74+
expect(handler.mock.calls[0][0].cancelable).toBe(true);
75+
expect(handler.mock.calls[0][0].detail).toStrictEqual(Error('componentWillLoad'));
76+
expect(handler.mock.calls[1][0].detail).toStrictEqual(Error('componentWillRender'));
77+
expect(handler.mock.calls[2][0].detail).toStrictEqual(Error('componentDidRender'));
78+
expect(handler.mock.calls[3][0].detail).toStrictEqual(Error('componentDidLoad'));
79+
expect(handler.mock.calls[4][0].detail).toStrictEqual(Error('componentWillUpdate'));
80+
expect(handler.mock.calls[5][0].detail).toStrictEqual(Error('componentWillRender'));
81+
expect(handler.mock.calls[6][0].detail).toStrictEqual(Error('render'));
82+
expect(handler.mock.calls[7][0].detail).toStrictEqual(Error('componentDidRender'));
83+
expect(handler.mock.calls[8][0].detail).toStrictEqual(Error('componentDidUpdate'));
84+
});
85+
});
86+
});

src/runtime/update-component.ts

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ const dispatchHooks = (hostRef: d.HostRef, isInitialLoad: boolean): Promise<void
9292
if (BUILD.lazyLoad && BUILD.hostListener) {
9393
hostRef.$flags$ |= HOST_FLAGS.isListenReady;
9494
if (hostRef.$queuedListeners$) {
95-
hostRef.$queuedListeners$.map(([methodName, event]) => safeCall(instance, methodName, event));
95+
hostRef.$queuedListeners$.map(([methodName, event]) => safeCall(instance, methodName, event, elm));
9696
hostRef.$queuedListeners$ = undefined;
9797
}
9898
}
@@ -103,7 +103,7 @@ const dispatchHooks = (hostRef: d.HostRef, isInitialLoad: boolean): Promise<void
103103
// rendering the component, doing other lifecycle stuff, etc. So
104104
// in that case we assign the returned promise to the variable we
105105
// declared above to hold a possible 'queueing' Promise
106-
maybePromise = safeCall(instance, 'componentWillLoad');
106+
maybePromise = safeCall(instance, 'componentWillLoad', undefined, elm);
107107
}
108108
} else {
109109
emitLifecycleEvent(elm, 'componentWillUpdate');
@@ -114,13 +114,13 @@ const dispatchHooks = (hostRef: d.HostRef, isInitialLoad: boolean): Promise<void
114114
// we specify that our runtime will wait for that `Promise` to
115115
// resolve before the component re-renders. So if the method
116116
// returns a `Promise` we need to keep it around!
117-
maybePromise = safeCall(instance, 'componentWillUpdate');
117+
maybePromise = safeCall(instance, 'componentWillUpdate', undefined, elm);
118118
}
119119
}
120120

121121
emitLifecycleEvent(elm, 'componentWillRender');
122122
if (BUILD.cmpWillRender) {
123-
maybePromise = enqueue(maybePromise, () => safeCall(instance, 'componentWillRender'));
123+
maybePromise = enqueue(maybePromise, () => safeCall(instance, 'componentWillRender', undefined, elm));
124124
}
125125

126126
endSchedule();
@@ -326,7 +326,7 @@ export const postUpdateComponent = (hostRef: d.HostRef) => {
326326
if (BUILD.isDev) {
327327
hostRef.$flags$ |= HOST_FLAGS.devOnRender;
328328
}
329-
safeCall(instance, 'componentDidRender');
329+
safeCall(instance, 'componentDidRender', undefined, elm);
330330
if (BUILD.isDev) {
331331
hostRef.$flags$ &= ~HOST_FLAGS.devOnRender;
332332
}
@@ -345,7 +345,7 @@ export const postUpdateComponent = (hostRef: d.HostRef) => {
345345
if (BUILD.isDev) {
346346
hostRef.$flags$ |= HOST_FLAGS.devOnDidLoad;
347347
}
348-
safeCall(instance, 'componentDidLoad');
348+
safeCall(instance, 'componentDidLoad', undefined, elm);
349349
if (BUILD.isDev) {
350350
hostRef.$flags$ &= ~HOST_FLAGS.devOnDidLoad;
351351
}
@@ -369,7 +369,7 @@ export const postUpdateComponent = (hostRef: d.HostRef) => {
369369
if (BUILD.isDev) {
370370
hostRef.$flags$ |= HOST_FLAGS.devOnRender;
371371
}
372-
safeCall(instance, 'componentDidUpdate');
372+
safeCall(instance, 'componentDidUpdate', undefined, elm);
373373
if (BUILD.isDev) {
374374
hostRef.$flags$ &= ~HOST_FLAGS.devOnRender;
375375
}
@@ -438,14 +438,15 @@ export const appDidLoad = (who: string) => {
438438
* @param instance any object that may or may not contain methods
439439
* @param method method name
440440
* @param arg single arbitrary argument
441+
* @param elm the element which made the call
441442
* @returns result of method call if it exists, otherwise `undefined`
442443
*/
443-
export const safeCall = (instance: any, method: string, arg?: any) => {
444+
export const safeCall = (instance: any, method: string, arg?: any, elm?: HTMLElement) => {
444445
if (instance && instance[method]) {
445446
try {
446447
return instance[method](arg);
447448
} catch (e) {
448-
consoleError(e);
449+
consoleError(e, elm);
449450
}
450451
}
451452
return undefined;

0 commit comments

Comments
 (0)