Skip to content

Commit

Permalink
feat(runtime): Add element to component error handler. Enables error …
Browse files Browse the repository at this point in the history
…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 <[email protected]>
Co-authored-by: John Jenkins <[email protected]>
  • Loading branch information
3 people authored Jan 23, 2025
1 parent a7d3873 commit 5605d48
Show file tree
Hide file tree
Showing 9 changed files with 128 additions and 30 deletions.
21 changes: 14 additions & 7 deletions src/client/client-load-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,18 @@ export const loadModule = (
/* webpackInclude: /\.entry\.js$/ */
/* webpackExclude: /\.system\.entry\.js$/ */
/* webpackMode: "lazy" */
`${MODULE_IMPORT_PREFIX}${bundleId}.entry.js${BUILD.hotModuleReplacement && hmrVersionId ? '?s-hmr=' + hmrVersionId : ''}`
).then((importedModule) => {
if (!BUILD.hotModuleReplacement) {
cmpModules.set(bundleId, importedModule);
}
return importedModule[exportName];
}, consoleError);
`${MODULE_IMPORT_PREFIX}${bundleId}.entry.js${
BUILD.hotModuleReplacement && hmrVersionId ? '?s-hmr=' + hmrVersionId : ''
}`
).then(
(importedModule) => {
if (!BUILD.hotModuleReplacement) {
cmpModules.set(bundleId, importedModule);
}
return importedModule[exportName];
},
(e: Error) => {
consoleError(e, hostRef.$hostElement$);
},
);
};
2 changes: 1 addition & 1 deletion src/client/client-log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type * as d from '../declarations';

let customError: d.ErrorHandler;

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

export const STENCIL_DEV_MODE = BUILD.isTesting
? ['STENCIL:'] // E2E testing
Expand Down
6 changes: 5 additions & 1 deletion src/hydrate/platform/proxy-host-element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,11 @@ export function proxyHostElement(elm: d.HostElement, cstr: d.ComponentConstructo
Object.defineProperty(elm, memberName, {
value(this: d.HostElement, ...args: any[]) {
const ref = getHostRef(this);
return ref?.$onInstancePromise$?.then(() => ref?.$lazyInstance$?.[memberName](...args)).catch(consoleError);
return ref?.$onInstancePromise$
?.then(() => ref?.$lazyInstance$?.[memberName](...args))
.catch((e) => {
consoleError(e, this);
});
},
});
}
Expand Down
4 changes: 2 additions & 2 deletions src/runtime/connected-callback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,9 +113,9 @@ export const connectedCallback = (elm: d.HostElement) => {

// fire off connectedCallback() on component instance
if (hostRef?.$lazyInstance$) {
fireConnectedCallback(hostRef.$lazyInstance$);
fireConnectedCallback(hostRef.$lazyInstance$, elm);
} else if (hostRef?.$onReadyPromise$) {
hostRef.$onReadyPromise$.then(() => fireConnectedCallback(hostRef.$lazyInstance$));
hostRef.$onReadyPromise$.then(() => fireConnectedCallback(hostRef.$lazyInstance$, elm));
}
}

Expand Down
10 changes: 5 additions & 5 deletions src/runtime/disconnected-callback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@ import { PLATFORM_FLAGS } from './runtime-constants';
import { rootAppliedStyles } from './styles';
import { safeCall } from './update-component';

const disconnectInstance = (instance: any) => {
const disconnectInstance = (instance: any, elm?: d.HostElement) => {
if (BUILD.lazyLoad && BUILD.disconnectedCallback) {
safeCall(instance, 'disconnectedCallback');
safeCall(instance, 'disconnectedCallback', undefined, elm || instance);
}
if (BUILD.cmpDidUnload) {
safeCall(instance, 'componentDidUnload');
safeCall(instance, 'componentDidUnload', undefined, elm || instance);
}
};

Expand All @@ -29,9 +29,9 @@ export const disconnectedCallback = async (elm: d.HostElement) => {
if (!BUILD.lazyLoad) {
disconnectInstance(elm);
} else if (hostRef?.$lazyInstance$) {
disconnectInstance(hostRef.$lazyInstance$);
disconnectInstance(hostRef.$lazyInstance$, elm);
} else if (hostRef?.$onReadyPromise$) {
hostRef.$onReadyPromise$.then(() => disconnectInstance(hostRef.$lazyInstance$));
hostRef.$onReadyPromise$.then(() => disconnectInstance(hostRef.$lazyInstance$, elm));
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/runtime/host-listener.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ const hostListenerProxy = (hostRef: d.HostRef, methodName: string) => (ev: Event
(hostRef.$hostElement$ as any)[methodName](ev);
}
} catch (e) {
consoleError(e);
consoleError(e, hostRef.$hostElement$);
}
};

Expand Down
8 changes: 4 additions & 4 deletions src/runtime/initialize-component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ export const initializeComponent = async (
try {
new (Cstr as any)(hostRef);
} catch (e) {
consoleError(e);
consoleError(e, elm);
}

if (BUILD.member) {
Expand All @@ -87,7 +87,7 @@ export const initializeComponent = async (
hostRef.$flags$ |= HOST_FLAGS.isWatchReady;
}
endNewInstance();
fireConnectedCallback(hostRef.$lazyInstance$);
fireConnectedCallback(hostRef.$lazyInstance$, elm);
} else {
// sync constructor component
Cstr = elm.constructor as any;
Expand Down Expand Up @@ -189,8 +189,8 @@ export const initializeComponent = async (
}
};

export const fireConnectedCallback = (instance: any) => {
export const fireConnectedCallback = (instance: any, elm?: HTMLElement) => {
if (BUILD.lazyLoad && BUILD.connectedCallback) {
safeCall(instance, 'connectedCallback');
safeCall(instance, 'connectedCallback', undefined, elm);
}
};
86 changes: 86 additions & 0 deletions src/runtime/test/component-error-handling.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { Component, ComponentInterface, h, Prop, setErrorHandler } from '@stencil/core';
import { newSpecPage } from '@stencil/core/testing';

describe('component error handling', () => {
it('calls a handler with an error and element during every lifecycle hook and render', async () => {
@Component({ tag: 'cmp-a' })
class CmpA implements ComponentInterface {
@Prop() reRender = false;

componentWillLoad() {
throw new Error('componentWillLoad');
}

componentDidLoad() {
throw new Error('componentDidLoad');
}

componentWillRender() {
throw new Error('componentWillRender');
}

componentDidRender() {
throw new Error('componentDidRender');
}

componentWillUpdate() {
throw new Error('componentWillUpdate');
}

componentDidUpdate() {
throw new Error('componentDidUpdate');
}

render() {
if (!this.reRender) return <div></div>;
else throw new Error('render');
}
}

const customErrorHandler = (e: Error, el: HTMLElement) => {
if (!el) return;
el.dispatchEvent(
new CustomEvent('componentError', {
bubbles: true,
cancelable: true,
composed: true,
detail: e,
}),
);
};
setErrorHandler(customErrorHandler);

const { doc, waitForChanges } = await newSpecPage({
components: [CmpA],
html: ``,
});

const handler = jest.fn();
doc.addEventListener('componentError', handler);
const cmpA = document.createElement('cmp-a') as any;
doc.body.appendChild(cmpA);
try {
await waitForChanges();
} catch (e) {}

cmpA.reRender = true;
try {
await waitForChanges();
} catch (e) {}

return Promise.resolve().then(() => {
expect(handler).toHaveBeenCalledTimes(9);
expect(handler.mock.calls[0][0].bubbles).toBe(true);
expect(handler.mock.calls[0][0].cancelable).toBe(true);
expect(handler.mock.calls[0][0].detail).toStrictEqual(Error('componentWillLoad'));
expect(handler.mock.calls[1][0].detail).toStrictEqual(Error('componentWillRender'));
expect(handler.mock.calls[2][0].detail).toStrictEqual(Error('componentDidRender'));
expect(handler.mock.calls[3][0].detail).toStrictEqual(Error('componentDidLoad'));
expect(handler.mock.calls[4][0].detail).toStrictEqual(Error('componentWillUpdate'));
expect(handler.mock.calls[5][0].detail).toStrictEqual(Error('componentWillRender'));
expect(handler.mock.calls[6][0].detail).toStrictEqual(Error('render'));
expect(handler.mock.calls[7][0].detail).toStrictEqual(Error('componentDidRender'));
expect(handler.mock.calls[8][0].detail).toStrictEqual(Error('componentDidUpdate'));
});
});
});
19 changes: 10 additions & 9 deletions src/runtime/update-component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ const dispatchHooks = (hostRef: d.HostRef, isInitialLoad: boolean): Promise<void
if (BUILD.lazyLoad && BUILD.hostListener) {
hostRef.$flags$ |= HOST_FLAGS.isListenReady;
if (hostRef.$queuedListeners$) {
hostRef.$queuedListeners$.map(([methodName, event]) => safeCall(instance, methodName, event));
hostRef.$queuedListeners$.map(([methodName, event]) => safeCall(instance, methodName, event, elm));
hostRef.$queuedListeners$ = undefined;
}
}
Expand All @@ -103,7 +103,7 @@ const dispatchHooks = (hostRef: d.HostRef, isInitialLoad: boolean): Promise<void
// rendering the component, doing other lifecycle stuff, etc. So
// in that case we assign the returned promise to the variable we
// declared above to hold a possible 'queueing' Promise
maybePromise = safeCall(instance, 'componentWillLoad');
maybePromise = safeCall(instance, 'componentWillLoad', undefined, elm);
}
} else {
emitLifecycleEvent(elm, 'componentWillUpdate');
Expand All @@ -114,13 +114,13 @@ const dispatchHooks = (hostRef: d.HostRef, isInitialLoad: boolean): Promise<void
// we specify that our runtime will wait for that `Promise` to
// resolve before the component re-renders. So if the method
// returns a `Promise` we need to keep it around!
maybePromise = safeCall(instance, 'componentWillUpdate');
maybePromise = safeCall(instance, 'componentWillUpdate', undefined, elm);
}
}

emitLifecycleEvent(elm, 'componentWillRender');
if (BUILD.cmpWillRender) {
maybePromise = enqueue(maybePromise, () => safeCall(instance, 'componentWillRender'));
maybePromise = enqueue(maybePromise, () => safeCall(instance, 'componentWillRender', undefined, elm));
}

endSchedule();
Expand Down Expand Up @@ -326,7 +326,7 @@ export const postUpdateComponent = (hostRef: d.HostRef) => {
if (BUILD.isDev) {
hostRef.$flags$ |= HOST_FLAGS.devOnRender;
}
safeCall(instance, 'componentDidRender');
safeCall(instance, 'componentDidRender', undefined, elm);
if (BUILD.isDev) {
hostRef.$flags$ &= ~HOST_FLAGS.devOnRender;
}
Expand All @@ -345,7 +345,7 @@ export const postUpdateComponent = (hostRef: d.HostRef) => {
if (BUILD.isDev) {
hostRef.$flags$ |= HOST_FLAGS.devOnDidLoad;
}
safeCall(instance, 'componentDidLoad');
safeCall(instance, 'componentDidLoad', undefined, elm);
if (BUILD.isDev) {
hostRef.$flags$ &= ~HOST_FLAGS.devOnDidLoad;
}
Expand All @@ -369,7 +369,7 @@ export const postUpdateComponent = (hostRef: d.HostRef) => {
if (BUILD.isDev) {
hostRef.$flags$ |= HOST_FLAGS.devOnRender;
}
safeCall(instance, 'componentDidUpdate');
safeCall(instance, 'componentDidUpdate', undefined, elm);
if (BUILD.isDev) {
hostRef.$flags$ &= ~HOST_FLAGS.devOnRender;
}
Expand Down Expand Up @@ -438,14 +438,15 @@ export const appDidLoad = (who: string) => {
* @param instance any object that may or may not contain methods
* @param method method name
* @param arg single arbitrary argument
* @param elm the element which made the call
* @returns result of method call if it exists, otherwise `undefined`
*/
export const safeCall = (instance: any, method: string, arg?: any) => {
export const safeCall = (instance: any, method: string, arg?: any, elm?: HTMLElement) => {
if (instance && instance[method]) {
try {
return instance[method](arg);
} catch (e) {
consoleError(e);
consoleError(e, elm);
}
}
return undefined;
Expand Down

0 comments on commit 5605d48

Please sign in to comment.