From bb22b216dbd601991199617c8c5c720627c990c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Bosi?= <71827178+bosiraphael@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:14:47 +0200 Subject: [PATCH] Fix front components rendering a blank panel in Firefox (#23213) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #22973 In Firefox, front components rendered a blank panel with only `DataCloneError: Exception object could not be cloned` in the console. Accessing `caches` in the opaque-origin sandbox worker throws a Gecko `Exception` (worker-side CacheStorage code up to v2.22, or any component code touching it since), and `@quilted/threads` posts thrown values raw over the MessagePort. Firefox cannot structured-clone these exceptions, so the error report itself failed and the render promise never settled. Thread errors are now flattened to clonable payloads and rehydrated on the other side, so the real error surfaces in the error box instead of silently hanging the panel. Also makes the CacheStorage guards exception-safe (v2.23 already moved that code host-side, which removed the main trigger). Verified end to end in stock Firefox 149: before, a component touching `caches` hangs silently; after, render rejects with the full `NS_ERROR_FAILURE` diagnostic. Chromium behavior unchanged. ```mermaid sequenceDiagram participant Host as Host (React) participant Worker as Sandbox worker (null origin) participant Threads as @quilted/threads rect rgb(250, 235, 235) note over Host,Threads: Before — Firefox hangs Host->>Worker: render(component) Worker->>Worker: throws Gecko Exception
(typeof caches) Worker->>Threads: postMessage(rawException) Threads--xHost: DataCloneError:
Exception could not be cloned note over Host: CALL_RESULT never arrives
render() promise never settles → blank panel end rect rgb(232, 245, 233) note over Host,Threads: After — error surfaces Host->>Worker: render(component) Worker->>Worker: throws Gecko Exception Worker->>Threads: serialize → { name, message, stack } Threads->>Host: postMessage(clonable payload) Host->>Host: rehydrate → Error, reject render() note over Host: error box shows NS_ERROR_FAILURE end ``` Review in cubic --- .../jest.config.mjs | 11 +- .../jest.setup.mjs | 5 + .../FrontComponentThreadErrorMarker.ts | 2 + .../frontComponentCacheStorageService.test.ts | 43 ++++ .../utils/createFrontComponentHostThread.ts | 2 + .../frontComponentCacheStorageService.ts | 18 +- ...eateWorkerSpawnErrorSandboxMessage.test.ts | 20 ++ .../createWorkerSpawnErrorSandboxMessage.ts | 22 ++- .../src/remote/worker/remote-worker.ts | 2 + .../src/types/ClonableErrorPayload.ts | 6 + .../src/types/ErrorLikeValue.ts | 8 + .../buildClonableErrorPayload.test.ts | 58 ++++++ ...teClonableErrorThreadSerialization.test.ts | 185 ++++++++++++++++++ .../extractClonableErrorPayload.test.ts | 61 ++++++ .../utils/__tests__/isErrorLikeValue.test.ts | 54 +++++ .../__tests__/rehydrateClonableError.test.ts | 36 ++++ .../src/utils/buildClonableErrorPayload.ts | 33 ++++ .../createClonableErrorThreadSerialization.ts | 25 +++ .../src/utils/extractClonableErrorPayload.ts | 36 ++++ .../src/utils/isErrorLikeValue.ts | 25 +++ .../src/utils/rehydrateClonableError.ts | 20 ++ 21 files changed, 654 insertions(+), 18 deletions(-) create mode 100644 packages/twenty-front-component-renderer/jest.setup.mjs create mode 100644 packages/twenty-front-component-renderer/src/constants/FrontComponentThreadErrorMarker.ts create mode 100644 packages/twenty-front-component-renderer/src/host/utils/__tests__/frontComponentCacheStorageService.test.ts create mode 100644 packages/twenty-front-component-renderer/src/types/ClonableErrorPayload.ts create mode 100644 packages/twenty-front-component-renderer/src/types/ErrorLikeValue.ts create mode 100644 packages/twenty-front-component-renderer/src/utils/__tests__/buildClonableErrorPayload.test.ts create mode 100644 packages/twenty-front-component-renderer/src/utils/__tests__/createClonableErrorThreadSerialization.test.ts create mode 100644 packages/twenty-front-component-renderer/src/utils/__tests__/extractClonableErrorPayload.test.ts create mode 100644 packages/twenty-front-component-renderer/src/utils/__tests__/isErrorLikeValue.test.ts create mode 100644 packages/twenty-front-component-renderer/src/utils/__tests__/rehydrateClonableError.test.ts create mode 100644 packages/twenty-front-component-renderer/src/utils/buildClonableErrorPayload.ts create mode 100644 packages/twenty-front-component-renderer/src/utils/createClonableErrorThreadSerialization.ts create mode 100644 packages/twenty-front-component-renderer/src/utils/extractClonableErrorPayload.ts create mode 100644 packages/twenty-front-component-renderer/src/utils/isErrorLikeValue.ts create mode 100644 packages/twenty-front-component-renderer/src/utils/rehydrateClonableError.ts diff --git a/packages/twenty-front-component-renderer/jest.config.mjs b/packages/twenty-front-component-renderer/jest.config.mjs index d9045ebb41..42dc18dd87 100644 --- a/packages/twenty-front-component-renderer/jest.config.mjs +++ b/packages/twenty-front-component-renderer/jest.config.mjs @@ -13,9 +13,10 @@ const jestConfig = { displayName: 'twenty-front-component-renderer', preset: '../../jest.preset.js', testEnvironment: 'jsdom', - transformIgnorePatterns: ['../../node_modules/'], + setupFiles: ['/jest.setup.mjs'], + transformIgnorePatterns: ['node_modules/(?!@quilted/)'], transform: { - '^.+\\.[tj]sx?$': [ + '^.+\\.(mjs|[tj]sx?)$': [ '@swc/jest', { jsc: { @@ -29,8 +30,12 @@ const jestConfig = { ...pathsToModuleNameMapper(tsConfig.compilerOptions.paths, { prefix: '/', }), + '^@quilted/threads$': + '/../../node_modules/@quilted/threads/build/esm/index.mjs', + '^@quilted/events$': + '/../../node_modules/@quilted/events/build/esm/index.mjs', }, - moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'mjs'], extensionsToTreatAsEsm: ['.ts', '.tsx'], coverageDirectory: './coverage', }; diff --git a/packages/twenty-front-component-renderer/jest.setup.mjs b/packages/twenty-front-component-renderer/jest.setup.mjs new file mode 100644 index 0000000000..1c93e8e30d --- /dev/null +++ b/packages/twenty-front-component-renderer/jest.setup.mjs @@ -0,0 +1,5 @@ +import { deserialize, serialize } from 'node:v8'; + +if (typeof globalThis.structuredClone !== 'function') { + globalThis.structuredClone = (value) => deserialize(serialize(value)); +} diff --git a/packages/twenty-front-component-renderer/src/constants/FrontComponentThreadErrorMarker.ts b/packages/twenty-front-component-renderer/src/constants/FrontComponentThreadErrorMarker.ts new file mode 100644 index 0000000000..65a2235c5b --- /dev/null +++ b/packages/twenty-front-component-renderer/src/constants/FrontComponentThreadErrorMarker.ts @@ -0,0 +1,2 @@ +export const FRONT_COMPONENT_THREAD_ERROR_MARKER = + '__frontComponentThreadError'; diff --git a/packages/twenty-front-component-renderer/src/host/utils/__tests__/frontComponentCacheStorageService.test.ts b/packages/twenty-front-component-renderer/src/host/utils/__tests__/frontComponentCacheStorageService.test.ts new file mode 100644 index 0000000000..95ee91fd22 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/host/utils/__tests__/frontComponentCacheStorageService.test.ts @@ -0,0 +1,43 @@ +import { frontComponentCacheStorageService } from '../frontComponentCacheStorageService'; + +describe('frontComponentCacheStorageService', () => { + describe('open', () => { + afterEach(() => { + Reflect.deleteProperty(globalThis, 'caches'); + }); + + it('should return undefined when accessing caches throws, as in Firefox opaque-origin contexts', async () => { + Object.defineProperty(globalThis, 'caches', { + configurable: true, + get() { + throw new Error('NS_ERROR_FAILURE'); + }, + }); + + await expect(frontComponentCacheStorageService.open()).resolves.toBe( + undefined, + ); + }); + + it('should return undefined when caches is not available', async () => { + await expect(frontComponentCacheStorageService.open()).resolves.toBe( + undefined, + ); + }); + + it('should open the front component cache when caches is available', async () => { + const fakeCache = { match: jest.fn() }; + const open = jest.fn().mockResolvedValue(fakeCache); + + Object.defineProperty(globalThis, 'caches', { + configurable: true, + value: { open }, + }); + + await expect(frontComponentCacheStorageService.open()).resolves.toBe( + fakeCache, + ); + expect(open).toHaveBeenCalledWith('front-component-source-v1'); + }); + }); +}); diff --git a/packages/twenty-front-component-renderer/src/host/utils/createFrontComponentHostThread.ts b/packages/twenty-front-component-renderer/src/host/utils/createFrontComponentHostThread.ts index 3a1d82041c..ef70ea9016 100644 --- a/packages/twenty-front-component-renderer/src/host/utils/createFrontComponentHostThread.ts +++ b/packages/twenty-front-component-renderer/src/host/utils/createFrontComponentHostThread.ts @@ -5,6 +5,7 @@ import { type FrontComponentHostThreadExports } from '@/types/FrontComponentHost import { type FrontComponentThread } from '@/types/FrontComponentThread'; import { type HostFetchFunction } from '@/types/HostFetchFunction'; import { type WorkerExports } from '@/types/WorkerExports'; +import { createClonableErrorThreadSerialization } from '@/utils/createClonableErrorThreadSerialization'; export const createFrontComponentHostThread = ( hostMessagePort: MessagePort, @@ -18,6 +19,7 @@ export const createFrontComponentHostThread = ( ...FRONT_COMPONENT_HOST_COMMUNICATION_API_NOOP, hostFetch, }, + serialization: createClonableErrorThreadSerialization(), }); hostMessagePort.start(); diff --git a/packages/twenty-front-component-renderer/src/host/utils/frontComponentCacheStorageService.ts b/packages/twenty-front-component-renderer/src/host/utils/frontComponentCacheStorageService.ts index cf83e4d158..4f5e7674b5 100644 --- a/packages/twenty-front-component-renderer/src/host/utils/frontComponentCacheStorageService.ts +++ b/packages/twenty-front-component-renderer/src/host/utils/frontComponentCacheStorageService.ts @@ -8,11 +8,13 @@ export const frontComponentCacheStorageService = { }: { source: string; }): Promise => { - if (typeof crypto === 'undefined' || !isDefined(crypto.subtle)) { - return undefined; - } - + // Guards stay inside the try block: in Firefox, accessing `caches` or + // `crypto` in an opaque-origin context throws instead of being undefined. try { + if (typeof crypto === 'undefined' || !isDefined(crypto.subtle)) { + return undefined; + } + const digest = await crypto.subtle.digest( 'SHA-256', new TextEncoder().encode(source), @@ -27,11 +29,11 @@ export const frontComponentCacheStorageService = { }, open: async (): Promise => { - if (typeof caches === 'undefined') { - return undefined; - } - try { + if (typeof caches === 'undefined') { + return undefined; + } + return await caches.open(FRONT_COMPONENT_SOURCE_CACHE_NAME); } catch { return undefined; diff --git a/packages/twenty-front-component-renderer/src/remote/sandbox/utils/__tests__/createWorkerSpawnErrorSandboxMessage.test.ts b/packages/twenty-front-component-renderer/src/remote/sandbox/utils/__tests__/createWorkerSpawnErrorSandboxMessage.test.ts index 9fef050b55..8b6111af87 100644 --- a/packages/twenty-front-component-renderer/src/remote/sandbox/utils/__tests__/createWorkerSpawnErrorSandboxMessage.test.ts +++ b/packages/twenty-front-component-renderer/src/remote/sandbox/utils/__tests__/createWorkerSpawnErrorSandboxMessage.test.ts @@ -24,4 +24,24 @@ describe('createWorkerSpawnErrorSandboxMessage', () => { message: 'Failed to spawn the front component worker', }); }); + + it('should stringify foreign exception objects that carry no message', () => { + class FakeGeckoException { + name = 'NS_ERROR_FAILURE'; + message = ''; + stack = '@blob:null/uuid:5:15'; + + toString(): string { + return '[Exception... "Failure" nsresult: "0x80004005 (NS_ERROR_FAILURE)"]'; + } + } + + expect( + createWorkerSpawnErrorSandboxMessage(new FakeGeckoException()), + ).toEqual({ + type: FRONT_COMPONENT_SANDBOX_MESSAGE_TYPE.ERROR, + message: + '[Exception... "Failure" nsresult: "0x80004005 (NS_ERROR_FAILURE)"]', + }); + }); }); diff --git a/packages/twenty-front-component-renderer/src/remote/sandbox/utils/createWorkerSpawnErrorSandboxMessage.ts b/packages/twenty-front-component-renderer/src/remote/sandbox/utils/createWorkerSpawnErrorSandboxMessage.ts index 2cbe5789e5..300650517c 100644 --- a/packages/twenty-front-component-renderer/src/remote/sandbox/utils/createWorkerSpawnErrorSandboxMessage.ts +++ b/packages/twenty-front-component-renderer/src/remote/sandbox/utils/createWorkerSpawnErrorSandboxMessage.ts @@ -2,16 +2,24 @@ import { isNonEmptyString } from '@sniptt/guards'; import { FRONT_COMPONENT_SANDBOX_MESSAGE_TYPE } from '@/remote/sandbox/constants/FrontComponentSandboxMessageType'; import { type FrontComponentSandboxMessage } from '@/remote/sandbox/types/FrontComponentSandboxMessage'; +import { buildClonableErrorPayload } from '@/utils/buildClonableErrorPayload'; +import { isErrorLikeValue } from '@/utils/isErrorLikeValue'; const WORKER_SPAWN_FAILURE_MESSAGE = 'Failed to spawn the front component worker'; export const createWorkerSpawnErrorSandboxMessage = ( error: unknown, -): FrontComponentSandboxMessage => ({ - type: FRONT_COMPONENT_SANDBOX_MESSAGE_TYPE.ERROR, - message: - error instanceof Error && isNonEmptyString(error.message) - ? error.message - : WORKER_SPAWN_FAILURE_MESSAGE, -}); +): FrontComponentSandboxMessage => { + const { name, message } = buildClonableErrorPayload(error); + + const isInformativeMessage = isNonEmptyString(message) && message !== name; + + return { + type: FRONT_COMPONENT_SANDBOX_MESSAGE_TYPE.ERROR, + message: + isErrorLikeValue(error) && isInformativeMessage + ? message + : WORKER_SPAWN_FAILURE_MESSAGE, + }; +}; 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 cb557badd2..86a27d3b0d 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 @@ -20,6 +20,7 @@ import { setFrontComponentExecutionContext } from '@/remote/worker/utils/setFron import { type FrontComponentHostThread } from '@/types/FrontComponentHostThread'; import { type FrontComponentHostThreadExports } from '@/types/FrontComponentHostThreadExports'; import { type WorkerExports } from '@/types/WorkerExports'; +import { createClonableErrorThreadSerialization } from '@/utils/createClonableErrorThreadSerialization'; installStylePropertyOnRemoteElements(); patchRemoteElementAttributes(); @@ -71,6 +72,7 @@ self.addEventListener('message', (event) => { WorkerExports >(transferredPort, { exports: workerExports, + serialization: createClonableErrorThreadSerialization(), }); transferredPort.start(); diff --git a/packages/twenty-front-component-renderer/src/types/ClonableErrorPayload.ts b/packages/twenty-front-component-renderer/src/types/ClonableErrorPayload.ts new file mode 100644 index 0000000000..c738d70a7b --- /dev/null +++ b/packages/twenty-front-component-renderer/src/types/ClonableErrorPayload.ts @@ -0,0 +1,6 @@ +export type ClonableErrorPayload = { + name: string; + message: string; + stack?: string; + code?: string; +}; diff --git a/packages/twenty-front-component-renderer/src/types/ErrorLikeValue.ts b/packages/twenty-front-component-renderer/src/types/ErrorLikeValue.ts new file mode 100644 index 0000000000..a8b8d20e4c --- /dev/null +++ b/packages/twenty-front-component-renderer/src/types/ErrorLikeValue.ts @@ -0,0 +1,8 @@ +// `stack` is required: a string stack is the discriminator that tells a real +// thrown value (Error, DOMException, Firefox Gecko Exception) apart from a plain +// data object that merely happens to carry `name` and `message` fields. +export type ErrorLikeValue = { + name: string; + message: string; + stack: string; +}; diff --git a/packages/twenty-front-component-renderer/src/utils/__tests__/buildClonableErrorPayload.test.ts b/packages/twenty-front-component-renderer/src/utils/__tests__/buildClonableErrorPayload.test.ts new file mode 100644 index 0000000000..4b57406597 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/utils/__tests__/buildClonableErrorPayload.test.ts @@ -0,0 +1,58 @@ +import { CustomError } from 'twenty-shared/utils'; + +import { buildClonableErrorPayload } from '../buildClonableErrorPayload'; + +class FakeGeckoException { + name = 'NS_ERROR_FAILURE'; + message = ''; + stack = '@blob:null/uuid:5:15'; + + toString(): string { + return '[Exception... "Failure" nsresult: "0x80004005 (NS_ERROR_FAILURE)"]'; + } +} + +describe('buildClonableErrorPayload', () => { + it('should flatten an Error to name, message and stack', () => { + const error = new Error('boom'); + + expect(buildClonableErrorPayload(error)).toEqual({ + name: 'Error', + message: 'boom', + stack: error.stack, + code: undefined, + }); + }); + + it('should carry the code of a CustomError', () => { + const error = new CustomError('fetch bridge unavailable', 'FETCH_BRIDGE'); + + expect(buildClonableErrorPayload(error)).toMatchObject({ + message: 'fetch bridge unavailable', + code: 'FETCH_BRIDGE', + }); + }); + + it('should fall back to the stringified value when the message is empty', () => { + expect(buildClonableErrorPayload(new FakeGeckoException())).toEqual({ + name: 'NS_ERROR_FAILURE', + message: + '[Exception... "Failure" nsresult: "0x80004005 (NS_ERROR_FAILURE)"]', + stack: '@blob:null/uuid:5:15', + code: undefined, + }); + }); + + it('should stringify values that are not error-like', () => { + expect(buildClonableErrorPayload('exploded')).toEqual({ + name: 'Error', + message: 'exploded', + }); + }); + + it('should produce a structured-cloneable payload', () => { + expect(() => + structuredClone(buildClonableErrorPayload(new FakeGeckoException())), + ).not.toThrow(); + }); +}); diff --git a/packages/twenty-front-component-renderer/src/utils/__tests__/createClonableErrorThreadSerialization.test.ts b/packages/twenty-front-component-renderer/src/utils/__tests__/createClonableErrorThreadSerialization.test.ts new file mode 100644 index 0000000000..86cbec583f --- /dev/null +++ b/packages/twenty-front-component-renderer/src/utils/__tests__/createClonableErrorThreadSerialization.test.ts @@ -0,0 +1,185 @@ +import { Thread } from '@quilted/threads'; +import { CustomError } from 'twenty-shared/utils'; + +import { FRONT_COMPONENT_THREAD_ERROR_MARKER } from '@/constants/FrontComponentThreadErrorMarker'; + +import { createClonableErrorThreadSerialization } from '../createClonableErrorThreadSerialization'; + +class FakeGeckoException { + name = 'NS_ERROR_FAILURE'; + message = ''; + stack = '@blob:null/uuid:5:15'; + + toString(): string { + return '[Exception... "Failure" nsresult: "0x80004005 (NS_ERROR_FAILURE)"]'; + } +} + +type TestThreadExports = { + explode(): Promise; +}; + +type ThreadMessageListener = (data: unknown) => void; + +const createDetachedThread = () => + new Thread({ send: () => undefined, listen: () => undefined }); + +const createExplodingThreadPair = () => { + const listeners: { + caller?: ThreadMessageListener; + responder?: ThreadMessageListener; + } = {}; + + const callingThread = new Thread( + { + send: (message) => + queueMicrotask(() => listeners.responder?.(structuredClone(message))), + listen: (listener) => { + listeners.caller = listener; + }, + }, + { serialization: createClonableErrorThreadSerialization() }, + ); + + new Thread, TestThreadExports>( + { + send: (message) => + queueMicrotask(() => listeners.caller?.(structuredClone(message))), + listen: (listener) => { + listeners.responder = listener; + }, + }, + { + exports: { + explode: async () => { + throw new FakeGeckoException(); + }, + }, + serialization: createClonableErrorThreadSerialization(), + }, + ); + + return callingThread; +}; + +describe('createClonableErrorThreadSerialization', () => { + it('should serialize an Error to a marked structured-cloneable payload', () => { + const serialization = createClonableErrorThreadSerialization(); + + const serialized = serialization.serialize( + new Error('boom'), + createDetachedThread(), + ); + + expect(serialized).toMatchObject({ + [FRONT_COMPONENT_THREAD_ERROR_MARKER]: { + name: 'Error', + message: 'boom', + }, + }); + expect(() => structuredClone(serialized)).not.toThrow(); + }); + + it('should serialize a foreign exception object to its stringified diagnostic', () => { + const serialization = createClonableErrorThreadSerialization(); + + const serialized = serialization.serialize( + new FakeGeckoException(), + createDetachedThread(), + ); + + expect(serialized).toEqual({ + [FRONT_COMPONENT_THREAD_ERROR_MARKER]: { + name: 'NS_ERROR_FAILURE', + message: + '[Exception... "Failure" nsresult: "0x80004005 (NS_ERROR_FAILURE)"]', + stack: '@blob:null/uuid:5:15', + code: undefined, + }, + }); + expect(() => structuredClone(serialized)).not.toThrow(); + }); + + it('should serialize errors nested inside regular payloads', () => { + const serialization = createClonableErrorThreadSerialization(); + + const serialized = serialization.serialize( + { results: [new Error('nested boom')] }, + createDetachedThread(), + ); + + expect(serialized).toMatchObject({ + results: [ + { + [FRONT_COMPONENT_THREAD_ERROR_MARKER]: { message: 'nested boom' }, + }, + ], + }); + }); + + it('should leave regular payloads untouched', () => { + const serialization = createClonableErrorThreadSerialization(); + + const payload = { + url: 'https://example.com', + headers: { authorization: 'Bearer token' }, + }; + + expect(serialization.serialize(payload, createDetachedThread())).toEqual( + payload, + ); + }); + + it('should deserialize a marked payload into an Error', () => { + const serialization = createClonableErrorThreadSerialization(); + + const deserialized = serialization.deserialize( + { + [FRONT_COMPONENT_THREAD_ERROR_MARKER]: { + name: 'NS_ERROR_FAILURE', + message: '[Exception...]', + stack: '@blob:null/uuid:5:15', + }, + }, + createDetachedThread(), + ); + + expect(deserialized).toBeInstanceOf(Error); + expect(deserialized).toMatchObject({ + name: 'NS_ERROR_FAILURE', + message: '[Exception...]', + stack: '@blob:null/uuid:5:15', + }); + }); + + it('should round-trip a CustomError with its code', () => { + const serialization = createClonableErrorThreadSerialization(); + const thread = createDetachedThread(); + + const roundTripped = serialization.deserialize( + structuredClone( + serialization.serialize( + new CustomError('fetch bridge unavailable', 'FETCH_BRIDGE'), + thread, + ), + ), + thread, + ); + + expect(roundTripped).toBeInstanceOf(CustomError); + expect(roundTripped).toMatchObject({ + message: 'fetch bridge unavailable', + code: 'FETCH_BRIDGE', + }); + }); + + it('should reject thread calls with the real error when the export throws an uncloneable exception', async () => { + const callingThread = createExplodingThreadPair(); + + await expect(callingThread.imports.explode()).rejects.toMatchObject({ + name: 'NS_ERROR_FAILURE', + message: + '[Exception... "Failure" nsresult: "0x80004005 (NS_ERROR_FAILURE)"]', + }); + }); +}); diff --git a/packages/twenty-front-component-renderer/src/utils/__tests__/extractClonableErrorPayload.test.ts b/packages/twenty-front-component-renderer/src/utils/__tests__/extractClonableErrorPayload.test.ts new file mode 100644 index 0000000000..4aecb13753 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/utils/__tests__/extractClonableErrorPayload.test.ts @@ -0,0 +1,61 @@ +import { FRONT_COMPONENT_THREAD_ERROR_MARKER } from '@/constants/FrontComponentThreadErrorMarker'; + +import { extractClonableErrorPayload } from '../extractClonableErrorPayload'; + +describe('extractClonableErrorPayload', () => { + it('should extract a marked payload', () => { + expect( + extractClonableErrorPayload({ + [FRONT_COMPONENT_THREAD_ERROR_MARKER]: { + name: 'NS_ERROR_FAILURE', + message: '[Exception...]', + stack: '@blob:null/uuid:5:15', + code: 'SOME_CODE', + }, + }), + ).toEqual({ + name: 'NS_ERROR_FAILURE', + message: '[Exception...]', + stack: '@blob:null/uuid:5:15', + code: 'SOME_CODE', + }); + }); + + it('should drop non-string stack and code fields', () => { + expect( + extractClonableErrorPayload({ + [FRONT_COMPONENT_THREAD_ERROR_MARKER]: { + name: 'Error', + message: 'boom', + stack: 42, + code: { nested: true }, + }, + }), + ).toEqual({ + name: 'Error', + message: 'boom', + stack: undefined, + code: undefined, + }); + }); + + it('should return null for unmarked objects', () => { + expect( + extractClonableErrorPayload({ name: 'Error', message: 'boom' }), + ).toBe(null); + }); + + it('should return null when the marked payload is malformed', () => { + expect( + extractClonableErrorPayload({ + [FRONT_COMPONENT_THREAD_ERROR_MARKER]: { message: 42 }, + }), + ).toBe(null); + + expect( + extractClonableErrorPayload({ + [FRONT_COMPONENT_THREAD_ERROR_MARKER]: 'boom', + }), + ).toBe(null); + }); +}); diff --git a/packages/twenty-front-component-renderer/src/utils/__tests__/isErrorLikeValue.test.ts b/packages/twenty-front-component-renderer/src/utils/__tests__/isErrorLikeValue.test.ts new file mode 100644 index 0000000000..41004fbeb7 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/utils/__tests__/isErrorLikeValue.test.ts @@ -0,0 +1,54 @@ +import { CustomError } from 'twenty-shared/utils'; + +import { isErrorLikeValue } from '../isErrorLikeValue'; + +class FakeGeckoException { + name = 'NS_ERROR_FAILURE'; + message = ''; + stack = '@blob:null/uuid:5:15'; + + toString(): string { + return '[Exception... "Failure" nsresult: "0x80004005 (NS_ERROR_FAILURE)"]'; + } +} + +describe('isErrorLikeValue', () => { + it('should match Error instances', () => { + expect(isErrorLikeValue(new Error('boom'))).toBe(true); + }); + + it('should match Error subclasses', () => { + expect(isErrorLikeValue(new CustomError('boom', 'BOOM_CODE'))).toBe(true); + }); + + it('should match DOMException instances', () => { + expect(isErrorLikeValue(new DOMException('denied', 'SecurityError'))).toBe( + true, + ); + }); + + it('should match foreign exception objects exposing name, message and stack strings', () => { + expect(isErrorLikeValue(new FakeGeckoException())).toBe(true); + }); + + it('should not match plain objects missing a stack', () => { + expect(isErrorLikeValue({ name: 'Error', message: 'boom' })).toBe(false); + }); + + it('should not match user data that happens to expose name and message', () => { + expect(isErrorLikeValue({ name: 'Acme', message: 'In stock' })).toBe(false); + }); + + it('should not match plain data objects', () => { + expect( + isErrorLikeValue({ url: 'https://example.com', method: 'GET' }), + ).toBe(false); + }); + + it('should not match primitives', () => { + expect(isErrorLikeValue('boom')).toBe(false); + expect(isErrorLikeValue(42)).toBe(false); + expect(isErrorLikeValue(null)).toBe(false); + expect(isErrorLikeValue(undefined)).toBe(false); + }); +}); diff --git a/packages/twenty-front-component-renderer/src/utils/__tests__/rehydrateClonableError.test.ts b/packages/twenty-front-component-renderer/src/utils/__tests__/rehydrateClonableError.test.ts new file mode 100644 index 0000000000..aae3d5f35e --- /dev/null +++ b/packages/twenty-front-component-renderer/src/utils/__tests__/rehydrateClonableError.test.ts @@ -0,0 +1,36 @@ +import { CustomError } from 'twenty-shared/utils'; + +import { rehydrateClonableError } from '../rehydrateClonableError'; + +describe('rehydrateClonableError', () => { + it('should rebuild an Error with name, message and stack', () => { + const error = rehydrateClonableError({ + name: 'NS_ERROR_FAILURE', + message: '[Exception...]', + stack: '@blob:null/uuid:5:15', + }); + + expect(error).toBeInstanceOf(Error); + expect(error.name).toBe('NS_ERROR_FAILURE'); + expect(error.message).toBe('[Exception...]'); + expect(error.stack).toBe('@blob:null/uuid:5:15'); + }); + + it('should rebuild a CustomError when a code is present', () => { + const error = rehydrateClonableError({ + name: 'CustomError', + message: 'fetch bridge unavailable', + code: 'FETCH_BRIDGE', + }); + + expect(error).toBeInstanceOf(CustomError); + expect(error.message).toBe('fetch bridge unavailable'); + expect(error).toMatchObject({ code: 'FETCH_BRIDGE' }); + }); + + it('should keep the generated stack when the payload has none', () => { + const error = rehydrateClonableError({ name: 'Error', message: 'boom' }); + + expect(error.stack).toEqual(expect.any(String)); + }); +}); diff --git a/packages/twenty-front-component-renderer/src/utils/buildClonableErrorPayload.ts b/packages/twenty-front-component-renderer/src/utils/buildClonableErrorPayload.ts new file mode 100644 index 0000000000..4acf1b8df9 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/utils/buildClonableErrorPayload.ts @@ -0,0 +1,33 @@ +import { isNonEmptyString, isString } from '@sniptt/guards'; + +import { type ClonableErrorPayload } from '@/types/ClonableErrorPayload'; +import { isErrorLikeValue } from '@/utils/isErrorLikeValue'; + +const FALLBACK_ERROR_NAME = 'Error'; + +const stringifyErrorValue = (value: unknown): string => { + try { + return String(value); + } catch { + return FALLBACK_ERROR_NAME; + } +}; + +export const buildClonableErrorPayload = ( + value: unknown, +): ClonableErrorPayload => { + if (!isErrorLikeValue(value)) { + return { name: FALLBACK_ERROR_NAME, message: stringifyErrorValue(value) }; + } + + const { code } = value as { code?: unknown }; + + return { + name: isNonEmptyString(value.name) ? value.name : FALLBACK_ERROR_NAME, + message: isNonEmptyString(value.message) + ? value.message + : stringifyErrorValue(value), + stack: isString(value.stack) ? value.stack : undefined, + code: isString(code) ? code : undefined, + }; +}; diff --git a/packages/twenty-front-component-renderer/src/utils/createClonableErrorThreadSerialization.ts b/packages/twenty-front-component-renderer/src/utils/createClonableErrorThreadSerialization.ts new file mode 100644 index 0000000000..fe31d61632 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/utils/createClonableErrorThreadSerialization.ts @@ -0,0 +1,25 @@ +import { ThreadSerializationStructuredClone } from '@quilted/threads'; +import { isDefined } from 'twenty-shared/utils'; + +import { FRONT_COMPONENT_THREAD_ERROR_MARKER } from '@/constants/FrontComponentThreadErrorMarker'; +import { buildClonableErrorPayload } from '@/utils/buildClonableErrorPayload'; +import { extractClonableErrorPayload } from '@/utils/extractClonableErrorPayload'; +import { isErrorLikeValue } from '@/utils/isErrorLikeValue'; +import { rehydrateClonableError } from '@/utils/rehydrateClonableError'; + +export const createClonableErrorThreadSerialization = + (): ThreadSerializationStructuredClone => + new ThreadSerializationStructuredClone({ + serialize: (value) => + isErrorLikeValue(value) + ? { + [FRONT_COMPONENT_THREAD_ERROR_MARKER]: + buildClonableErrorPayload(value), + } + : undefined, + deserialize: (value) => { + const payload = extractClonableErrorPayload(value); + + return isDefined(payload) ? rehydrateClonableError(payload) : undefined; + }, + }); diff --git a/packages/twenty-front-component-renderer/src/utils/extractClonableErrorPayload.ts b/packages/twenty-front-component-renderer/src/utils/extractClonableErrorPayload.ts new file mode 100644 index 0000000000..07095c162d --- /dev/null +++ b/packages/twenty-front-component-renderer/src/utils/extractClonableErrorPayload.ts @@ -0,0 +1,36 @@ +import { isObject, isString } from '@sniptt/guards'; + +import { FRONT_COMPONENT_THREAD_ERROR_MARKER } from '@/constants/FrontComponentThreadErrorMarker'; +import { type ClonableErrorPayload } from '@/types/ClonableErrorPayload'; + +export const extractClonableErrorPayload = ( + value: object, +): ClonableErrorPayload | null => { + if (!(FRONT_COMPONENT_THREAD_ERROR_MARKER in value)) { + return null; + } + + const payload = value[FRONT_COMPONENT_THREAD_ERROR_MARKER]; + + if (!isObject(payload)) { + return null; + } + + const { name, message, stack, code } = payload as { + name?: unknown; + message?: unknown; + stack?: unknown; + code?: unknown; + }; + + if (!isString(name) || !isString(message)) { + return null; + } + + return { + name, + message, + stack: isString(stack) ? stack : undefined, + code: isString(code) ? code : undefined, + }; +}; diff --git a/packages/twenty-front-component-renderer/src/utils/isErrorLikeValue.ts b/packages/twenty-front-component-renderer/src/utils/isErrorLikeValue.ts new file mode 100644 index 0000000000..4d78852adf --- /dev/null +++ b/packages/twenty-front-component-renderer/src/utils/isErrorLikeValue.ts @@ -0,0 +1,25 @@ +import { isObject, isString } from '@sniptt/guards'; + +import { type ErrorLikeValue } from '@/types/ErrorLikeValue'; + +export const isErrorLikeValue = (value: unknown): value is ErrorLikeValue => { + if (value instanceof Error) { + return true; + } + + if (typeof DOMException !== 'undefined' && value instanceof DOMException) { + return true; + } + + if (!isObject(value)) { + return false; + } + + const { name, message, stack } = value as { + name?: unknown; + message?: unknown; + stack?: unknown; + }; + + return isString(name) && isString(message) && isString(stack); +}; diff --git a/packages/twenty-front-component-renderer/src/utils/rehydrateClonableError.ts b/packages/twenty-front-component-renderer/src/utils/rehydrateClonableError.ts new file mode 100644 index 0000000000..46bf0ba155 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/utils/rehydrateClonableError.ts @@ -0,0 +1,20 @@ +import { isNonEmptyString } from '@sniptt/guards'; +import { CustomError, isDefined } from 'twenty-shared/utils'; + +import { type ClonableErrorPayload } from '@/types/ClonableErrorPayload'; + +export const rehydrateClonableError = ( + payload: ClonableErrorPayload, +): Error => { + const error = isNonEmptyString(payload.code) + ? new CustomError(payload.message, payload.code) + : new Error(payload.message); + + error.name = payload.name; + + if (isDefined(payload.stack)) { + error.stack = payload.stack; + } + + return error; +};