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:
Raphaël Bosi
2026-07-02 18:15:04 +02:00
committed by GitHub
parent bc1a4cb3fb
commit 7c33280465
3 changed files with 192 additions and 0 deletions
@@ -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,
@@ -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();
});
});
@@ -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 {}
});
};