diff --git a/packages/twenty-front/src/modules/front-components/utils/mockFrontComponent.ts b/packages/twenty-front/src/modules/front-components/utils/mockFrontComponent.ts
index f0dfdfdb70..ec0742cb7d 100644
--- a/packages/twenty-front/src/modules/front-components/utils/mockFrontComponent.ts
+++ b/packages/twenty-front/src/modules/front-components/utils/mockFrontComponent.ts
@@ -1,54 +1,9 @@
-// Mock component code that runs inside the remote worker
-
import { isDefined } from 'twenty-shared/utils';
-// Uses globalThis.React and globalThis.RemoteComponents which are exposed by the worker
+// eslint-disable-next-line
const mockFrontComponentCode = `
-const React = globalThis.React;
-const { useEffect, useState } = React;
-
-const { HtmlButton, HtmlDiv, HtmlH3, HtmlP } = globalThis.RemoteComponents;
-
-const FrontComponent = () => {
- const [clickCount, setClickCount] = useState(0);
- const [currentTime, setCurrentTime] = useState(
- new Date().toLocaleTimeString(),
- );
-
- useEffect(() => {
- const interval = setInterval(() => {
- setCurrentTime(new Date().toLocaleTimeString());
- }, 1000);
- return () => clearInterval(interval);
- }, []);
-
- return React.createElement(
- HtmlDiv,
- null,
- React.createElement(HtmlH3, null, 'Remote DOM front component'),
- React.createElement(
- HtmlP,
- null,
- 'Rendered in a web worker and mirrored on the host.',
- ),
- React.createElement(
- HtmlButton,
- { onClick: () => setClickCount(clickCount + 1) },
- 'Click me',
- ),
- React.createElement(
- HtmlP,
- null,
- 'Clicked ',
- clickCount,
- ' time',
- clickCount === 1 ? '' : 's',
- ),
- React.createElement(HtmlP, null, 'Current time: ', currentTime),
- );
-};
-
-export default React.createElement(FrontComponent);
+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};
+//# sourceMappingURL=test-component.front-component.mjs.map
`;
let cachedMockBlobUrl: string | null = null;
diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/esbuild-watcher.ts b/packages/twenty-sdk/src/cli/utilities/build/common/esbuild-watcher.ts
index 800f7f7837..1146fd084b 100644
--- a/packages/twenty-sdk/src/cli/utilities/build/common/esbuild-watcher.ts
+++ b/packages/twenty-sdk/src/cli/utilities/build/common/esbuild-watcher.ts
@@ -1,15 +1,17 @@
-import * as esbuild from 'esbuild';
-import path from 'path';
import { cleanupRemovedFiles } from '@/cli/utilities/build/common/cleanup-removed-files';
import { processEsbuildResult } from '@/cli/utilities/build/common/esbuild-result-processor';
+import { jsxTransformToRemoteDomWorkerFormatPlugin } from '@/cli/utilities/build/common/front-component-build/jsx-transform-to-remote-dom-worker-format-plugin';
+import { reactGlobalsPlugin } from '@/cli/utilities/build/common/front-component-build/react-globals-plugin';
import {
type OnBuildErrorCallback,
type OnFileBuiltCallback,
type RestartableWatcher,
type RestartableWatcherOptions,
} from '@/cli/utilities/build/common/restartable-watcher-interface';
-import { FileFolder } from 'twenty-shared/types';
+import * as esbuild from 'esbuild';
+import path from 'path';
import { OUTPUT_DIR } from 'twenty-shared/application';
+import { FileFolder } from 'twenty-shared/types';
export const FUNCTION_EXTERNAL_MODULES: string[] = [
'path',
@@ -37,10 +39,7 @@ export const FUNCTION_EXTERNAL_MODULES: string[] = [
];
export const FRONT_COMPONENT_EXTERNAL_MODULES: string[] = [
- 'react',
'react-dom',
- 'react/jsx-runtime',
- 'react/jsx-dev-runtime',
'twenty-sdk',
'twenty-sdk/*',
'twenty-shared',
@@ -53,6 +52,7 @@ export type EsbuildWatcherConfig = {
platform?: esbuild.Platform;
jsx?: 'automatic';
extraPlugins?: esbuild.Plugin[];
+ minify?: boolean;
};
export type EsbuildWatcherOptions = RestartableWatcherOptions & {
@@ -177,6 +177,7 @@ export class EsbuildWatcher implements RestartableWatcher {
sourcemap: true,
metafile: true,
logLevel: 'silent',
+ minify: this.config.minify,
plugins,
});
@@ -225,5 +226,9 @@ export const createFrontComponentsWatcher = (
externalModules: FRONT_COMPONENT_EXTERNAL_MODULES,
fileFolder: FileFolder.BuiltFrontComponent,
jsx: 'automatic',
+ extraPlugins: [
+ reactGlobalsPlugin,
+ jsxTransformToRemoteDomWorkerFormatPlugin,
+ ],
},
});
diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/__tests__/jsx-transform-to-remote-dom-worker-format-plugin.spec.ts b/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/__tests__/jsx-transform-to-remote-dom-worker-format-plugin.spec.ts
new file mode 100644
index 0000000000..4ce5d6f77f
--- /dev/null
+++ b/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/__tests__/jsx-transform-to-remote-dom-worker-format-plugin.spec.ts
@@ -0,0 +1,191 @@
+import { transformJsxToRemoteComponents } from '@/cli/utilities/build/common/front-component-build/jsx-transform-to-remote-dom-worker-format-plugin';
+
+describe('transformJsxToRemoteComponents', () => {
+ describe('basic tag transformations', () => {
+ it('should transform div tags', () => {
+ const input = '
Hello
';
+ const expected =
+ 'Hello';
+ expect(transformJsxToRemoteComponents(input)).toBe(expected);
+ });
+
+ it('should transform span tags', () => {
+ const input = 'Text';
+ const expected =
+ 'Text';
+ expect(transformJsxToRemoteComponents(input)).toBe(expected);
+ });
+
+ it('should transform button tags', () => {
+ const input = '';
+ const expected =
+ 'Click me';
+ expect(transformJsxToRemoteComponents(input)).toBe(expected);
+ });
+
+ it('should transform self-closing tags', () => {
+ const input = '
';
+ const expected = '';
+ expect(transformJsxToRemoteComponents(input)).toBe(expected);
+ });
+
+ it('should transform img tags with attributes', () => {
+ const input = '
';
+ const expected = '';
+ expect(transformJsxToRemoteComponents(input)).toBe(expected);
+ });
+ });
+
+ describe('nested elements', () => {
+ it('should transform nested elements', () => {
+ const input = 'Nested
';
+ const expected =
+ 'Nested';
+ expect(transformJsxToRemoteComponents(input)).toBe(expected);
+ });
+
+ it('should transform deeply nested elements', () => {
+ const input = '';
+ const expected =
+ 'Item';
+ expect(transformJsxToRemoteComponents(input)).toBe(expected);
+ });
+ });
+
+ describe('attributes preservation', () => {
+ it('should preserve className attribute', () => {
+ const input = 'Content
';
+ const expected =
+ 'Content';
+ expect(transformJsxToRemoteComponents(input)).toBe(expected);
+ });
+
+ it('should preserve onClick handler', () => {
+ const input = '';
+ const expected =
+ 'Click';
+ expect(transformJsxToRemoteComponents(input)).toBe(expected);
+ });
+
+ it('should preserve multiple attributes', () => {
+ const input =
+ '';
+ const expected =
+ '';
+ expect(transformJsxToRemoteComponents(input)).toBe(expected);
+ });
+ });
+
+ describe('custom components should not be transformed', () => {
+ it('should not transform PascalCase components', () => {
+ const input = 'Content';
+ expect(transformJsxToRemoteComponents(input)).toBe(input);
+ });
+
+ it('should not transform components starting with uppercase', () => {
+ const input = '';
+ expect(transformJsxToRemoteComponents(input)).toBe(input);
+ });
+
+ it('should transform HTML tags but not custom components in mixed content', () => {
+ const input = '
';
+ const expected =
+ '';
+ expect(transformJsxToRemoteComponents(input)).toBe(expected);
+ });
+ });
+
+ describe('fragments should not be transformed', () => {
+ it('should not transform empty fragments', () => {
+ const input = '<>>';
+ expect(transformJsxToRemoteComponents(input)).toBe(input);
+ });
+
+ it('should preserve fragments with content', () => {
+ const input = '<>Content
>';
+ const expected =
+ '<>Content>';
+ expect(transformJsxToRemoteComponents(input)).toBe(expected);
+ });
+ });
+
+ describe('all supported HTML elements', () => {
+ const testCases: [string, string][] = [
+ ['div', 'HtmlDiv'],
+ ['span', 'HtmlSpan'],
+ ['section', 'HtmlSection'],
+ ['article', 'HtmlArticle'],
+ ['header', 'HtmlHeader'],
+ ['footer', 'HtmlFooter'],
+ ['main', 'HtmlMain'],
+ ['nav', 'HtmlNav'],
+ ['aside', 'HtmlAside'],
+ ['p', 'HtmlP'],
+ ['h1', 'HtmlH1'],
+ ['h2', 'HtmlH2'],
+ ['h3', 'HtmlH3'],
+ ['h4', 'HtmlH4'],
+ ['h5', 'HtmlH5'],
+ ['h6', 'HtmlH6'],
+ ['strong', 'HtmlStrong'],
+ ['em', 'HtmlEm'],
+ ['small', 'HtmlSmall'],
+ ['code', 'HtmlCode'],
+ ['pre', 'HtmlPre'],
+ ['blockquote', 'HtmlBlockquote'],
+ ['a', 'HtmlA'],
+ ['img', 'HtmlImg'],
+ ['ul', 'HtmlUl'],
+ ['ol', 'HtmlOl'],
+ ['li', 'HtmlLi'],
+ ['form', 'HtmlForm'],
+ ['label', 'HtmlLabel'],
+ ['input', 'HtmlInput'],
+ ['textarea', 'HtmlTextarea'],
+ ['select', 'HtmlSelect'],
+ ['option', 'HtmlOption'],
+ ['button', 'HtmlButton'],
+ ['table', 'HtmlTable'],
+ ['thead', 'HtmlThead'],
+ ['tbody', 'HtmlTbody'],
+ ['tfoot', 'HtmlTfoot'],
+ ['tr', 'HtmlTr'],
+ ['th', 'HtmlTh'],
+ ['td', 'HtmlTd'],
+ ['br', 'HtmlBr'],
+ ['hr', 'HtmlHr'],
+ ];
+
+ it.each(testCases)(
+ 'should transform <%s> to RemoteComponents.%s',
+ (tag, component) => {
+ const input = `<${tag}>Content${tag}>`;
+ const expected = `Content`;
+ expect(transformJsxToRemoteComponents(input)).toBe(expected);
+ },
+ );
+ });
+
+ describe('edge cases', () => {
+ it('should handle multiline JSX', () => {
+ const input = `
+
+ Hello
+
+ `;
+ const result = transformJsxToRemoteComponents(input);
+ expect(result).toContain('');
+ expect(result).toContain('');
+ expect(result).toContain('');
+ expect(result).toContain('');
+ });
+
+ it('should handle JSX expressions', () => {
+ const input =
+ '{items.map(item => {item.name})}
';
+ const expected =
+ '{items.map(item => {item.name})}';
+ expect(transformJsxToRemoteComponents(input)).toBe(expected);
+ });
+ });
+});
diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/__tests__/react-globals-plugin.spec.ts b/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/__tests__/react-globals-plugin.spec.ts
new file mode 100644
index 0000000000..936d7ac5d9
--- /dev/null
+++ b/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/__tests__/react-globals-plugin.spec.ts
@@ -0,0 +1,205 @@
+import * as esbuild from 'esbuild';
+import * as fs from 'fs';
+import * as path from 'path';
+
+import { reactGlobalsPlugin } from '../react-globals-plugin';
+
+describe('reactGlobalsPlugin', () => {
+ const tempDir = path.join(__dirname, '.temp-test');
+ const tempFile = path.join(tempDir, 'test-component.tsx');
+
+ beforeAll(() => {
+ if (!fs.existsSync(tempDir)) {
+ fs.mkdirSync(tempDir, { recursive: true });
+ }
+ });
+
+ afterAll(() => {
+ if (fs.existsSync(tempDir)) {
+ fs.rmSync(tempDir, { recursive: true, force: true });
+ }
+ });
+
+ const buildWithPlugin = async (code: string): Promise => {
+ fs.writeFileSync(tempFile, code, 'utf-8');
+
+ const result = await esbuild.build({
+ entryPoints: [tempFile],
+ bundle: true,
+ write: false,
+ format: 'esm',
+ jsx: 'automatic',
+ plugins: [reactGlobalsPlugin],
+ });
+
+ return result.outputFiles[0].text;
+ };
+
+ describe('react/jsx-runtime imports', () => {
+ it('should replace jsx import with globalThis.jsx', async () => {
+ const code = `
+ import { jsx } from 'react/jsx-runtime';
+ export const Component = () => jsx('div', {});
+ `;
+
+ const result = await buildWithPlugin(code);
+
+ expect(result).toContain('globalThis.jsx');
+ expect(result).not.toContain('from "react/jsx-runtime"');
+ });
+
+ it('should replace jsxs import with globalThis.jsxs', async () => {
+ const code = `
+ import { jsxs } from 'react/jsx-runtime';
+ export const Component = () => jsxs('div', {});
+ `;
+
+ const result = await buildWithPlugin(code);
+
+ expect(result).toContain('globalThis.jsxs');
+ expect(result).not.toContain('from "react/jsx-runtime"');
+ });
+
+ it('should replace Fragment import with globalThis.React.Fragment', async () => {
+ const code = `
+ import { Fragment } from 'react/jsx-runtime';
+ export const Component = () => Fragment;
+ `;
+
+ const result = await buildWithPlugin(code);
+
+ expect(result).toContain('globalThis.React.Fragment');
+ expect(result).not.toContain('from "react/jsx-runtime"');
+ });
+ });
+
+ describe('react imports', () => {
+ it('should replace useState with globalThis.React.useState', async () => {
+ const code = `
+ import { useState } from 'react';
+ export const Component = () => {
+ const [state, setState] = useState(0);
+ return state;
+ };
+ `;
+
+ const result = await buildWithPlugin(code);
+
+ expect(result).toContain('globalThis.React.useState');
+ expect(result).not.toContain('from "react"');
+ });
+
+ it('should replace multiple hooks with globalThis.React equivalents', async () => {
+ const code = `
+ import { useState, useEffect, useCallback } from 'react';
+ export const Component = () => {
+ const [state, setState] = useState(0);
+ useEffect(() => {}, []);
+ const cb = useCallback(() => {}, []);
+ return state;
+ };
+ `;
+
+ const result = await buildWithPlugin(code);
+
+ expect(result).toContain('globalThis.React.useState');
+ expect(result).toContain('globalThis.React.useEffect');
+ expect(result).toContain('globalThis.React.useCallback');
+ expect(result).not.toContain('from "react"');
+ });
+
+ it('should replace default React import with globalThis.React', async () => {
+ const code = `
+ import React from 'react';
+ export const Component = () => React.createElement('div');
+ `;
+
+ const result = await buildWithPlugin(code);
+
+ expect(result).toContain('globalThis.React');
+ expect(result).not.toContain('from "react"');
+ });
+ });
+
+ describe('JSX transformation with plugin', () => {
+ it('should transform JSX using globalThis.jsx', async () => {
+ const code = `
+ export const Component = () => Hello
;
+ `;
+
+ const result = await buildWithPlugin(code);
+
+ expect(result).toContain('globalThis.jsx');
+ expect(result).not.toContain('from "react/jsx-runtime"');
+ });
+
+ it('should transform JSX with multiple children using globalThis.jsxs', async () => {
+ const code = `
+ export const Component = () => (
+
+ One
+ Two
+
+ );
+ `;
+
+ const result = await buildWithPlugin(code);
+
+ expect(result).toContain('globalThis.jsxs');
+ expect(result).not.toContain('from "react/jsx-runtime"');
+ });
+ });
+
+ describe('only include used React exports', () => {
+ it('should only include used React exports', async () => {
+ const code = `
+ import { useState, useEffect } from 'react';
+ export const Component = () => {
+ const [state, setState] = useState(0);
+ useEffect(() => {}, []);
+ return state;
+ };
+ `;
+
+ const result = await buildWithPlugin(code);
+
+ expect(result).toContain('globalThis.React.useState');
+ expect(result).toContain('globalThis.React.useEffect');
+ expect(result).not.toContain('globalThis.React.useCallback');
+ expect(result).not.toContain('globalThis.React.useMemo');
+ expect(result).not.toContain('globalThis.React.useRef');
+ expect(result).not.toContain('globalThis.React.useReducer');
+ });
+
+ it('should handle aliased imports', async () => {
+ const code = `
+ import { useState as useLocalState } from 'react';
+ export const Component = () => {
+ const [state] = useLocalState(0);
+ return state;
+ };
+ `;
+
+ const result = await buildWithPlugin(code);
+
+ expect(result).toContain('globalThis.React.useState');
+ expect(result).not.toContain('globalThis.React.useEffect');
+ });
+
+ it('should handle mixed default and named imports', async () => {
+ const code = `
+ import React, { useState } from 'react';
+ export const Component = () => {
+ const [state] = useState(0);
+ return React.createElement('div', null, state);
+ };
+ `;
+
+ const result = await buildWithPlugin(code);
+
+ expect(result).toContain('globalThis.React.useState');
+ expect(result).toContain('globalThis.React');
+ expect(result).not.toContain('globalThis.React.useEffect');
+ });
+ });
+});
diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/jsx-transform-to-remote-dom-worker-format-plugin.ts b/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/jsx-transform-to-remote-dom-worker-format-plugin.ts
new file mode 100644
index 0000000000..95abc42eee
--- /dev/null
+++ b/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/jsx-transform-to-remote-dom-worker-format-plugin.ts
@@ -0,0 +1,42 @@
+import type * as esbuild from 'esbuild';
+import * as fs from 'node:fs/promises';
+
+import { replaceHtmlTagsWithRemoteComponents } from './utils/replace-html-tags-with-remote-components';
+import { unwrapDefineFrontComponentToDirectExport } from './utils/unwrap-define-front-component-to-direct-export';
+
+export { replaceHtmlTagsWithRemoteComponents as transformJsxToRemoteComponents } from './utils/replace-html-tags-with-remote-components';
+
+export const jsxTransformToRemoteDomWorkerFormatPlugin: esbuild.Plugin = {
+ name: 'jsx-transform-to-remote-dom-worker-format-plugin',
+ setup: (esbuildBuild) => {
+ esbuildBuild.onLoad(
+ { filter: /\.tsx$/ },
+ async ({ path }): Promise => {
+ try {
+ const frontComponentSourceCode = await fs.readFile(path, 'utf8');
+
+ const sourceWithRemoteComponents =
+ replaceHtmlTagsWithRemoteComponents(frontComponentSourceCode);
+
+ const sourceWithUnwrappedFrontComponent =
+ unwrapDefineFrontComponentToDirectExport(
+ sourceWithRemoteComponents,
+ );
+
+ const transformedContents = `var RemoteComponents = globalThis.RemoteComponents;\n${sourceWithUnwrappedFrontComponent}`;
+
+ return { contents: transformedContents, loader: 'tsx' };
+ } catch (transformError) {
+ return {
+ errors: [
+ {
+ text: `Failed to transform front component: ${transformError instanceof Error ? transformError.message : String(transformError)}`,
+ location: { file: path },
+ },
+ ],
+ };
+ }
+ },
+ );
+ },
+};
diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/react-globals-plugin.ts b/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/react-globals-plugin.ts
new file mode 100644
index 0000000000..d33022dd51
--- /dev/null
+++ b/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/react-globals-plugin.ts
@@ -0,0 +1,124 @@
+import * as fs from 'fs/promises';
+
+import type * as esbuild from 'esbuild';
+
+import { isDefined } from 'twenty-shared/utils';
+import { extractNamesFromImportSpecifier } from './utils/extract-names-from-import-specifier';
+
+const REACT_IMPORT_PATTERN =
+ /import\s+(?:(\w+)\s*,?\s*)?(?:\{([^}]*)\})?\s*from\s*['"]react['"];?/g;
+
+const REACT_MODULE_FILTER_PATTERN = /^react(\/jsx-runtime)?$/;
+
+const JSX_RUNTIME_EXPORTS = `
+export var jsx = globalThis.jsx;
+export var jsxs = globalThis.jsxs;
+export var Fragment = globalThis.React.Fragment;
+`.trim();
+
+const collectReactImports = (sourceContent: string): Set => {
+ const collectedReactImports = new Set();
+
+ let importMatch;
+
+ while (isDefined((importMatch = REACT_IMPORT_PATTERN.exec(sourceContent)))) {
+ const defaultImportName = importMatch[1];
+ const namedImportsString = importMatch[2];
+
+ if (defaultImportName) {
+ collectedReactImports.add('default');
+ }
+
+ if (namedImportsString) {
+ namedImportsString
+ .split(',')
+ .filter((importSpecifier) => importSpecifier.trim())
+ .forEach((importSpecifier) => {
+ const { originalName } =
+ extractNamesFromImportSpecifier(importSpecifier);
+
+ collectedReactImports.add(originalName);
+ });
+ }
+ }
+
+ REACT_IMPORT_PATTERN.lastIndex = 0;
+
+ return collectedReactImports;
+};
+
+const generateReactExports = (reactImports: Set): string => {
+ const exportStatements: string[] = [];
+
+ for (const reactImportName of reactImports) {
+ if (reactImportName === 'default') {
+ exportStatements.push('export default globalThis.React;');
+ } else {
+ exportStatements.push(
+ `export var ${reactImportName} = globalThis.React.${reactImportName};`,
+ );
+ }
+ }
+
+ return exportStatements.join('\n');
+};
+
+export const reactGlobalsPlugin: esbuild.Plugin = {
+ name: 'react-globals',
+ setup: async (build) => {
+ const reactImportsByFilePath = new Map>();
+
+ build.onStart(() => {
+ reactImportsByFilePath.clear();
+ });
+
+ build.onResolve(
+ { filter: REACT_MODULE_FILTER_PATTERN },
+ async ({ importer, path }) => {
+ if (importer && !reactImportsByFilePath.has(importer)) {
+ try {
+ const sourceFileContent = await fs.readFile(importer, 'utf-8');
+
+ reactImportsByFilePath.set(
+ importer,
+ collectReactImports(sourceFileContent),
+ );
+ } catch {
+ reactImportsByFilePath.set(importer, new Set());
+ }
+ }
+
+ return {
+ path,
+ namespace: 'react-globals',
+ pluginData: { importer },
+ };
+ },
+ );
+
+ build.onLoad(
+ { filter: /.*/, namespace: 'react-globals' },
+ ({ pluginData, path }) => {
+ const importerFilePath = pluginData?.importer || '';
+ const collectedReactImports =
+ reactImportsByFilePath.get(importerFilePath) || new Set();
+
+ if (path === 'react/jsx-runtime') {
+ return {
+ contents: JSX_RUNTIME_EXPORTS,
+ loader: 'js',
+ };
+ }
+
+ if (path === 'react') {
+ return {
+ contents: generateReactExports(collectedReactImports),
+ loader: 'js',
+ };
+ }
+
+ return null;
+ },
+ );
+ },
+};
diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/types/ParsedImportSpecifier.ts b/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/types/ParsedImportSpecifier.ts
new file mode 100644
index 0000000000..19d8542fd3
--- /dev/null
+++ b/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/types/ParsedImportSpecifier.ts
@@ -0,0 +1,4 @@
+export type ParsedImportSpecifier = {
+ originalName: string;
+ aliasName: string;
+};
diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/utils/extract-names-from-import-specifier.ts b/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/utils/extract-names-from-import-specifier.ts
new file mode 100644
index 0000000000..385503fc51
--- /dev/null
+++ b/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/utils/extract-names-from-import-specifier.ts
@@ -0,0 +1,20 @@
+import { isDefined } from 'twenty-shared/utils';
+
+import { type ParsedImportSpecifier } from '../types/ParsedImportSpecifier';
+
+const ALIASED_IMPORT_PATTERN = /^(\w+)\s+as\s+(\w+)$/;
+
+export const extractNamesFromImportSpecifier = (
+ importSpecifier: string,
+): ParsedImportSpecifier => {
+ const trimmedSpecifier = importSpecifier.trim();
+ const aliasMatch = trimmedSpecifier.match(ALIASED_IMPORT_PATTERN);
+
+ if (isDefined(aliasMatch)) {
+ const [, originalName, aliasName] = aliasMatch;
+
+ return { originalName, aliasName };
+ }
+
+ return { originalName: trimmedSpecifier, aliasName: trimmedSpecifier };
+};
diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/utils/replace-html-tags-with-remote-components.ts b/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/utils/replace-html-tags-with-remote-components.ts
new file mode 100644
index 0000000000..08d2e28507
--- /dev/null
+++ b/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/utils/replace-html-tags-with-remote-components.ts
@@ -0,0 +1,39 @@
+import { HTML_TAG_TO_REMOTE_COMPONENT } from 'twenty-shared/front-component-constants';
+
+import { isDefined } from 'twenty-shared/utils';
+
+const REMOTE_COMPONENTS_GLOBAL_NAMESPACE = 'RemoteComponents';
+
+const buildHtmlTagToRemoteComponentPattern = (): RegExp => {
+ const supportedHtmlTagNames = Object.keys(HTML_TAG_TO_REMOTE_COMPONENT).join(
+ '|',
+ );
+
+ return new RegExp(
+ `(<\\/?)\\b(${supportedHtmlTagNames})\\b(?=[\\s>\\/>])`,
+ 'g',
+ );
+};
+
+const HTML_TAG_TO_REMOTE_COMPONENT_PATTERN =
+ buildHtmlTagToRemoteComponentPattern();
+
+export const replaceHtmlTagsWithRemoteComponents = (
+ sourceCode: string,
+): string => {
+ return sourceCode.replace(
+ HTML_TAG_TO_REMOTE_COMPONENT_PATTERN,
+ (fullMatch, tagPrefix: string, htmlTagName: string) => {
+ const remoteComponentName =
+ HTML_TAG_TO_REMOTE_COMPONENT[
+ htmlTagName as keyof typeof HTML_TAG_TO_REMOTE_COMPONENT
+ ];
+
+ if (isDefined(remoteComponentName)) {
+ return `${tagPrefix}${REMOTE_COMPONENTS_GLOBAL_NAMESPACE}.${remoteComponentName}`;
+ }
+
+ return fullMatch;
+ },
+ );
+};
diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/utils/unwrap-define-front-component-to-direct-export.ts b/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/utils/unwrap-define-front-component-to-direct-export.ts
new file mode 100644
index 0000000000..95abf55323
--- /dev/null
+++ b/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/utils/unwrap-define-front-component-to-direct-export.ts
@@ -0,0 +1,38 @@
+const DEFINE_FRONT_COMPONENT_IMPORT_PATTERN =
+ /import\s*\{\s*defineFrontComponent\s*\}\s*from\s*['"][^'"]+['"];?\n?/g;
+
+const DEFINE_FRONT_COMPONENT_EXPORT_PATTERN =
+ /export\s+default\s+defineFrontComponent\s*\(\s*\{[^}]*component\s*:\s*(\w+)[^}]*\}\s*\)\s*;?/s;
+
+export const unwrapDefineFrontComponentToDirectExport = (
+ sourceCode: string,
+): string => {
+ let transformedSource = sourceCode.replace(
+ DEFINE_FRONT_COMPONENT_IMPORT_PATTERN,
+ '',
+ );
+
+ const defineFrontComponentMatch = transformedSource.match(
+ DEFINE_FRONT_COMPONENT_EXPORT_PATTERN,
+ );
+
+ if (defineFrontComponentMatch) {
+ const wrappedComponentName = defineFrontComponentMatch[1];
+
+ const exportedComponentDeclarationPattern = new RegExp(
+ `export\\s+(const|function)\\s+${wrappedComponentName}\\b`,
+ );
+
+ transformedSource = transformedSource.replace(
+ exportedComponentDeclarationPattern,
+ `$1 ${wrappedComponentName}`,
+ );
+
+ transformedSource = transformedSource.replace(
+ DEFINE_FRONT_COMPONENT_EXPORT_PATTERN,
+ `export default globalThis.jsx(${wrappedComponentName}, {});`,
+ );
+ }
+
+ return transformedSource;
+};
diff --git a/packages/twenty-sdk/vite.config.ts b/packages/twenty-sdk/vite.config.ts
index b853591dca..33cdcb5a3f 100644
--- a/packages/twenty-sdk/vite.config.ts
+++ b/packages/twenty-sdk/vite.config.ts
@@ -89,12 +89,14 @@ export default defineConfig(() => {
...Object.keys((packageJson as any).dependencies || {}),
'path',
'fs',
+ 'fs/promises',
'url',
'crypto',
'stream',
'util',
'os',
'module',
+ /^node:/,
],
output: [
{
diff --git a/packages/twenty-shared/package.json b/packages/twenty-shared/package.json
index 2b622d94bd..20d640c715 100644
--- a/packages/twenty-shared/package.json
+++ b/packages/twenty-shared/package.json
@@ -75,6 +75,11 @@
"import": "./dist/front-component.mjs",
"require": "./dist/front-component.cjs"
},
+ "./front-component-constants": {
+ "types": "./dist/front-component-constants/index.d.ts",
+ "import": "./dist/front-component-constants.mjs",
+ "require": "./dist/front-component-constants.cjs"
+ },
"./metadata": {
"types": "./dist/metadata/index.d.ts",
"import": "./dist/metadata.mjs",
@@ -118,6 +123,7 @@
"constants",
"database-events",
"front-component",
+ "front-component-constants",
"metadata",
"testing",
"translations",
@@ -143,6 +149,9 @@
"front-component": [
"dist/front-component/index.d.ts"
],
+ "front-component-constants": [
+ "dist/front-component-constants/index.d.ts"
+ ],
"metadata": [
"dist/metadata/index.d.ts"
],
diff --git a/packages/twenty-shared/project.json b/packages/twenty-shared/project.json
index e91edfaf93..11ba3245b0 100644
--- a/packages/twenty-shared/project.json
+++ b/packages/twenty-shared/project.json
@@ -24,6 +24,8 @@
"{projectRoot}/database-events/dist",
"{projectRoot}/front-component/package.json",
"{projectRoot}/front-component/dist",
+ "{projectRoot}/front-component-constants/package.json",
+ "{projectRoot}/front-component-constants/dist",
"{projectRoot}/metadata/package.json",
"{projectRoot}/metadata/dist",
"{projectRoot}/testing/package.json",
diff --git a/packages/twenty-shared/scripts/generateBarrels.ts b/packages/twenty-shared/scripts/generateBarrels.ts
index 70d3d0a153..28aac3dc55 100644
--- a/packages/twenty-shared/scripts/generateBarrels.ts
+++ b/packages/twenty-shared/scripts/generateBarrels.ts
@@ -4,7 +4,7 @@ import * as fs from 'fs';
import { globSync } from 'glob';
// @ts-ignore
import path from 'path';
-import { Options } from 'prettier';
+import { type Options } from 'prettier';
import slash from 'slash';
// @ts-ignore
import ts from 'typescript';
@@ -105,15 +105,7 @@ const partitionFileExportsByType = (declarations: DeclarationOccurrence[]) => {
const generateModuleIndexFiles = (exportByBarrel: ExportByBarrel[]) => {
return exportByBarrel.map(
- ({ barrel: { moduleDirectory, moduleName }, allFileExports }) => {
- if (moduleName === 'front-component') {
- return {
- content: `export { FrontComponentRenderer } from './host/components/FrontComponentRenderer'`,
- path: moduleDirectory,
- filename: INDEX_FILENAME,
- };
- }
-
+ ({ barrel: { moduleDirectory }, allFileExports }) => {
const content = allFileExports
.sort((a, b) => a.file.localeCompare(b.file))
.map(({ exports, file }) => {
diff --git a/packages/twenty-shared/scripts/remote-dom/generateRemoteDomElements.ts b/packages/twenty-shared/scripts/remote-dom/generateRemoteDomElements.ts
index 9fd560d37a..823aac42ff 100644
--- a/packages/twenty-shared/scripts/remote-dom/generateRemoteDomElements.ts
+++ b/packages/twenty-shared/scripts/remote-dom/generateRemoteDomElements.ts
@@ -4,10 +4,10 @@ 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 { 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';
+import { ALLOWED_HTML_ELEMENTS } from '../../src/front-component-constants/AllowedHtmlElements';
+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';
import {
type ComponentSchema,
diff --git a/packages/twenty-shared/src/front-component/constants/AllowedHtmlElements.ts b/packages/twenty-shared/src/front-component-constants/AllowedHtmlElements.ts
similarity index 97%
rename from packages/twenty-shared/src/front-component/constants/AllowedHtmlElements.ts
rename to packages/twenty-shared/src/front-component-constants/AllowedHtmlElements.ts
index 504f9e8f8a..46cb05e6f0 100644
--- a/packages/twenty-shared/src/front-component/constants/AllowedHtmlElements.ts
+++ b/packages/twenty-shared/src/front-component-constants/AllowedHtmlElements.ts
@@ -3,11 +3,13 @@ type PropertySchema = {
optional: boolean;
};
-export const ALLOWED_HTML_ELEMENTS: Array<{
+export type AllowedHtmlElement = {
tag: string;
name: string;
properties: Record;
-}> = [
+};
+
+export const ALLOWED_HTML_ELEMENTS: AllowedHtmlElement[] = [
{ tag: 'html-div', name: 'HtmlDiv', properties: {} },
{ tag: 'html-span', name: 'HtmlSpan', properties: {} },
{ tag: 'html-section', name: 'HtmlSection', properties: {} },
@@ -143,4 +145,4 @@ export const ALLOWED_HTML_ELEMENTS: Array<{
},
{ tag: 'html-br', name: 'HtmlBr', properties: {} },
{ tag: 'html-hr', name: 'HtmlHr', properties: {} },
-] as const;
+];
diff --git a/packages/twenty-shared/src/front-component/constants/CommonHtmlEvents.ts b/packages/twenty-shared/src/front-component-constants/CommonHtmlEvents.ts
similarity index 100%
rename from packages/twenty-shared/src/front-component/constants/CommonHtmlEvents.ts
rename to packages/twenty-shared/src/front-component-constants/CommonHtmlEvents.ts
diff --git a/packages/twenty-shared/src/front-component/constants/EventToReact.ts b/packages/twenty-shared/src/front-component-constants/EventToReact.ts
similarity index 100%
rename from packages/twenty-shared/src/front-component/constants/EventToReact.ts
rename to packages/twenty-shared/src/front-component-constants/EventToReact.ts
diff --git a/packages/twenty-shared/src/front-component/constants/HtmlCommonProperties.ts b/packages/twenty-shared/src/front-component-constants/HtmlCommonProperties.ts
similarity index 86%
rename from packages/twenty-shared/src/front-component/constants/HtmlCommonProperties.ts
rename to packages/twenty-shared/src/front-component-constants/HtmlCommonProperties.ts
index 8dd3d29ad3..29c0e25d70 100644
--- a/packages/twenty-shared/src/front-component/constants/HtmlCommonProperties.ts
+++ b/packages/twenty-shared/src/front-component-constants/HtmlCommonProperties.ts
@@ -1,4 +1,4 @@
-import { type PropertySchema } from '../types/PropertySchema';
+import { type PropertySchema } from '../front-component/types/PropertySchema';
export const HTML_COMMON_PROPERTIES: Record = {
id: { type: 'string', optional: true },
diff --git a/packages/twenty-shared/src/front-component-constants/HtmlTagToRemoteComponent.ts b/packages/twenty-shared/src/front-component-constants/HtmlTagToRemoteComponent.ts
new file mode 100644
index 0000000000..e33dba8048
--- /dev/null
+++ b/packages/twenty-shared/src/front-component-constants/HtmlTagToRemoteComponent.ts
@@ -0,0 +1,9 @@
+import { ALLOWED_HTML_ELEMENTS } from './AllowedHtmlElements';
+
+export const HTML_TAG_TO_REMOTE_COMPONENT: Record =
+ Object.fromEntries(
+ ALLOWED_HTML_ELEMENTS.map((element) => [
+ element.tag.startsWith('html-') ? element.tag.slice(5) : element.tag,
+ element.name,
+ ]),
+ );
diff --git a/packages/twenty-shared/src/front-component-constants/index.ts b/packages/twenty-shared/src/front-component-constants/index.ts
new file mode 100644
index 0000000000..9f5919390c
--- /dev/null
+++ b/packages/twenty-shared/src/front-component-constants/index.ts
@@ -0,0 +1,15 @@
+/*
+ * _____ _
+ *|_ _|_ _____ _ __ | |_ _ _
+ * | | \ \ /\ / / _ \ '_ \| __| | | | Auto-generated file
+ * | | \ V V / __/ | | | |_| |_| | Any edits to this will be overridden
+ * |_| \_/\_/ \___|_| |_|\__|\__, |
+ * |___/
+ */
+
+export type { AllowedHtmlElement } from './AllowedHtmlElements';
+export { ALLOWED_HTML_ELEMENTS } from './AllowedHtmlElements';
+export { COMMON_HTML_EVENTS } from './CommonHtmlEvents';
+export { EVENT_TO_REACT } from './EventToReact';
+export { HTML_COMMON_PROPERTIES } from './HtmlCommonProperties';
+export { HTML_TAG_TO_REMOTE_COMPONENT } from './HtmlTagToRemoteComponent';
diff --git a/packages/twenty-shared/src/front-component/index.ts b/packages/twenty-shared/src/front-component/index.ts
index dcbc61a205..21d35f5940 100644
--- a/packages/twenty-shared/src/front-component/index.ts
+++ b/packages/twenty-shared/src/front-component/index.ts
@@ -8,3 +8,116 @@
*/
export { FrontComponentRenderer } from './host/components/FrontComponentRenderer';
+export { componentRegistry } from './host/generated/host-component-registry';
+export { FrontComponentWorkerEffect } from './remote/components/FrontComponentWorkerEffect';
+export {
+ HtmlDiv,
+ HtmlSpan,
+ HtmlSection,
+ HtmlArticle,
+ HtmlHeader,
+ HtmlFooter,
+ HtmlMain,
+ HtmlNav,
+ HtmlAside,
+ HtmlP,
+ HtmlH1,
+ HtmlH2,
+ HtmlH3,
+ HtmlH4,
+ HtmlH5,
+ HtmlH6,
+ HtmlStrong,
+ HtmlEm,
+ HtmlSmall,
+ HtmlCode,
+ HtmlPre,
+ HtmlBlockquote,
+ HtmlA,
+ HtmlImg,
+ HtmlUl,
+ HtmlOl,
+ HtmlLi,
+ HtmlForm,
+ HtmlLabel,
+ HtmlInput,
+ HtmlTextarea,
+ HtmlSelect,
+ HtmlOption,
+ HtmlButton,
+ HtmlTable,
+ HtmlThead,
+ HtmlTbody,
+ HtmlTfoot,
+ HtmlTr,
+ HtmlTh,
+ HtmlTd,
+ HtmlBr,
+ HtmlHr,
+} from './remote/generated/remote-components';
+export type {
+ HtmlCommonProperties,
+ HtmlCommonEvents,
+ HtmlAProperties,
+ HtmlImgProperties,
+ HtmlFormProperties,
+ HtmlLabelProperties,
+ HtmlInputProperties,
+ HtmlTextareaProperties,
+ HtmlSelectProperties,
+ HtmlOptionProperties,
+ HtmlButtonProperties,
+ HtmlThProperties,
+ HtmlTdProperties,
+} from './remote/generated/remote-elements';
+export {
+ HtmlDivElement,
+ HtmlSpanElement,
+ HtmlSectionElement,
+ HtmlArticleElement,
+ HtmlHeaderElement,
+ HtmlFooterElement,
+ HtmlMainElement,
+ HtmlNavElement,
+ HtmlAsideElement,
+ HtmlPElement,
+ HtmlH1Element,
+ HtmlH2Element,
+ HtmlH3Element,
+ HtmlH4Element,
+ HtmlH5Element,
+ HtmlH6Element,
+ HtmlStrongElement,
+ HtmlEmElement,
+ HtmlSmallElement,
+ HtmlCodeElement,
+ HtmlPreElement,
+ HtmlBlockquoteElement,
+ HtmlAElement,
+ HtmlImgElement,
+ HtmlUlElement,
+ HtmlOlElement,
+ HtmlLiElement,
+ HtmlFormElement,
+ HtmlLabelElement,
+ HtmlInputElement,
+ HtmlTextareaElement,
+ HtmlSelectElement,
+ HtmlOptionElement,
+ HtmlButtonElement,
+ HtmlTableElement,
+ HtmlTheadElement,
+ HtmlTbodyElement,
+ HtmlTfootElement,
+ HtmlTrElement,
+ HtmlThElement,
+ HtmlTdElement,
+ HtmlBrElement,
+ HtmlHrElement,
+ RemoteRootElement,
+ RemoteFragmentElement,
+} from './remote/generated/remote-elements';
+export { createRemoteWorker } from './remote/worker/createRemoteWorker';
+export type { HostToWorkerRenderContext } from './types/HostToWorkerRenderContext';
+export type { PropertySchema } from './types/PropertySchema';
+export type { WorkerExports } from './types/WorkerExports';
diff --git a/packages/twenty-shared/src/front-component/remote/worker/remote-worker.ts b/packages/twenty-shared/src/front-component/remote/worker/remote-worker.ts
index d3d17e8cc2..c5b2a945a8 100644
--- a/packages/twenty-shared/src/front-component/remote/worker/remote-worker.ts
+++ b/packages/twenty-shared/src/front-component/remote/worker/remote-worker.ts
@@ -11,12 +11,15 @@ import {
} from '@remote-dom/core/elements';
import React from 'react';
import { createRoot } from 'react-dom/client';
+import { jsx, jsxs } from 'react/jsx-runtime';
import { type HostToWorkerRenderContext } from '../../types/HostToWorkerRenderContext';
import { type WorkerExports } from '../../types/WorkerExports';
import * as RemoteComponents from '../generated/remote-components';
(globalThis as Record).React = React;
(globalThis as Record).RemoteComponents = RemoteComponents;
+(globalThis as Record).jsx = jsx;
+(globalThis as Record).jsxs = jsxs;
const render: WorkerExports['render'] = async (
connection: RemoteConnection,
diff --git a/packages/twenty-shared/src/front-component/types/WorkerToHostBridge.ts b/packages/twenty-shared/src/front-component/types/WorkerToHostBridge.ts
deleted file mode 100644
index 102a72711c..0000000000
--- a/packages/twenty-shared/src/front-component/types/WorkerToHostBridge.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-import { type RemoteConnection } from '@remote-dom/core/elements';
-import { type HostToWorkerRenderContext } from './HostToWorkerRenderContext';
-
-export type WorkerExports = {
- render: (
- connection: RemoteConnection,
- context: HostToWorkerRenderContext,
- ) => Promise;
-};