Fix front components rendering a blank panel in Firefox (#23213)
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<br/>(typeof caches) Worker->>Threads: postMessage(rawException) Threads--xHost: DataCloneError:<br/>Exception could not be cloned note over Host: CALL_RESULT never arrives<br/>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 ``` <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23213?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:
@@ -13,9 +13,10 @@ const jestConfig = {
|
||||
displayName: 'twenty-front-component-renderer',
|
||||
preset: '../../jest.preset.js',
|
||||
testEnvironment: 'jsdom',
|
||||
transformIgnorePatterns: ['../../node_modules/'],
|
||||
setupFiles: ['<rootDir>/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: '<rootDir>/',
|
||||
}),
|
||||
'^@quilted/threads$':
|
||||
'<rootDir>/../../node_modules/@quilted/threads/build/esm/index.mjs',
|
||||
'^@quilted/events$':
|
||||
'<rootDir>/../../node_modules/@quilted/events/build/esm/index.mjs',
|
||||
},
|
||||
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
|
||||
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'mjs'],
|
||||
extensionsToTreatAsEsm: ['.ts', '.tsx'],
|
||||
coverageDirectory: './coverage',
|
||||
};
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { deserialize, serialize } from 'node:v8';
|
||||
|
||||
if (typeof globalThis.structuredClone !== 'function') {
|
||||
globalThis.structuredClone = (value) => deserialize(serialize(value));
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
export const FRONT_COMPONENT_THREAD_ERROR_MARKER =
|
||||
'__frontComponentThreadError';
|
||||
+43
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
+2
@@ -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();
|
||||
|
||||
+10
-8
@@ -8,11 +8,13 @@ export const frontComponentCacheStorageService = {
|
||||
}: {
|
||||
source: string;
|
||||
}): Promise<string | undefined> => {
|
||||
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<Cache | undefined> => {
|
||||
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;
|
||||
|
||||
+20
@@ -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)"]',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+15
-7
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export type ClonableErrorPayload = {
|
||||
name: string;
|
||||
message: string;
|
||||
stack?: string;
|
||||
code?: string;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
+58
@@ -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();
|
||||
});
|
||||
});
|
||||
+185
@@ -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<void>;
|
||||
};
|
||||
|
||||
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<TestThreadExports>(
|
||||
{
|
||||
send: (message) =>
|
||||
queueMicrotask(() => listeners.responder?.(structuredClone(message))),
|
||||
listen: (listener) => {
|
||||
listeners.caller = listener;
|
||||
},
|
||||
},
|
||||
{ serialization: createClonableErrorThreadSerialization() },
|
||||
);
|
||||
|
||||
new Thread<Record<string, never>, 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)"]',
|
||||
});
|
||||
});
|
||||
});
|
||||
+61
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
+36
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
+25
@@ -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;
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user