diff --git a/packages/twenty-front-component-renderer/package.json b/packages/twenty-front-component-renderer/package.json index f2adc63172..a134dd3b2c 100644 --- a/packages/twenty-front-component-renderer/package.json +++ b/packages/twenty-front-component-renderer/package.json @@ -26,6 +26,7 @@ "@sniptt/guards": "^0.2.0", "react": "^19.2.0", "react-dom": "^19.2.0", + "react-error-boundary": "^4.0.11", "zod": "^4.1.11" }, "devDependencies": { diff --git a/packages/twenty-front-component-renderer/src/host/components/FrontComponentRenderer.tsx b/packages/twenty-front-component-renderer/src/host/components/FrontComponentRenderer.tsx index b808243b1b..29a4168dbf 100644 --- a/packages/twenty-front-component-renderer/src/host/components/FrontComponentRenderer.tsx +++ b/packages/twenty-front-component-renderer/src/host/components/FrontComponentRenderer.tsx @@ -12,11 +12,16 @@ import { RemoteRootRenderer, } from '@remote-dom/react/host'; import { useMemo, useState } from 'react'; +import { ErrorBoundary } from 'react-error-boundary'; import { isDefined } from 'twenty-shared/utils'; import { ThemeProvider } from 'twenty-ui/theme-constants'; import { FrontComponentWorkerEffect } from '../../remote/components/FrontComponentWorkerEffect'; import { componentRegistry } from '../generated/host-component-registry'; +import { createFallbackComponentRegistry } from '../utils/createFallbackComponentRegistry'; + +const fallbackComponentRegistry = + createFallbackComponentRegistry(componentRegistry); type FrontComponentContentProps = { componentUrl: string; @@ -128,10 +133,17 @@ export const FrontComponentRenderer = ({ {isDefined(receiver) && isExecutionContextInitialized && ( - + setError(null)} + resetKeys={[componentUrl]} + fallbackRender={() => null} + > + + )} diff --git a/packages/twenty-front-component-renderer/src/host/constants/DenyListedRemoteElementTags.ts b/packages/twenty-front-component-renderer/src/host/constants/DenyListedRemoteElementTags.ts new file mode 100644 index 0000000000..e5edce5db2 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/host/constants/DenyListedRemoteElementTags.ts @@ -0,0 +1,10 @@ +export const DENY_LISTED_REMOTE_ELEMENT_TAGS = new Set([ + 'script', + 'object', + 'embed', + 'link', + 'meta', + 'base', + 'noscript', + 'style', +]); diff --git a/packages/twenty-front-component-renderer/src/host/utils/__tests__/createFallbackComponentRegistry.test.ts b/packages/twenty-front-component-renderer/src/host/utils/__tests__/createFallbackComponentRegistry.test.ts new file mode 100644 index 0000000000..8e5f0f34eb --- /dev/null +++ b/packages/twenty-front-component-renderer/src/host/utils/__tests__/createFallbackComponentRegistry.test.ts @@ -0,0 +1,64 @@ +import { RemoteFragmentRenderer } from '@remote-dom/react/host'; + +import { createFallbackComponentRegistry } from '../createFallbackComponentRegistry'; + +const DIV_COMPONENT = (() => null) as never; +const IFRAME_COMPONENT = (() => null) as never; + +const buildBaseRegistry = () => + new Map([ + ['html-div', DIV_COMPONENT], + ['html-iframe', IFRAME_COMPONENT], + ]) as Map; + +describe('createFallbackComponentRegistry', () => { + it('should return the registered component for a known tag', () => { + const registry = createFallbackComponentRegistry(buildBaseRegistry()); + + expect(registry.get('html-div')).toBe(DIV_COMPONENT); + }); + + it('should render children only for an unknown tag', () => { + const registry = createFallbackComponentRegistry(buildBaseRegistry()); + + expect(registry.get('some-unknown-tag')).toBe(RemoteFragmentRenderer); + }); + + it('should route a raw iframe to the sandboxing html-iframe renderer instead of denying it', () => { + const registry = createFallbackComponentRegistry(buildBaseRegistry()); + + expect(registry.get('iframe')).toBe(IFRAME_COMPONENT); + }); + + it('should route a raw tag to its safe renderer regardless of casing or html- prefix', () => { + const registry = createFallbackComponentRegistry(buildBaseRegistry()); + + expect(registry.get('IFRAME')).toBe(IFRAME_COMPONENT); + expect(registry.get('HTML-IFRAME')).toBe(IFRAME_COMPONENT); + }); + + it('should render nothing for a deny-listed tag with no safe renderer', () => { + const registry = createFallbackComponentRegistry(buildBaseRegistry()); + + const scriptComponent = registry.get('script'); + + expect(scriptComponent).toBeDefined(); + expect(scriptComponent).not.toBe(RemoteFragmentRenderer); + expect(registry.get('script')).toBe(scriptComponent); + }); + + it('should deny-list tags regardless of an html- prefix or casing', () => { + const registry = createFallbackComponentRegistry(buildBaseRegistry()); + + expect(registry.get('SCRIPT')).not.toBe(RemoteFragmentRenderer); + expect(registry.get('html-OBJECT')).not.toBe(RemoteFragmentRenderer); + }); + + it('should remain a Map instance and not mutate the base registry', () => { + const baseRegistry = buildBaseRegistry(); + const registry = createFallbackComponentRegistry(baseRegistry); + + expect(registry).toBeInstanceOf(Map); + expect(baseRegistry.get('script')).toBeUndefined(); + }); +}); diff --git a/packages/twenty-front-component-renderer/src/host/utils/createFallbackComponentRegistry.ts b/packages/twenty-front-component-renderer/src/host/utils/createFallbackComponentRegistry.ts new file mode 100644 index 0000000000..f31c2e84a1 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/host/utils/createFallbackComponentRegistry.ts @@ -0,0 +1,51 @@ +import { + RemoteFragmentRenderer, + createRemoteComponentRenderer, +} from '@remote-dom/react/host'; +import { isDefined } from 'twenty-shared/utils'; + +import { DENY_LISTED_REMOTE_ELEMENT_TAGS } from '@/host/constants/DenyListedRemoteElementTags'; + +type ComponentRegistryValue = + | ReturnType + | typeof RemoteFragmentRenderer; + +const RenderNothingComponent = createRemoteComponentRenderer(() => null); +const RenderChildrenOnlyComponent = RemoteFragmentRenderer; + +const getNormalizedTagName = (tag: string): string => + tag.toLowerCase().replace(/^(html-)+/, ''); + +export const createFallbackComponentRegistry = ( + baseRegistry: Map, +): Map => { + const registryWithFallback = new Map(baseRegistry); + const getRegisteredComponentForTag = + Map.prototype.get.bind(registryWithFallback); + + registryWithFallback.get = ( + tag: string, + ): ComponentRegistryValue | undefined => { + const directlyRegisteredComponent = getRegisteredComponentForTag(tag); + if (isDefined(directlyRegisteredComponent)) { + return directlyRegisteredComponent; + } + + const normalizedTagName = getNormalizedTagName(tag); + + const safeWrapperComponent = getRegisteredComponentForTag( + `html-${normalizedTagName}`, + ); + if (isDefined(safeWrapperComponent)) { + return safeWrapperComponent; + } + + if (DENY_LISTED_REMOTE_ELEMENT_TAGS.has(normalizedTagName)) { + return RenderNothingComponent; + } + + return RenderChildrenOnlyComponent; + }; + + return registryWithFallback; +}; diff --git a/yarn.lock b/yarn.lock index 13eb0554a4..d14d60071d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -52785,6 +52785,7 @@ __metadata: prettier: "npm:^3.1.1" react: "npm:^19.2.0" react-dom: "npm:^19.2.0" + react-error-boundary: "npm:^4.0.11" storybook: "npm:^10.4.6" styled-components: "npm:^6.1.0" ts-morph: "npm:^25.0.0"