Fire front component window error handlers (#22462)
## What
Front components run in a Web Worker with the remote-dom polyfill, which
installs a fake `window`. Native `error` and `unhandledrejection` events
only fire on the real worker scope, so a component's `window.onerror`,
`window.addEventListener('error', ...)`, or
`window.onunhandledrejection` handler is a **silent no-op** today —
error-tracking libraries (Sentry-style) never see anything.
This adds `installErrorEventBridge` to the worker bootstrap: it listens
for the native `error`/`unhandledrejection` events and re-dispatches
equivalent events onto the fake `window`, so component-registered
handlers fire as they would on the web. It is guarded to no-op outside
the worker (when the fake window is the global scope) and swallows
errors thrown by a component's own handler.
## Scope
Worker-side only, no host, SDK, or RPC changes. Uncaught synchronous
errors already reach the host error panel via the native worker
`onerror`; surfacing unhandled promise rejections to the host panel is a
separate follow-up.
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22462?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
@@ -15,6 +15,7 @@ import { isDefined } from 'twenty-shared/utils';
|
||||
import { installStyleBridge } from '@/polyfills/installStyleBridge';
|
||||
import { installStylePropertyOnRemoteElements } from '@/remote/utils/installStylePropertyOnRemoteElements';
|
||||
import { patchRemoteElementSetAttribute } from '@/remote/utils/patchRemoteElementSetAttribute';
|
||||
import { installErrorEventBridge } from './utils/installErrorEventBridge';
|
||||
import { type FrontComponentExecutionContext } from 'twenty-sdk/front-component';
|
||||
import { frontComponentHostCommunicationApi } from '@/constants/frontComponentHostCommunicationApi';
|
||||
import { HTML_TAG_TO_CUSTOM_ELEMENT_TAG } from '@/constants/HtmlTagToRemoteComponent';
|
||||
@@ -31,6 +32,7 @@ import { setWorkerEnv } from './utils/setWorkerEnv';
|
||||
|
||||
installStylePropertyOnRemoteElements();
|
||||
patchRemoteElementSetAttribute();
|
||||
installErrorEventBridge();
|
||||
|
||||
exposeGlobals({
|
||||
__HTML_TAG_TO_CUSTOM_ELEMENT_TAG__: HTML_TAG_TO_CUSTOM_ELEMENT_TAG,
|
||||
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
import { installErrorEventBridge } from '../installErrorEventBridge';
|
||||
|
||||
class FakeErrorEvent {
|
||||
constructor(
|
||||
public type: string,
|
||||
public init: Record<string, unknown>,
|
||||
) {}
|
||||
}
|
||||
|
||||
class FakePromiseRejectionEvent {
|
||||
constructor(
|
||||
public type: string,
|
||||
public init: Record<string, unknown>,
|
||||
) {}
|
||||
}
|
||||
|
||||
const createScope = () => {
|
||||
const listeners = new Map<string, (event: unknown) => void>();
|
||||
const dispatchEvent = jest.fn((_event: object) => true);
|
||||
|
||||
const scope = {
|
||||
window: { dispatchEvent },
|
||||
ErrorEvent: FakeErrorEvent,
|
||||
PromiseRejectionEvent: FakePromiseRejectionEvent,
|
||||
addEventListener: (type: string, listener: (event: unknown) => void) => {
|
||||
listeners.set(type, listener);
|
||||
},
|
||||
};
|
||||
|
||||
return { scope, listeners, dispatchEvent };
|
||||
};
|
||||
|
||||
describe('installErrorEventBridge', () => {
|
||||
it('should re-dispatch native error events onto the fake window', () => {
|
||||
const { scope, listeners, dispatchEvent } = createScope();
|
||||
|
||||
installErrorEventBridge(scope as never);
|
||||
listeners.get('error')?.({
|
||||
message: 'boom',
|
||||
filename: 'app.js',
|
||||
lineno: 1,
|
||||
colno: 2,
|
||||
error: new Error('boom'),
|
||||
});
|
||||
|
||||
expect(dispatchEvent).toHaveBeenCalledTimes(1);
|
||||
const dispatched = dispatchEvent.mock.calls[0][0] as FakeErrorEvent;
|
||||
expect(dispatched).toBeInstanceOf(FakeErrorEvent);
|
||||
expect(dispatched.type).toBe('error');
|
||||
expect(dispatched.init.message).toBe('boom');
|
||||
});
|
||||
|
||||
it('should re-dispatch native unhandledrejection events onto the fake window', () => {
|
||||
const { scope, listeners, dispatchEvent } = createScope();
|
||||
|
||||
installErrorEventBridge(scope as never);
|
||||
const reason = new Error('rejected');
|
||||
const promise = Promise.resolve();
|
||||
listeners.get('unhandledrejection')?.({ reason, promise });
|
||||
|
||||
expect(dispatchEvent).toHaveBeenCalledTimes(1);
|
||||
const dispatched = dispatchEvent.mock
|
||||
.calls[0][0] as FakePromiseRejectionEvent;
|
||||
expect(dispatched).toBeInstanceOf(FakePromiseRejectionEvent);
|
||||
expect(dispatched.type).toBe('unhandledrejection');
|
||||
expect(dispatched.init.reason).toBe(reason);
|
||||
expect(dispatched.init.promise).toBe(promise);
|
||||
});
|
||||
|
||||
it('should not throw when a fake window handler throws', () => {
|
||||
const { scope, listeners } = createScope();
|
||||
scope.window.dispatchEvent = jest.fn((_event: object): boolean => {
|
||||
throw new Error('handler exploded');
|
||||
});
|
||||
|
||||
installErrorEventBridge(scope as never);
|
||||
|
||||
expect(() => listeners.get('error')?.({ message: 'boom' })).not.toThrow();
|
||||
});
|
||||
|
||||
it('should do nothing when there is no fake window', () => {
|
||||
const addEventListener = jest.fn();
|
||||
|
||||
installErrorEventBridge({ addEventListener } as never);
|
||||
|
||||
expect(addEventListener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should do nothing when the window is the global scope itself', () => {
|
||||
const addEventListener = jest.fn();
|
||||
const scope: Record<string, unknown> = { addEventListener };
|
||||
scope.window = scope;
|
||||
|
||||
installErrorEventBridge(scope as never);
|
||||
|
||||
expect(addEventListener).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
type PolyfillEventTarget = {
|
||||
dispatchEvent: (event: object) => boolean;
|
||||
};
|
||||
|
||||
type PolyfillErrorEventConstructor = new (
|
||||
type: string,
|
||||
eventInitDict: {
|
||||
message?: string;
|
||||
filename?: string;
|
||||
lineno?: number;
|
||||
colno?: number;
|
||||
error?: unknown;
|
||||
},
|
||||
) => object;
|
||||
|
||||
type PolyfillPromiseRejectionEventConstructor = new (
|
||||
type: string,
|
||||
eventInitDict: { reason?: unknown; promise?: Promise<unknown> },
|
||||
) => object;
|
||||
|
||||
type NativeErrorEvent = {
|
||||
message?: string;
|
||||
filename?: string;
|
||||
lineno?: number;
|
||||
colno?: number;
|
||||
error?: unknown;
|
||||
};
|
||||
|
||||
type NativePromiseRejectionEvent = {
|
||||
reason?: unknown;
|
||||
promise?: Promise<unknown>;
|
||||
};
|
||||
|
||||
type ErrorEventBridgeScope = {
|
||||
window?: PolyfillEventTarget;
|
||||
ErrorEvent?: PolyfillErrorEventConstructor;
|
||||
PromiseRejectionEvent?: PolyfillPromiseRejectionEventConstructor;
|
||||
addEventListener: (
|
||||
type: string,
|
||||
listener: (event: NativeErrorEvent & NativePromiseRejectionEvent) => void,
|
||||
) => void;
|
||||
};
|
||||
|
||||
export const installErrorEventBridge = (
|
||||
globalScope: ErrorEventBridgeScope = globalThis as unknown as ErrorEventBridgeScope,
|
||||
): void => {
|
||||
const polyfillWindow = globalScope.window;
|
||||
|
||||
if (
|
||||
!isDefined(polyfillWindow) ||
|
||||
(polyfillWindow as unknown) === globalScope
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
globalScope.addEventListener('error', (event) => {
|
||||
const PolyfillErrorEvent = globalScope.ErrorEvent;
|
||||
if (!isDefined(PolyfillErrorEvent)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
polyfillWindow.dispatchEvent(
|
||||
new PolyfillErrorEvent('error', {
|
||||
message: event.message,
|
||||
filename: event.filename,
|
||||
lineno: event.lineno,
|
||||
colno: event.colno,
|
||||
error: event.error,
|
||||
}),
|
||||
);
|
||||
} catch {}
|
||||
});
|
||||
|
||||
globalScope.addEventListener('unhandledrejection', (event) => {
|
||||
const PolyfillPromiseRejectionEvent = globalScope.PromiseRejectionEvent;
|
||||
if (!isDefined(PolyfillPromiseRejectionEvent)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
polyfillWindow.dispatchEvent(
|
||||
new PolyfillPromiseRejectionEvent('unhandledrejection', {
|
||||
reason: event.reason,
|
||||
promise: event.promise,
|
||||
}),
|
||||
);
|
||||
} catch {}
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user