From ff6a0c6e6930fa6c2e04646326687d477d5de857 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 15:13:50 +0200
Subject: [PATCH] Fix front component crash on unknown elements (#22455)
## What
Front components are third-party React components rendered on the host
via remote-dom against an allow-list of elements. Today the host
renderer throws on any element tag it has no component for (e.g. a raw
tag produced by `innerHTML`), and there is no error boundary, so a
single unknown element crashes the whole widget.
This wraps the component registry with a fallback:
- a raw tag that has an allow-listed `html-*` equivalent is routed to
that safe wrapper (so a raw `iframe` renders through the existing
sandbox-forcing renderer instead of being dropped),
- tags with no safe renderer (`script`, `object`, `embed`, `link`,
`meta`, `base`, `noscript`, `style`) render nothing,
- any other unknown tag renders children only.
`RemoteRootRenderer` is also wrapped in an error boundary that fails
closed to the existing error panel, so a render error can no longer take
down the host.
## Notes
The host allow-list remains the single rendering gate. This is the first
hardening step of a broader effort to widen the DOM/Web API surface
available to front components; it is self-contained and does not change
behavior for components that only use allow-listed elements.
---
.../package.json | 1 +
.../components/FrontComponentRenderer.tsx | 20 ++++--
.../constants/DenyListedRemoteElementTags.ts | 10 +++
.../createFallbackComponentRegistry.test.ts | 64 +++++++++++++++++++
.../utils/createFallbackComponentRegistry.ts | 51 +++++++++++++++
yarn.lock | 1 +
6 files changed, 143 insertions(+), 4 deletions(-)
create mode 100644 packages/twenty-front-component-renderer/src/host/constants/DenyListedRemoteElementTags.ts
create mode 100644 packages/twenty-front-component-renderer/src/host/utils/__tests__/createFallbackComponentRegistry.test.ts
create mode 100644 packages/twenty-front-component-renderer/src/host/utils/createFallbackComponentRegistry.ts
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"