Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(runtime): Add element to component error handler. Enables error boundaries #2979

Merged
merged 36 commits into from
Jan 23, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
ba5dec0
feat(emit error events): emit custom event on component error within …
johnjenkins Jul 19, 2021
d775a79
test(add test for component error handling)
johnjenkins Jul 26, 2021
289f214
revert un-required changes
johnjenkins Jul 26, 2021
f82a2c2
Added host element to loadModule error handling
johnjenkins Jul 27, 2021
f1894fb
chore(format): add prettier
rwaskiewicz Jul 1, 2021
adc5d39
format codebase
johnjenkins Aug 6, 2021
a78f0c3
revert cherry pick
johnjenkins Sep 4, 2021
15fa80e
feat(emit error events): emit custom event on component error within …
johnjenkins Jul 19, 2021
817fd3a
test(add test for component error handling)
johnjenkins Jul 26, 2021
a6c5bdc
revert un-required changes
johnjenkins Jul 26, 2021
69e094e
Added host element to loadModule error handling
johnjenkins Jul 27, 2021
2f9749a
revert cherry pick
johnjenkins Sep 4, 2021
9e87b12
Merge remote-tracking branch 'origin/feat-error-boundaries' into feat…
johnjenkins Sep 5, 2021
c12b9d3
Merge branch 'feat-error-boundaries-rebase' into feat-error-boundaries
johnjenkins Sep 5, 2021
f5e7b6c
run prettier
johnjenkins Sep 5, 2021
dbef347
rm rv karma/test-components
johnjenkins Sep 5, 2021
e6b900a
rv extra prettier call
johnjenkins Sep 5, 2021
cfbcfe4
Merge branch 'master' into feat-error-boundaries
johnjenkins Sep 7, 2021
8a94d5e
Merge branch 'main' into feat-error-boundaries
johnjenkins Oct 27, 2021
c768c7b
Merge branch 'main' into feat-error-boundaries
johnjenkins Nov 2, 2021
568e9f7
Merge branch 'main' into feat-error-boundaries
johnjenkins Nov 10, 2021
37ddb2c
Merge branch 'main' into feat-error-boundaries
johnjenkins Nov 26, 2021
a371230
Flaky test?
Nov 26, 2021
bf32f82
Merge branch 'main' into feat-error-boundaries
johnjenkins Dec 2, 2021
0714e00
Merge branch 'main' into feat-error-boundaries
johnjenkins Dec 7, 2021
ab05674
Merge branch 'stencil-main' into feat-error-boundaries
Jun 22, 2022
2e89a8c
fix lint
Jun 22, 2022
0662977
fixup `strictNullChecks` issues
Jun 23, 2022
3e29512
Merge branch 'main' into feat-error-boundaries
johnjenkins Jun 23, 2022
da1076a
Merge branch 'main' into feat-error-boundaries
johnjenkins Jul 1, 2022
a9d6f65
Merge branch 'stencil-main' into feat-error-boundaries
Aug 30, 2022
d1c3778
Merge branch 'stencil-main' into feat-error-boundaries
Nov 16, 2022
ab37726
Merge remote-tracking branch 'stencil-main/main' into feat-error-boun…
Jan 22, 2025
47abd3c
chore: tidy
Jan 22, 2025
4172db3
chore: formatting
Jan 23, 2025
3ffe820
chore: revert type
Jan 23, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading