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 {} + }); +};