From 7c33280465e78fd3c8ad6f9c4748735ed8deb40a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Rapha=C3=ABl=20Bosi?=
<71827178+bosiraphael@users.noreply.github.com>
Date: Thu, 2 Jul 2026 18:15:04 +0200
Subject: [PATCH] Fire front component window error handlers (#22462)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## 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.
---
.../src/remote/worker/remote-worker.ts | 2 +
.../__tests__/installErrorEventBridge.test.ts | 98 +++++++++++++++++++
.../worker/utils/installErrorEventBridge.ts | 92 +++++++++++++++++
3 files changed, 192 insertions(+)
create mode 100644 packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/installErrorEventBridge.test.ts
create mode 100644 packages/twenty-front-component-renderer/src/remote/worker/utils/installErrorEventBridge.ts
diff --git a/packages/twenty-front-component-renderer/src/remote/worker/remote-worker.ts b/packages/twenty-front-component-renderer/src/remote/worker/remote-worker.ts
index 089a8e1b16..00d4cc4759 100644
--- a/packages/twenty-front-component-renderer/src/remote/worker/remote-worker.ts
+++ b/packages/twenty-front-component-renderer/src/remote/worker/remote-worker.ts
@@ -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,
diff --git a/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/installErrorEventBridge.test.ts b/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/installErrorEventBridge.test.ts
new file mode 100644
index 0000000000..296bf498bd
--- /dev/null
+++ b/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/installErrorEventBridge.test.ts
@@ -0,0 +1,98 @@
+import { installErrorEventBridge } from '../installErrorEventBridge';
+
+class FakeErrorEvent {
+ constructor(
+ public type: string,
+ public init: Record,
+ ) {}
+}
+
+class FakePromiseRejectionEvent {
+ constructor(
+ public type: string,
+ public init: Record,
+ ) {}
+}
+
+const createScope = () => {
+ const listeners = new Map 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 = { addEventListener };
+ scope.window = scope;
+
+ installErrorEventBridge(scope as never);
+
+ expect(addEventListener).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/twenty-front-component-renderer/src/remote/worker/utils/installErrorEventBridge.ts b/packages/twenty-front-component-renderer/src/remote/worker/utils/installErrorEventBridge.ts
new file mode 100644
index 0000000000..ad6e94244e
--- /dev/null
+++ b/packages/twenty-front-component-renderer/src/remote/worker/utils/installErrorEventBridge.ts
@@ -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 },
+) => object;
+
+type NativeErrorEvent = {
+ message?: string;
+ filename?: string;
+ lineno?: number;
+ colno?: number;
+ error?: unknown;
+};
+
+type NativePromiseRejectionEvent = {
+ reason?: unknown;
+ promise?: Promise;
+};
+
+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 {}
+ });
+};