Remote dom twenty UI POC (#17652)

Create a proof of concept which allows us to use twenty-ui in the
remote-dom.
For now only the button is allowed.


https://github.com/user-attachments/assets/e5441d2c-eb63-4b99-931b-86ee14621393

Known limitation: The icons are not yet available
This commit is contained in:
Raphaël Bosi 2026-02-03 16:54:18 +01:00 committed by GitHub
parent 5c91a96e84
commit 66d7182d02
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 230 additions and 21 deletions

View file

@ -1,12 +1,10 @@
import { useState } from 'react';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { t } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
import { getMockFrontComponentUrl } from '@/front-components/utils/mockFrontComponent';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useTheme } from '@emotion/react';
import { t } from '@lingui/core/macro';
import { useState } from 'react';
import { FrontComponentRenderer as SharedFrontComponentRenderer } from 'twenty-sdk/front-component';
import { isDefined } from 'twenty-shared/utils';
type FrontComponentRendererProps = {
frontComponentId: string;
};
@ -14,6 +12,7 @@ type FrontComponentRendererProps = {
export const FrontComponentRenderer = ({
frontComponentId: _frontComponentId,
}: FrontComponentRendererProps) => {
const theme = useTheme();
const [hasError, setHasError] = useState(false);
const { enqueueErrorSnackBar } = useSnackBar();
@ -35,6 +34,7 @@ export const FrontComponentRenderer = ({
return (
<SharedFrontComponentRenderer
theme={theme}
componentUrl={getMockFrontComponentUrl()}
onError={handleError}
/>

View file

@ -1,8 +1,18 @@
import { isDefined } from 'twenty-shared/utils';
// eslint-disable-next-line
const mockFrontComponentCode = `
var r=globalThis.React.useState,s=globalThis.React.useEffect;var n=globalThis.jsx,o=globalThis.jsxs,g=globalThis.React.Fragment;var e=globalThis.RemoteComponents,x=()=>{let[a,l]=r(0),[m,p]=r(0);return s(()=>{let t=setInterval(()=>{p(i=>i+1)},1e3);return()=>clearInterval(t)},[]),o(e.HtmlDiv,{style:{padding:"20px",fontFamily:"Arial, sans-serif",maxWidth:"400px",margin:"0 auto",display:"flex",flexDirection:"column",alignItems:"center"},children:[n(e.HtmlH1,{style:{color:"#333",marginBottom:"20px",fontSize:"24px"},children:"Test Component"}),o(e.HtmlP,{style:{fontSize:"18px",marginBottom:"10px",color:"#666"},children:["Count: ",a]}),o(e.HtmlP,{style:{fontSize:"18px",marginBottom:"20px",color:"#666"},children:["Timer: ",m,"s"]}),n(e.HtmlButton,{onClick:()=>l(a+1),style:{padding:"10px 20px",fontSize:"16px",backgroundColor:"#007bff",color:"white",border:"none",borderRadius:"5px",cursor:"pointer",transition:"all 0.3s ease",boxShadow:"0 2px 4px rgba(0, 0, 0, 0.2)"},onMouseEnter:t=>{t.currentTarget.style.backgroundColor="#0056b3",t.currentTarget.style.transform="translateY(-2px)",t.currentTarget.style.boxShadow="0 4px 8px rgba(0, 0, 0, 0.3)"},onMouseLeave:t=>{t.currentTarget.style.backgroundColor="#007bff",t.currentTarget.style.transform="translateY(0)",t.currentTarget.style.boxShadow="0 2px 4px rgba(0, 0, 0, 0.2)"},children:"Increment"})]})},b=globalThis.jsx(x,{});export{b as default};
const { jsx } = globalThis;
const { TwentyUiButton } = globalThis.RemoteComponents;
export default jsx(TwentyUiButton, {
variant: 'primary',
disabled: false,
fullWidth: false,
onClick: () => {
console.log('Button clicked');
},
title: 'Click me',
});
`;
let cachedMockBlobUrl: string | null = null;

View file

@ -63,6 +63,7 @@
"jsonc-parser": "^3.2.0",
"lodash.camelcase": "^4.3.0",
"react": "^18.0.0",
"twenty-ui": "workspace:*",
"typescript": "^5.9.2",
"uuid": "^13.0.0",
"vite": "^7.0.0",

View file

@ -1,10 +1,10 @@
/* eslint-disable no-console */
import * as prettier from '@prettier/sync';
import * as fs from 'fs';
import * as path from 'path';
import { IndentationText, Project, QuoteKind } from 'ts-morph';
import { ALLOWED_HTML_ELEMENTS } from '../../src/front-component-constants/AllowedHtmlElements';
import { ALLOWED_UI_COMPONENTS } from '../../src/front-component-constants/AllowedUiComponents';
import { COMMON_HTML_EVENTS } from '../../src/front-component-constants/CommonHtmlEvents';
import { EVENT_TO_REACT } from '../../src/front-component-constants/EventToReact';
import { HTML_COMMON_PROPERTIES } from '../../src/front-component-constants/HtmlCommonProperties';
@ -17,6 +17,7 @@ import {
generateRemoteElements,
HtmlElementConfigArrayZ,
OUTPUT_FILES,
UiComponentConfigArrayZ,
} from './generators';
const SCRIPT_DIR = path.dirname(new URL(import.meta.url).pathname);
@ -59,6 +60,28 @@ const getHtmlElementSchemas = (): ComponentSchema[] => {
}));
};
const getUiComponentSchemas = (): ComponentSchema[] => {
const result = UiComponentConfigArrayZ.safeParse(ALLOWED_UI_COMPONENTS);
if (!result.success) {
throw new Error(
`Invalid UI component configuration:\n${formatZodError(result.error)}`,
);
}
return result.data.map((component) => ({
name: component.name,
tagName: component.name,
customElementName: component.tag,
properties: component.properties,
events: COMMON_HTML_EVENTS,
isHtmlElement: false,
htmlTag: undefined,
componentImport: component.componentImport,
componentPath: component.componentPath,
}));
};
const createProject = (): Project => {
return new Project({
manipulationSettings: {
@ -99,8 +122,11 @@ const main = (): void => {
console.log('📖 Generating remote DOM elements...\n');
let htmlElements: ComponentSchema[];
let uiComponents: ComponentSchema[];
try {
htmlElements = getHtmlElementSchemas();
uiComponents = getUiComponentSchemas();
} catch (error) {
console.error('❌ Validation failed:', error);
process.exit(1);
@ -113,8 +139,15 @@ const main = (): void => {
console.log(
` Events: ${COMMON_HTML_EVENTS.length} common events per element`,
);
console.log(`\nUI Components: ${uiComponents.length} components`);
console.log(
` Tags: ${uiComponents.map((component) => component.customElementName).join(', ')}`,
);
console.log('');
const allComponents = [...htmlElements, ...uiComponents];
ensureDirectoriesExist();
const project = createProject();
@ -122,7 +155,7 @@ const main = (): void => {
console.log('Host files:');
const hostRegistry = generateHostRegistry(
project,
htmlElements,
allComponents,
EVENT_TO_REACT,
);
writeGeneratedFile(
@ -134,7 +167,7 @@ const main = (): void => {
console.log('\nRemote files:');
const remoteElements = generateRemoteElements(
project,
htmlElements,
allComponents,
HTML_COMMON_PROPERTIES,
COMMON_HTML_EVENTS,
);
@ -144,7 +177,7 @@ const main = (): void => {
remoteElements.getFullText(),
);
const remoteComponents = generateRemoteComponents(project, htmlElements);
const remoteComponents = generateRemoteComponents(project, allComponents);
writeGeneratedFile(
REMOTE_GENERATED_DIR,
OUTPUT_FILES.REMOTE_COMPONENTS,

View file

@ -1,5 +1,6 @@
import type { Project, SourceFile } from 'ts-morph';
import { isDefined } from 'twenty-shared/utils';
import { CUSTOM_ELEMENT_NAMES } from './constants';
import { type ComponentSchema } from './schemas';
import { addFileHeader, addStatement } from './utils';
@ -65,12 +66,25 @@ const filterProps = (props: Record<string, unknown>) => {
};`;
};
const generateWrapperComponent = (component: ComponentSchema): string => {
const generateHtmlWrapperComponent = (component: ComponentSchema): string => {
return `const ${component.name}Wrapper = ({ children, ...props }: { children?: React.ReactNode } & Record<string, unknown>) => {
return React.createElement('${component.htmlTag}', filterProps(props), children);
};`;
};
const generateUiWrapperComponent = (component: ComponentSchema): string => {
return `const ${component.name}Wrapper = ({ children, ...props }: { children?: React.ReactNode } & Record<string, unknown>) => {
return React.createElement(${component.componentImport}, filterProps(props), children);
};`;
};
const generateWrapperComponent = (component: ComponentSchema): string => {
if (component.isHtmlElement) {
return generateHtmlWrapperComponent(component);
}
return generateUiWrapperComponent(component);
};
const generateRegistryMap = (components: ComponentSchema[]): string => {
const entries = components
.map(
@ -89,6 +103,28 @@ ${entries}
]);`;
};
const groupImportsByPath = (
components: ComponentSchema[],
): Map<string, string[]> => {
const importsByPath = new Map<string, string[]>();
for (const component of components) {
if (
!component.isHtmlElement &&
isDefined(component.componentPath) &&
isDefined(component.componentImport)
) {
const existing = importsByPath.get(component.componentPath) ?? [];
if (!existing.includes(component.componentImport)) {
existing.push(component.componentImport);
}
importsByPath.set(component.componentPath, existing);
}
}
return importsByPath;
};
export const generateHostRegistry = (
project: Project,
components: ComponentSchema[],
@ -110,6 +146,15 @@ export const generateHostRegistry = (
namedImports: ['RemoteFragmentRenderer', 'createRemoteComponentRenderer'],
});
const uiImports = groupImportsByPath(components);
for (const [modulePath, namedImports] of uiImports) {
sourceFile.addImportDeclaration({
moduleSpecifier: modulePath,
namedImports,
});
}
addStatement(sourceFile, generateRuntimeUtilities(eventToReactMapping));
for (const component of components) {

View file

@ -15,6 +15,20 @@ export const HtmlElementConfigZ = z.object({
export const HtmlElementConfigArrayZ = z.array(HtmlElementConfigZ);
export const UiComponentConfigZ = z.object({
tag: z
.string()
.regex(/^twenty-ui-[a-z0-9-]+$/, 'Tag must start with "twenty-ui-"'),
name: z
.string()
.regex(/^TwentyUi[A-Z]/, 'Name must be PascalCase starting with TwentyUi'),
properties: z.record(z.string(), PropertySchemaZ),
componentImport: z.string().min(1),
componentPath: z.string().min(1),
});
export const UiComponentConfigArrayZ = z.array(UiComponentConfigZ);
export const ComponentSchemaZ = z.object({
name: z.string().min(1),
tagName: z.string().min(1),
@ -22,9 +36,12 @@ export const ComponentSchemaZ = z.object({
properties: z.record(z.string(), PropertySchemaZ),
events: z.array(z.string()).readonly(),
isHtmlElement: z.boolean(),
htmlTag: z.string().min(1),
htmlTag: z.string().optional(),
componentImport: z.string().optional(),
componentPath: z.string().optional(),
});
export type PropertySchema = z.infer<typeof PropertySchemaZ>;
export type HtmlElementConfig = z.infer<typeof HtmlElementConfigZ>;
export type UiComponentConfig = z.infer<typeof UiComponentConfigZ>;
export type ComponentSchema = z.infer<typeof ComponentSchemaZ>;

View file

@ -0,0 +1,26 @@
import { type PropertySchema } from '../front-component/types/PropertySchema';
export type AllowedUiComponent = {
tag: string;
name: string;
properties: Record<string, PropertySchema>;
componentImport: string;
componentPath: string;
};
export const ALLOWED_UI_COMPONENTS: AllowedUiComponent[] = [
{
tag: 'twenty-ui-button',
name: 'TwentyUiButton',
properties: {
title: { type: 'string', optional: true },
variant: { type: 'string', optional: true },
accent: { type: 'string', optional: true },
size: { type: 'string', optional: true },
disabled: { type: 'boolean', optional: true },
fullWidth: { type: 'boolean', optional: true },
},
componentImport: 'Button',
componentPath: 'twenty-ui/input',
},
];

View file

@ -9,6 +9,8 @@
export type { AllowedHtmlElement } from './AllowedHtmlElements';
export { ALLOWED_HTML_ELEMENTS } from './AllowedHtmlElements';
export type { AllowedUiComponent } from './AllowedUiComponents';
export { ALLOWED_UI_COMPONENTS } from './AllowedUiComponents';
export { COMMON_HTML_EVENTS } from './CommonHtmlEvents';
export { EVENT_TO_REACT } from './EventToReact';
export { HTML_COMMON_PROPERTIES } from './HtmlCommonProperties';

View file

@ -2,19 +2,24 @@ import {
type RemoteReceiver,
RemoteRootRenderer,
} from '@remote-dom/react/host';
import React, { useState } from 'react';
import { useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { ThemeProvider } from '@emotion/react';
import { type ThemeType } from 'twenty-ui/theme';
import { FrontComponentWorkerEffect } from '../../remote/components/FrontComponentWorkerEffect';
import { componentRegistry } from '../generated/host-component-registry';
type FrontComponentContentProps = {
componentUrl: string;
onError: (error?: Error) => void;
theme: ThemeType;
};
export const FrontComponentRenderer = ({
componentUrl,
onError,
theme,
}: FrontComponentContentProps) => {
const [receiver, setReceiver] = useState<RemoteReceiver | null>(null);
@ -27,10 +32,12 @@ export const FrontComponentRenderer = ({
/>
{isDefined(receiver) && (
<RemoteRootRenderer
receiver={receiver}
components={componentRegistry}
/>
<ThemeProvider theme={theme}>
<RemoteRootRenderer
receiver={receiver}
components={componentRegistry}
/>
</ThemeProvider>
)}
</>
);

View file

@ -12,6 +12,7 @@ import {
RemoteFragmentRenderer,
createRemoteComponentRenderer,
} from '@remote-dom/react/host';
import { Button } from 'twenty-ui/input';
const INTERNAL_PROPS = new Set(['element', 'receiver', 'components']);
const EVENT_NAME_MAP: Record<string, string> = {
@ -345,6 +346,12 @@ const HtmlHrWrapper = ({
}: { children?: React.ReactNode } & Record<string, unknown>) => {
return React.createElement('hr', filterProps(props), children);
};
const TwentyUiButtonWrapper = ({
children,
...props
}: { children?: React.ReactNode } & Record<string, unknown>) => {
return React.createElement(Button, filterProps(props), children);
};
type ComponentRegistryValue =
| ReturnType<typeof createRemoteComponentRenderer>
| typeof RemoteFragmentRenderer;
@ -393,5 +400,6 @@ export const componentRegistry: Map<string, ComponentRegistryValue> = new Map([
['html-td', createRemoteComponentRenderer(HtmlTdWrapper)],
['html-br', createRemoteComponentRenderer(HtmlBrWrapper)],
['html-hr', createRemoteComponentRenderer(HtmlHrWrapper)],
['twenty-ui-button', createRemoteComponentRenderer(TwentyUiButtonWrapper)],
['remote-fragment', RemoteFragmentRenderer],
]);

View file

@ -54,6 +54,7 @@ export {
HtmlTd,
HtmlBr,
HtmlHr,
TwentyUiButton,
} from './remote/generated/remote-components';
export type {
HtmlCommonProperties,
@ -69,6 +70,7 @@ export type {
HtmlButtonProperties,
HtmlThProperties,
HtmlTdProperties,
TwentyUiButtonProperties,
} from './remote/generated/remote-elements';
export {
HtmlDivElement,
@ -114,6 +116,7 @@ export {
HtmlTdElement,
HtmlBrElement,
HtmlHrElement,
TwentyUiButtonElement,
RemoteRootElement,
RemoteFragmentElement,
} from './remote/generated/remote-elements';

View file

@ -52,6 +52,7 @@ import {
HtmlTdElement,
HtmlBrElement,
HtmlHrElement,
TwentyUiButtonElement,
} from './remote-elements';
export const HtmlDiv = createRemoteComponent('html-div', HtmlDivElement, {
@ -1126,3 +1127,31 @@ export const HtmlHr = createRemoteComponent('html-hr', HtmlHrElement, {
onDrag: { event: 'drag' },
},
});
export const TwentyUiButton = createRemoteComponent(
'twenty-ui-button',
TwentyUiButtonElement,
{
eventProps: {
onClick: { event: 'click' },
onDblclick: { event: 'dblclick' },
onMousedown: { event: 'mousedown' },
onMouseup: { event: 'mouseup' },
onMouseover: { event: 'mouseover' },
onMouseout: { event: 'mouseout' },
onMouseenter: { event: 'mouseenter' },
onMouseleave: { event: 'mouseleave' },
onKeydown: { event: 'keydown' },
onKeyup: { event: 'keyup' },
onKeypress: { event: 'keypress' },
onFocus: { event: 'focus' },
onBlur: { event: 'blur' },
onChange: { event: 'change' },
onInput: { event: 'input' },
onSubmit: { event: 'submit' },
onScroll: { event: 'scroll' },
onWheel: { event: 'wheel' },
onContextmenu: { event: 'contextmenu' },
onDrag: { event: 'drag' },
},
},
);

View file

@ -608,6 +608,31 @@ export const HtmlHrElement = createRemoteElement<
properties: HTML_COMMON_PROPERTIES_CONFIG,
events: [...HTML_COMMON_EVENTS_ARRAY],
});
export type TwentyUiButtonProperties = HtmlCommonProperties & {
variant?: string;
accent?: string;
size?: string;
disabled?: boolean;
fullWidth?: boolean;
};
export const TwentyUiButtonElement = createRemoteElement<
TwentyUiButtonProperties,
Record<string, never>,
Record<string, never>,
HtmlCommonEvents
>({
properties: {
...HTML_COMMON_PROPERTIES_CONFIG,
variant: { type: String },
accent: { type: String },
size: { type: String },
disabled: { type: Boolean },
fullWidth: { type: Boolean },
},
events: [...HTML_COMMON_EVENTS_ARRAY],
});
customElements.define('html-div', HtmlDivElement);
customElements.define('html-span', HtmlSpanElement);
customElements.define('html-section', HtmlSectionElement);
@ -651,6 +676,7 @@ customElements.define('html-th', HtmlThElement);
customElements.define('html-td', HtmlTdElement);
customElements.define('html-br', HtmlBrElement);
customElements.define('html-hr', HtmlHrElement);
customElements.define('twenty-ui-button', TwentyUiButtonElement);
customElements.define('remote-root', RemoteRootElement);
customElements.define('remote-fragment', RemoteFragmentElement);
@ -700,6 +726,7 @@ declare global {
'html-td': InstanceType<typeof HtmlTdElement>;
'html-br': InstanceType<typeof HtmlBrElement>;
'html-hr': InstanceType<typeof HtmlHrElement>;
'twenty-ui-button': InstanceType<typeof TwentyUiButtonElement>;
'remote-root': InstanceType<typeof RemoteRootElement>;
'remote-fragment': InstanceType<typeof RemoteFragmentElement>;
}

View file

@ -4,7 +4,7 @@
"sourceRoot": "packages/twenty-ui/src",
"projectType": "library",
"tags": [
"scope:frontend"
"scope:shared"
],
"targets": {
"build": {

View file

@ -57675,6 +57675,7 @@ __metadata:
react: "npm:^18.0.0"
ts-morph: "npm:^25.0.0"
tsx: "npm:^4.7.0"
twenty-ui: "workspace:*"
typescript: "npm:^5.9.2"
uuid: "npm:^13.0.0"
vite: "npm:^7.0.0"