[FRONT COMPONENTS] Navigate from the remote (#17762)

## PR Description

This PR:
- Introduces a `FrontComponentHostCommunicationApi`, which allows us to
pass functions to be executed from the worker
- Exposes a navigate function from the host to the front component
remote workers, enabling SDK components to trigger in-app navigation
- Gets rid of `useSyncExternalStore` and makes the execution context
reactive without it
- Refactors and improves the `esbuild` plugin system

## Video


https://github.com/user-attachments/assets/7b26a1c2-f85f-4898-a71d-f60c70e61711
This commit is contained in:
Raphaël Bosi
2026-02-09 14:38:35 +01:00
committed by GitHub
parent 2106f46e9e
commit 2b29918bf8
39 changed files with 689 additions and 293 deletions
@@ -1,5 +1,5 @@
import { useNavigate } from 'react-router-dom';
import { type AppPath } from 'twenty-shared/types';
import { type AppPath, type NavigateOptions } from 'twenty-shared/types';
import { getAppPath } from 'twenty-shared/utils';
export const useNavigateApp = () => {
@@ -9,10 +9,7 @@ export const useNavigateApp = () => {
to: T,
params?: Parameters<typeof getAppPath<T>>[1],
queryParams?: Record<string, any>,
options?: {
replace?: boolean;
state?: any;
},
options?: NavigateOptions,
) => {
const path = getAppPath(to, params, queryParams);
return navigate(path, options);
@@ -1,13 +1,13 @@
import { currentUserState } from '@/auth/states/currentUserState';
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 { useRecoilValue } from 'recoil';
import { FrontComponentRenderer as SharedFrontComponentRenderer } from 'twenty-sdk/front-component';
import { isDefined } from 'twenty-shared/utils';
import { useFrontComponentExecutionContext } from '@/front-components/hooks/useFrontComponentExecutionContext';
type FrontComponentRendererProps = {
frontComponentId: string;
};
@@ -19,7 +19,8 @@ export const FrontComponentRenderer = ({
const [hasError, setHasError] = useState(false);
const { enqueueErrorSnackBar } = useSnackBar();
const currentUser = useRecoilValue(currentUserState);
const { executionContext, frontComponentHostCommunicationApi } =
useFrontComponentExecutionContext();
const handleError = (error?: Error) => {
if (isDefined(error)) {
@@ -40,9 +41,8 @@ export const FrontComponentRenderer = ({
<SharedFrontComponentRenderer
theme={theme}
componentUrl={getMockFrontComponentUrl()}
executionContext={{
userId: currentUser?.id,
}}
executionContext={executionContext}
frontComponentHostCommunicationApi={frontComponentHostCommunicationApi}
onError={handleError}
/>
);
@@ -0,0 +1,45 @@
import { useRecoilValue } from 'recoil';
import {
type FrontComponentExecutionContext,
type FrontComponentHostCommunicationApi,
} from 'twenty-sdk/front-component';
import { type AppPath } from 'twenty-shared/types';
import { currentUserState } from '@/auth/states/currentUserState';
import { useNavigateApp } from '~/hooks/useNavigateApp';
export const useFrontComponentExecutionContext = (): {
executionContext: FrontComponentExecutionContext;
frontComponentHostCommunicationApi: FrontComponentHostCommunicationApi;
} => {
const currentUser = useRecoilValue(currentUserState);
const navigateApp = useNavigateApp();
const navigate: FrontComponentHostCommunicationApi['navigate'] = async (
to,
params,
queryParams,
options,
) => {
navigateApp(
to as AppPath,
params as Parameters<typeof navigateApp>[1],
queryParams,
options,
);
};
const executionContext: FrontComponentExecutionContext = {
userId: currentUser?.id ?? null,
};
const frontComponentHostCommunicationApi: FrontComponentHostCommunicationApi =
{
navigate,
};
return {
executionContext,
frontComponentHostCommunicationApi,
};
};
@@ -1,107 +1,29 @@
/* eslint-disable */
import { isDefined } from 'twenty-shared/utils';
const mockFrontComponentCode = `
// react-globals:react
var useState = globalThis.React.useState;
var useEffect = globalThis.React.useEffect;
var useSyncExternalStore = globalThis.React.useSyncExternalStore;
var navigate = /* @__PURE__ */ (() => globalThis.TwentySdk.navigate)();
// react-globals:react/jsx-runtime
var jsx = globalThis.jsx;
var jsxs = globalThis.jsxs;
var Fragment = globalThis.React.Fragment;
var AppPath = /* @__PURE__ */ (() => globalThis.TwentyShared["types"].AppPath)();
var Button = /* @__PURE__ */ (() => globalThis.RemoteComponents.TwentyUiButton)();
var jsx = /* @__PURE__ */ (() => globalThis.jsx)();
// src/tata/test-component.front-component.tsx
var RemoteComponents = globalThis.RemoteComponents;
var getStore = () => {
const store = globalThis.frontComponentExecutionContextStore;
if (store === void 0) {
throw new Error(
"frontComponentExecutionContextStore not found on globalThis. This hook must be used within a front component running in the worker."
);
}
return store;
};
var useFrontComponentExecutionContext = () => {
const store = getStore();
return useSyncExternalStore(store.subscribe, store.getSnapshot);
};
var TestComponent = () => {
const [count, setCount] = useState(0);
const [timer, setTimer] = useState(0);
const context = useFrontComponentExecutionContext();
useEffect(() => {
const interval = setInterval(() => {
setTimer((prevTimer) => prevTimer + 1);
}, 1e3);
return () => clearInterval(interval);
}, []);
return /* @__PURE__ */ jsxs(
RemoteComponents.HtmlDiv,
return /* @__PURE__ */ jsx(
Button,
{
style: {
padding: "20px",
fontFamily: "Arial, sans-serif",
maxWidth: "400px",
margin: "0 auto",
display: "flex",
flexDirection: "column",
alignItems: "center"
},
children: [
/* @__PURE__ */ jsx(RemoteComponents.HtmlH1, { style: { color: "#333", marginBottom: "20px", fontSize: "24px" }, children: "Test Component" }),
/* @__PURE__ */ jsxs(RemoteComponents.HtmlP, { style: { fontSize: "18px", marginBottom: "10px", color: "#666" }, children: [
"Count: ",
count
] }),
/* @__PURE__ */ jsxs(RemoteComponents.HtmlP, { style: { fontSize: "18px", marginBottom: "20px", color: "#666" }, children: [
"Timer: ",
timer,
"s"
] }),
/* @__PURE__ */ jsxs(RemoteComponents.HtmlP, { style: { fontSize: "18px", marginBottom: "20px", color: "#666" }, children: [
"User ID: ",
context?.userId
] }),
/* @__PURE__ */ jsx(
RemoteComponents.HtmlButton,
{
onClick: () => setCount(count + 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: (e) => {
e.currentTarget.style.backgroundColor = "#0056b3";
e.currentTarget.style.transform = "translateY(-2px)";
e.currentTarget.style.boxShadow = "0 4px 8px rgba(0, 0, 0, 0.3)";
},
onMouseLeave: (e) => {
e.currentTarget.style.backgroundColor = "#007bff";
e.currentTarget.style.transform = "translateY(0)";
e.currentTarget.style.boxShadow = "0 2px 4px rgba(0, 0, 0, 0.2)";
},
children: "Increment"
}
)
]
title: "Navigate to people index page",
onClick: () => navigate?.(AppPath.RecordIndexPage, {
objectNamePlural: "people"
})
}
);
};
var test_component_front_component_default = globalThis.jsx(TestComponent, {});
export {
test_component_front_component_default as default,
useFrontComponentExecutionContext
test_component_front_component_default as default
};
//# sourceMappingURL=test-component.front-component.mjs.map
`;
let cachedMockBlobUrl: string | null = null;
+7
View File
@@ -31,6 +31,13 @@ const config: StorybookConfig = {
'@': path.resolve(dirname, '../src'),
},
},
optimizeDeps: {
...viteConfig.optimizeDeps,
include: [
...(viteConfig.optimizeDeps?.include ?? []),
'transliteration',
],
},
};
},
};
+28
View File
@@ -1,3 +1,6 @@
import reactPlugin from 'eslint-plugin-react';
import reactHooksPlugin from 'eslint-plugin-react-hooks';
import reactRefreshPlugin from 'eslint-plugin-react-refresh';
import baseConfig from '../../eslint.config.mjs';
export default [
@@ -7,8 +10,33 @@ export default [
},
{
files: ['**/*.{js,jsx,ts,tsx}'],
plugins: {
'react': reactPlugin,
'react-hooks': reactHooksPlugin,
'react-refresh': reactRefreshPlugin,
},
settings: {
react: {
version: 'detect',
},
},
rules: {
'prettier/prettier': 'error',
'react/no-unescaped-entities': 'off',
'react/prop-types': 'off',
'react/jsx-key': 'off',
'react/display-name': 'off',
'react/jsx-uses-react': 'off',
'react/react-in-jsx-scope': 'off',
'react/jsx-no-useless-fragment': 'off',
'react/jsx-props-no-spreading': [
'error',
{
explicitSpread: 'ignore',
},
],
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn',
},
},
{
@@ -1,7 +1 @@
export const FRONT_COMPONENT_EXTERNAL_MODULES: string[] = [
'react-dom',
'twenty-sdk',
'twenty-sdk/*',
'twenty-shared',
'twenty-shared/*',
];
export const FRONT_COMPONENT_EXTERNAL_MODULES: string[] = ['react-dom'];
@@ -18,12 +18,17 @@ export const jsxTransformToRemoteDomWorkerFormatPlugin: esbuild.Plugin = {
const sourceWithRemoteComponents =
replaceHtmlTagsWithRemoteComponents(frontComponentSourceCode);
const hasRemoteComponentReplacements =
sourceWithRemoteComponents !== frontComponentSourceCode;
const sourceWithUnwrappedFrontComponent =
unwrapDefineFrontComponentToDirectExport(
sourceWithRemoteComponents,
);
const transformedContents = `var RemoteComponents = globalThis.RemoteComponents;\n${sourceWithUnwrappedFrontComponent}`;
const transformedContents = hasRemoteComponentReplacements
? `var RemoteComponents = globalThis.RemoteComponents;\n${sourceWithUnwrappedFrontComponent}`
: sourceWithUnwrappedFrontComponent;
return { contents: transformedContents, loader: 'tsx' };
} catch (transformError) {
@@ -1,128 +1,76 @@
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';
import { collectNamedImports } from './utils/collect-named-imports';
import { createGlobalsPlugin } from './utils/create-globals-plugin';
const REACT_IMPORT_PATTERN =
/import\s+(?:(\w+)\s*,?\s*)?(?:\{([^}]*)\})?\s*from\s*['"]react['"];?/g;
/import\s+(?:(?<defaultImport>\w+)\s*,?\s*)?(?:\{(?<namedImports>[^}]*)\})?\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;
export var jsx = /* @__PURE__ */ (() => globalThis.jsx)();
export var jsxs = /* @__PURE__ */ (() => globalThis.jsxs)();
export var Fragment = /* @__PURE__ */ (() => globalThis.React.Fragment)();
`.trim();
const collectReactImports = (sourceContent: string): Set<string> => {
const collectedReactImports = new Set<string>();
const collectReactImports = (
sourceContent: string,
): Map<string, Set<string>> => {
const namedImports = collectNamedImports({
sourceContent,
pattern: REACT_IMPORT_PATTERN,
});
let importMatch;
while (isDefined((importMatch = REACT_IMPORT_PATTERN.exec(sourceContent)))) {
const defaultImportName = importMatch[1];
const namedImportsString = importMatch[2];
const defaultImportName = importMatch.groups?.defaultImport;
if (defaultImportName) {
collectedReactImports.add('default');
}
if (!namedImports.has('')) {
namedImports.set('', new Set());
}
if (namedImportsString) {
namedImportsString
.split(',')
.filter((importSpecifier) => importSpecifier.trim())
.forEach((importSpecifier) => {
const { originalName } =
extractNamesFromImportSpecifier(importSpecifier);
collectedReactImports.add(originalName);
});
namedImports.get('')!.add('default');
}
}
REACT_IMPORT_PATTERN.lastIndex = 0;
return collectedReactImports;
return namedImports;
};
const generateReactExports = (reactImports: Set<string>): string => {
const exportStatements: string[] = [];
const generateReactExports = ({
namedImports,
}: {
namedImports: Set<string>;
}): string => {
const exportLines: string[] = [];
for (const reactImportName of reactImports) {
for (const reactImportName of namedImports) {
if (reactImportName === 'default') {
exportStatements.push('export default globalThis.React;');
exportLines.push(
'export default /* @__PURE__ */ (() => globalThis.React)();',
);
} else {
exportStatements.push(
`export var ${reactImportName} = globalThis.React.${reactImportName};`,
exportLines.push(
`export var ${reactImportName} = /* @__PURE__ */ (() => globalThis.React.${reactImportName})();`,
);
}
}
return exportStatements.join('\n');
return exportLines.join('\n');
};
export const reactGlobalsPlugin: esbuild.Plugin = {
name: 'react-globals',
setup: async (build) => {
const reactImportsByFilePath = new Map<string, Set<string>>();
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<string>());
}
}
return {
path:
path === 'react' && importer
? `react?importer=${encodeURIComponent(importer)}`
: path,
namespace: 'react-globals',
pluginData: { importer },
};
},
);
build.onLoad(
{ filter: /.*/, namespace: 'react-globals' },
({ path, pluginData }) => {
if (path === 'react/jsx-runtime') {
return {
contents: JSX_RUNTIME_EXPORTS,
loader: 'js',
};
}
if (path === 'react' || path.startsWith('react?importer=')) {
const importerFilePath =
pluginData?.importer ||
decodeURIComponent(path.split('react?importer=')[1] || '');
const collectedReactImports =
reactImportsByFilePath.get(importerFilePath) || new Set<string>();
return {
contents: generateReactExports(collectedReactImports),
loader: 'js',
};
}
return null;
},
);
export const reactGlobalsPlugin = createGlobalsPlugin({
pluginName: 'react-globals',
namespace: 'react-globals',
moduleName: 'react',
moduleFilter: REACT_MODULE_FILTER_PATTERN,
collectImports: collectReactImports,
generateExports: generateReactExports,
staticContents: {
'react/jsx-runtime': JSX_RUNTIME_EXPORTS,
},
};
});
@@ -0,0 +1,31 @@
import * as fs from 'fs/promises';
import path from 'path';
import type * as esbuild from 'esbuild';
const SINGLE_LINE_COMMENT_PATTERN = /^\/\/.*$\n/gm;
export const stripCommentsPlugin: esbuild.Plugin = {
name: 'strip-comments',
setup: (build) => {
build.onEnd(async (result) => {
if (result.errors.length > 0) {
return;
}
const outputFiles = Object.keys(result.metafile?.outputs ?? {}).filter(
(file) => file.endsWith('.mjs'),
);
for (const outputFile of outputFiles) {
const absolutePath = path.resolve(outputFile);
const content = await fs.readFile(absolutePath, 'utf-8');
const stripped = content.replace(SINGLE_LINE_COMMENT_PATTERN, '');
if (stripped !== content) {
await fs.writeFile(absolutePath, stripped, 'utf-8');
}
}
});
},
};
@@ -0,0 +1,26 @@
import { collectNamedImports } from './utils/collect-named-imports';
import { createGlobalsPlugin } from './utils/create-globals-plugin';
const TWENTY_SDK_IMPORT_PATTERN =
/import\s+(?:\{(?<namedImports>[^}]*)\})?\s*from\s*['"]twenty-sdk['"];?/g;
const TWENTY_SDK_MODULE_FILTER_PATTERN = /^twenty-sdk$/;
export const twentySdkGlobalsPlugin = createGlobalsPlugin({
pluginName: 'twenty-sdk-globals',
namespace: 'twenty-sdk-globals',
moduleName: 'twenty-sdk',
moduleFilter: TWENTY_SDK_MODULE_FILTER_PATTERN,
collectImports: (sourceContent) =>
collectNamedImports({
sourceContent,
pattern: TWENTY_SDK_IMPORT_PATTERN,
}),
generateExports: ({ namedImports }) =>
[...namedImports]
.map(
(importName) =>
`export var ${importName} = /* @__PURE__ */ (() => globalThis.TwentySdk.${importName})();`,
)
.join('\n'),
});
@@ -0,0 +1,26 @@
import { collectNamedImports } from './utils/collect-named-imports';
import { createGlobalsPlugin } from './utils/create-globals-plugin';
const TWENTY_SDK_UI_IMPORT_PATTERN =
/import\s+\{(?<namedImports>[^}]*)\}\s*from\s*['"]twenty-sdk\/ui['"];?/g;
const TWENTY_SDK_UI_MODULE_FILTER_PATTERN = /^twenty-sdk\/ui$/;
export const twentySdkUiGlobalsPlugin = createGlobalsPlugin({
pluginName: 'twenty-sdk-ui-globals',
namespace: 'twenty-sdk-ui-globals',
moduleName: 'twenty-sdk/ui',
moduleFilter: TWENTY_SDK_UI_MODULE_FILTER_PATTERN,
collectImports: (sourceContent) =>
collectNamedImports({
sourceContent,
pattern: TWENTY_SDK_UI_IMPORT_PATTERN,
}),
generateExports: ({ namedImports }) =>
[...namedImports]
.map(
(importName) =>
`export var ${importName} = /* @__PURE__ */ (() => globalThis.RemoteComponents.TwentyUi${importName})();`,
)
.join('\n'),
});
@@ -0,0 +1,38 @@
import { collectNamedImports } from './utils/collect-named-imports';
import { createGlobalsPlugin } from './utils/create-globals-plugin';
const TWENTY_SHARED_IMPORT_PATTERN =
/import\s+\{(?<namedImports>[^}]*)\}\s*from\s*['"]twenty-shared(?:\/(?<subPath>[^'"]*?))?['"];?/g;
const TWENTY_SHARED_MODULE_FILTER_PATTERN = /^twenty-shared(\/.*)?$/;
const buildGlobalAccessorExpression = (moduleSubPath: string): string => {
if (moduleSubPath === '') {
return 'globalThis.TwentyShared';
}
return `globalThis.TwentyShared['${moduleSubPath}']`;
};
export const twentySharedGlobalsPlugin = createGlobalsPlugin({
pluginName: 'twenty-shared-globals',
namespace: 'twenty-shared-globals',
moduleName: 'twenty-shared',
moduleFilter: TWENTY_SHARED_MODULE_FILTER_PATTERN,
collectImports: (sourceContent) =>
collectNamedImports({
sourceContent,
pattern: TWENTY_SHARED_IMPORT_PATTERN,
}),
generateExports: ({ namedImports, moduleSubPath }) => {
const globalAccessorExpression =
buildGlobalAccessorExpression(moduleSubPath);
return [...namedImports]
.map(
(importName) =>
`export var ${importName} = /* @__PURE__ */ (() => ${globalAccessorExpression}.${importName})();`,
)
.join('\n');
},
});
@@ -0,0 +1,45 @@
import { isDefined } from 'twenty-shared/utils';
import { extractNamesFromImportSpecifier } from './extract-names-from-import-specifier';
const parseImportSpecifiers = (namedImportsString: string): string[] => {
return namedImportsString
.split(',')
.map((specifier) => specifier.trim())
.filter((specifier) => specifier.length > 0)
.filter((specifier) => !specifier.startsWith('type '))
.map(
(specifier) => extractNamesFromImportSpecifier(specifier).originalName,
);
};
export const collectNamedImports = ({
sourceContent,
pattern,
}: {
sourceContent: string;
pattern: RegExp;
}): Map<string, Set<string>> => {
const collectedImports = new Map<string, Set<string>>();
let importMatch;
while (isDefined((importMatch = pattern.exec(sourceContent)))) {
const namedImportsString = importMatch.groups?.namedImports;
const subPath = importMatch.groups?.subPath ?? '';
if (!collectedImports.has(subPath)) {
collectedImports.set(subPath, new Set());
}
if (namedImportsString) {
parseImportSpecifiers(namedImportsString).forEach((name) =>
collectedImports.get(subPath)?.add(name),
);
}
}
pattern.lastIndex = 0;
return collectedImports;
};
@@ -3,7 +3,7 @@ import type * as esbuild from 'esbuild';
import { FRONT_COMPONENT_EXTERNAL_MODULES } from '../constants/front-component-external-modules';
import { getFrontComponentBuildPlugins } from './get-front-component-build-plugins';
type FrontComponentBuildOptions = {
export type FrontComponentBuildOptions = {
entryPoints: esbuild.BuildOptions['entryPoints'];
outdir: string;
tsconfigPath?: string;
@@ -0,0 +1,96 @@
import * as fs from 'fs/promises';
import type * as esbuild from 'esbuild';
type GlobalsPluginData = {
importerFilePath: string;
originalPath: string;
};
type GlobalsPluginConfig = {
pluginName: string;
moduleName: string;
moduleFilter: RegExp;
collectImports: (sourceContent: string) => Map<string, Set<string>>;
generateExports: (params: {
namedImports: Set<string>;
moduleSubPath: string;
}) => string;
namespace?: string;
staticContents?: Record<string, string>;
};
export const createGlobalsPlugin = (
config: GlobalsPluginConfig,
): esbuild.Plugin => {
const namespace = config.namespace ?? config.pluginName;
return {
name: config.pluginName,
setup: (build) => {
const importsByFilePath = new Map<string, Map<string, Set<string>>>();
build.onStart(() => {
importsByFilePath.clear();
});
build.onResolve(
{ filter: config.moduleFilter },
async ({ importer, path }) => {
if (importer && !importsByFilePath.has(importer)) {
try {
const sourceFileContent = await fs.readFile(importer, 'utf-8');
importsByFilePath.set(
importer,
config.collectImports(sourceFileContent),
);
} catch {
importsByFilePath.set(importer, new Map());
}
}
return {
path: importer
? `${path}?importer=${encodeURIComponent(importer)}`
: path,
namespace,
pluginData: {
importerFilePath: importer,
originalPath: path,
} satisfies GlobalsPluginData,
};
},
);
build.onLoad({ filter: /.*/, namespace }, ({ pluginData }) => {
const { originalPath, importerFilePath } =
pluginData as GlobalsPluginData;
if (config.staticContents?.[originalPath]) {
return {
contents: config.staticContents[originalPath],
loader: 'js' as const,
};
}
const moduleSubPath =
originalPath === config.moduleName
? ''
: originalPath.replace(`${config.moduleName}/`, '');
const importsBySubPath = importsByFilePath.get(importerFilePath);
const namedImportsForSubPath =
importsBySubPath?.get(moduleSubPath) ?? new Set<string>();
return {
contents: config.generateExports({
namedImports: namedImportsForSubPath,
moduleSubPath,
}),
loader: 'js' as const,
};
});
},
};
};
@@ -2,8 +2,16 @@ import type * as esbuild from 'esbuild';
import { jsxTransformToRemoteDomWorkerFormatPlugin } from '../jsx-transform-to-remote-dom-worker-format-plugin';
import { reactGlobalsPlugin } from '../react-globals-plugin';
import { stripCommentsPlugin } from '../strip-comments-plugin';
import { twentySdkGlobalsPlugin } from '../twenty-sdk-globals-plugin';
import { twentySdkUiGlobalsPlugin } from '../twenty-sdk-ui-globals-plugin';
import { twentySharedGlobalsPlugin } from '../twenty-shared-globals-plugin';
export const getFrontComponentBuildPlugins = (): esbuild.Plugin[] => [
reactGlobalsPlugin,
twentySdkGlobalsPlugin,
twentySdkUiGlobalsPlugin,
twentySharedGlobalsPlugin,
jsxTransformToRemoteDomWorkerFormatPlugin,
stripCommentsPlugin,
];
@@ -1,10 +1,31 @@
import path from 'path';
import { type ValidationResult } from '@/sdk';
import * as esbuild from 'esbuild';
import * as fs from 'fs-extra';
import { createRequire } from 'module';
import * as esbuild from 'esbuild';
import os from 'os';
import path from 'path';
import { isDefined, isPlainObject } from 'twenty-shared/utils';
import { type ValidationResult } from '@/sdk';
const MANIFEST_MOCK_MODULES = ['twenty-sdk/ui'];
const manifestMockPlugin: esbuild.Plugin = {
name: 'manifest-mock',
setup: (build) => {
const filter = new RegExp(
`^(${MANIFEST_MOCK_MODULES.map((module) => module.replace('/', '\\/')).join('|')})$`,
);
build.onResolve({ filter }, ({ path: modulePath }) => ({
path: modulePath,
namespace: 'manifest-mock',
}));
build.onLoad({ filter: /.*/, namespace: 'manifest-mock' }, () => ({
contents: 'module.exports = new Proxy({}, { get: () => () => {} });',
loader: 'js',
}));
},
};
export const extractManifestFromFile = async <T>({
filePath,
@@ -53,6 +74,7 @@ const loadModule = async ({
...(reactPath && { react: reactPath }),
...(reactDomPath && { 'react-dom': reactDomPath }),
},
plugins: [manifestMockPlugin],
logLevel: 'silent',
});
@@ -1,6 +1,8 @@
import { FrontComponentErrorEffect } from '@/front-component/remote/components/FrontComponentErrorEffect';
import { FrontComponentHostCommunicationApiEffect } from '@/front-component/remote/components/FrontComponentHostCommunicationApiEffect';
import { FrontComponentUpdateContextEffect } from '@/front-component/remote/components/FrontComponentUpdateContextEffect';
import { type FrontComponentExecutionContext } from '@/front-component/types/FrontComponentExecutionContext';
import { type FrontComponentHostCommunicationApi } from '@/front-component/types/FrontComponentHostCommunicationApi';
import { type WorkerExports } from '@/front-component/types/WorkerExports';
import { type ThreadWebWorker } from '@quilted/threads';
import {
@@ -18,6 +20,7 @@ import { componentRegistry } from '../generated/host-component-registry';
type FrontComponentContentProps = {
componentUrl: string;
executionContext: FrontComponentExecutionContext;
frontComponentHostCommunicationApi: FrontComponentHostCommunicationApi;
onError: (error?: Error) => void;
theme: ThemeType;
};
@@ -25,25 +28,34 @@ type FrontComponentContentProps = {
export const FrontComponentRenderer = ({
componentUrl,
executionContext,
frontComponentHostCommunicationApi,
onError,
theme,
}: FrontComponentContentProps) => {
const [receiver, setReceiver] = useState<RemoteReceiver | null>(null);
const [thread, setThread] = useState<ThreadWebWorker<WorkerExports> | null>(
null,
);
const [thread, setThread] = useState<ThreadWebWorker<
WorkerExports,
FrontComponentHostCommunicationApi
> | null>(null);
const [error, setError] = useState<Error | null>(null);
const MemoizedFrontComponentWorkerEffect = useMemo(() => {
return (
<FrontComponentWorkerEffect
componentUrl={componentUrl}
frontComponentHostCommunicationApi={frontComponentHostCommunicationApi}
setReceiver={setReceiver}
setThread={setThread}
setError={setError}
/>
);
}, [componentUrl, setError, setReceiver, setThread]);
}, [
componentUrl,
frontComponentHostCommunicationApi,
setError,
setReceiver,
setThread,
]);
return (
<>
@@ -54,10 +66,13 @@ export const FrontComponentRenderer = ({
)}
{isDefined(thread) && (
<FrontComponentUpdateContextEffect
thread={thread}
executionContext={executionContext}
/>
<>
<FrontComponentHostCommunicationApiEffect thread={thread} />
<FrontComponentUpdateContextEffect
thread={thread}
executionContext={executionContext}
/>
</>
)}
{isDefined(receiver) && (
@@ -1,12 +1,9 @@
export { FrontComponentRenderer } from './host/components/FrontComponentRenderer';
export { componentRegistry } from './host/generated/host-component-registry';
export { FrontComponentErrorEffect } from './remote/components/FrontComponentErrorEffect';
export { FrontComponentHostCommunicationApiEffect } from './remote/components/FrontComponentHostCommunicationApiEffect';
export { FrontComponentUpdateContextEffect } from './remote/components/FrontComponentUpdateContextEffect';
export { FrontComponentWorkerEffect } from './remote/components/FrontComponentWorkerEffect';
export {
FrontComponentExecutionContextStore,
frontComponentExecutionContextStore,
} from './remote/context/FrontComponentExecutionContextStore';
export {
HtmlA,
HtmlArticle,
@@ -93,8 +90,8 @@ export {
HtmlTdElement,
HtmlTextareaElement,
HtmlTfootElement,
HtmlThElement,
HtmlTheadElement,
HtmlThElement,
HtmlTrElement,
HtmlUlElement,
RemoteFragmentElement,
@@ -119,6 +116,7 @@ export type {
} from './remote/generated/remote-elements';
export { createRemoteWorker } from './remote/worker/createRemoteWorker';
export type { FrontComponentExecutionContext } from './types/FrontComponentExecutionContext';
export type { FrontComponentHostCommunicationApi } from './types/FrontComponentHostCommunicationApi';
export type { HostToWorkerRenderContext } from './types/HostToWorkerRenderContext';
export type { PropertySchema } from './types/PropertySchema';
export type { WorkerExports } from './types/WorkerExports';
@@ -0,0 +1,20 @@
import { type FrontComponentHostCommunicationApi } from '@/front-component/types/FrontComponentHostCommunicationApi';
import { type WorkerExports } from '@/front-component/types/WorkerExports';
import { type ThreadWebWorker } from '@quilted/threads';
import { useEffect } from 'react';
type FrontComponentHostCommunicationApiEffectProps = {
thread: ThreadWebWorker<WorkerExports, FrontComponentHostCommunicationApi>;
};
export const FrontComponentHostCommunicationApiEffect = ({
thread,
}: FrontComponentHostCommunicationApiEffectProps) => {
useEffect(() => {
thread.imports.initializeHostCommunicationApi().catch((error) => {
console.error('Failed to initialize host communication API:', error);
});
}, [thread]);
return null;
};
@@ -1,10 +1,11 @@
import { type FrontComponentExecutionContext } from '@/front-component/types/FrontComponentExecutionContext';
import { type FrontComponentHostCommunicationApi } from '@/front-component/types/FrontComponentHostCommunicationApi';
import { type WorkerExports } from '@/front-component/types/WorkerExports';
import { type ThreadWebWorker } from '@quilted/threads';
import { useEffect } from 'react';
type FrontComponentUpdateContextEffectProps = {
thread: ThreadWebWorker<WorkerExports>;
thread: ThreadWebWorker<WorkerExports, FrontComponentHostCommunicationApi>;
executionContext: FrontComponentExecutionContext;
};
@@ -1,24 +1,36 @@
import { ThreadWebWorker, release, retain } from '@quilted/threads';
import { RemoteReceiver } from '@remote-dom/core/receivers';
import { useEffect } from 'react';
import { useEffect, useRef } from 'react';
import { type FrontComponentHostCommunicationApi } from '../../types/FrontComponentHostCommunicationApi';
import { type WorkerExports } from '../../types/WorkerExports';
import { createRemoteWorker } from '../worker/createRemoteWorker';
type FrontComponentWorkerEffectProps = {
componentUrl: string;
frontComponentHostCommunicationApi: FrontComponentHostCommunicationApi;
setReceiver: React.Dispatch<React.SetStateAction<RemoteReceiver | null>>;
setThread: React.Dispatch<
React.SetStateAction<ThreadWebWorker<WorkerExports> | null>
React.SetStateAction<ThreadWebWorker<
WorkerExports,
FrontComponentHostCommunicationApi
> | null>
>;
setError: React.Dispatch<React.SetStateAction<Error | null>>;
};
export const FrontComponentWorkerEffect = ({
componentUrl,
frontComponentHostCommunicationApi,
setReceiver,
setThread,
setError,
}: FrontComponentWorkerEffectProps) => {
const frontComponentHostCommunicationApiRef = useRef(
frontComponentHostCommunicationApi,
);
frontComponentHostCommunicationApiRef.current =
frontComponentHostCommunicationApi;
useEffect(() => {
const newReceiver = new RemoteReceiver({ retain, release });
@@ -28,7 +40,19 @@ export const FrontComponentWorkerEffect = ({
setError(event.error);
};
const thread = new ThreadWebWorker<WorkerExports>(worker);
// Expose host functions to the worker via stable refs to avoid recreating threads
const stableFrontComponentHostCommunicationApi: FrontComponentHostCommunicationApi =
{
navigate: (...args) =>
frontComponentHostCommunicationApiRef.current.navigate(...args),
};
const thread = new ThreadWebWorker<
WorkerExports,
FrontComponentHostCommunicationApi
>(worker, {
exports: stableFrontComponentHostCommunicationApi,
});
setThread(thread);
thread.imports
@@ -1,28 +0,0 @@
import { type FrontComponentExecutionContext } from '../../types/FrontComponentExecutionContext';
type Listener = () => void;
export class FrontComponentExecutionContextStore {
private context: FrontComponentExecutionContext | undefined = undefined;
private listeners = new Set<Listener>();
getSnapshot = (): FrontComponentExecutionContext | undefined => {
return this.context;
};
subscribe = (listener: Listener): (() => void) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
setContext = (context: FrontComponentExecutionContext): void => {
this.context = context;
for (const listener of this.listeners) {
listener();
}
};
}
export const frontComponentExecutionContextStore =
new FrontComponentExecutionContextStore();
@@ -0,0 +1,5 @@
export const exposeGlobals = (globals: Record<string, unknown>): void => {
for (const [key, value] of Object.entries(globals)) {
(globalThis as Record<string, unknown>)[key] = value;
}
};
@@ -12,18 +12,31 @@ import {
import React from 'react';
import { createRoot } from 'react-dom/client';
import { jsx, jsxs } from 'react/jsx-runtime';
import * as TwentySharedTypes from 'twenty-shared/types';
import * as TwentySharedUtils from 'twenty-shared/utils';
import { setFrontComponentExecutionContext } from '@/sdk/front-component-api/context/frontComponentContext';
import { setNavigate } from '@/sdk/front-component-api/functions/navigate';
import * as TwentySdk from '@/sdk';
import { type FrontComponentExecutionContext } from '../../types/FrontComponentExecutionContext';
import { type FrontComponentHostCommunicationApi } from '../../types/FrontComponentHostCommunicationApi';
import { type HostToWorkerRenderContext } from '../../types/HostToWorkerRenderContext';
import { type WorkerExports } from '../../types/WorkerExports';
import { frontComponentExecutionContextStore } from '../context/FrontComponentExecutionContextStore';
import * as RemoteComponents from '../generated/remote-components';
import { exposeGlobals } from '../utils/exposeGlobals';
(globalThis as Record<string, unknown>).React = React;
(globalThis as Record<string, unknown>).RemoteComponents = RemoteComponents;
(globalThis as Record<string, unknown>).jsx = jsx;
(globalThis as Record<string, unknown>).jsxs = jsxs;
(globalThis as Record<string, unknown>).frontComponentExecutionContextStore =
frontComponentExecutionContextStore;
exposeGlobals({
React,
RemoteComponents,
jsx,
jsxs,
TwentySdk,
TwentyShared: {
utils: TwentySharedUtils,
types: TwentySharedTypes,
},
});
const render: WorkerExports['render'] = async (
connection: RemoteConnection,
@@ -41,10 +54,21 @@ const render: WorkerExports['render'] = async (
reactRoot.render(componentModule.default);
};
const initializeHostCommunicationApi: WorkerExports['initializeHostCommunicationApi'] =
async () => {
const hostApi =
ThreadWebWorker.self.import<FrontComponentHostCommunicationApi>();
setNavigate(hostApi.navigate);
};
const updateContext: WorkerExports['updateContext'] = async (
context: FrontComponentExecutionContext,
) => {
frontComponentExecutionContextStore.setContext(context);
setFrontComponentExecutionContext(context);
};
ThreadWebWorker.self.export({ render, updateContext });
ThreadWebWorker.self.export({
render,
initializeHostCommunicationApi,
updateContext,
});
@@ -1,3 +1,4 @@
// Serializable execution context that can be passed via postMessage (no functions)
export type FrontComponentExecutionContext = {
userId: string | null;
};
@@ -0,0 +1,10 @@
import { type AppPath, type NavigateOptions } from 'twenty-shared/types';
export type FrontComponentHostCommunicationApi = {
navigate: (
to: AppPath,
params?: Record<string, string | null>,
queryParams?: Record<string, unknown>,
options?: NavigateOptions,
) => Promise<void>;
};
@@ -7,5 +7,6 @@ export type WorkerExports = {
connection: RemoteConnection,
context: HostToWorkerRenderContext,
) => Promise<void>;
initializeHostCommunicationApi: () => Promise<void>;
updateContext: (context: FrontComponentExecutionContext) => Promise<void>;
};
@@ -0,0 +1,35 @@
import { type FrontComponentExecutionContext } from '../types/FrontComponentExecutionContext';
type Listener = () => void;
let executionContext: FrontComponentExecutionContext | undefined;
const listeners = new Set<Listener>();
export const setFrontComponentExecutionContext = (
context: FrontComponentExecutionContext,
): void => {
executionContext = context;
for (const listener of listeners) {
listener();
}
};
export const getFrontComponentExecutionContext = ():
| FrontComponentExecutionContext
| undefined => {
return executionContext;
};
export const subscribeToFrontComponentExecutionContext = (
listener: Listener,
): void => {
listeners.add(listener);
};
export const unsubscribeFromFrontComponentExecutionContext = (
listener: Listener,
): void => {
listeners.delete(listener);
};
@@ -0,0 +1,27 @@
import { type AppPath, type NavigateOptions } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
type NavigateFunction = (
to: AppPath,
params?: Record<string, string | null>,
queryParams?: Record<string, unknown>,
options?: NavigateOptions,
) => Promise<void>;
let navigateFunction: NavigateFunction | undefined;
export const setNavigate = (fn: NavigateFunction): void => {
navigateFunction = fn;
};
export const navigate: NavigateFunction = (
to: AppPath,
params?: Record<string, string | null>,
queryParams?: Record<string, unknown>,
options?: NavigateOptions,
): Promise<void> => {
if (!isDefined(navigateFunction)) {
throw new Error('navigateFunction is not set');
}
return navigateFunction(to, params, queryParams, options);
};
@@ -1,26 +1,39 @@
import { useSyncExternalStore } from 'react';
import { useEffect, useRef, useState } from 'react';
import {
getFrontComponentExecutionContext,
subscribeToFrontComponentExecutionContext,
unsubscribeFromFrontComponentExecutionContext,
} from '../context/frontComponentContext';
import { type FrontComponentExecutionContext } from '../types/FrontComponentExecutionContext';
import { type FrontComponentExecutionContextStore } from '../types/FrontComponentExecutionContextStore';
const getStore = (): FrontComponentExecutionContextStore => {
const store = (globalThis as Record<string, unknown>)
.frontComponentExecutionContextStore as
| FrontComponentExecutionContextStore
| undefined;
export const useFrontComponentExecutionContext = <T>(
selector: (context: FrontComponentExecutionContext | undefined) => T,
): T => {
const [currentSelectedValue, setCurrentSelectedValue] = useState(() =>
selector(getFrontComponentExecutionContext()),
);
if (store === undefined) {
throw new Error(
'frontComponentExecutionContextStore not found on globalThis. This hook must be used within a front component running in the worker.',
);
}
const previousSelectedValueRef = useRef(currentSelectedValue);
return store;
};
export const useFrontComponentExecutionContext = ():
| FrontComponentExecutionContext
| undefined => {
const store = getStore();
return useSyncExternalStore(store.subscribe, store.getSnapshot);
useEffect(() => {
const onContextChange = () => {
const newSelectedValue = selector(getFrontComponentExecutionContext());
const hasSelectedValueChanged =
newSelectedValue !== previousSelectedValueRef.current;
if (hasSelectedValueChanged) {
previousSelectedValueRef.current = newSelectedValue;
setCurrentSelectedValue(newSelectedValue);
}
};
subscribeToFrontComponentExecutionContext(onContextChange);
onContextChange();
return () => unsubscribeFromFrontComponentExecutionContext(onContextChange);
}, [selector]);
return currentSelectedValue;
};
@@ -0,0 +1,10 @@
import { type FrontComponentExecutionContext } from '../types/FrontComponentExecutionContext';
import { useFrontComponentExecutionContext } from './useFrontComponentExecutionContext';
const selectUserId = (
context: FrontComponentExecutionContext | undefined,
): string | null | undefined => context?.userId;
export const useUserId = (): string | null | undefined => {
return useFrontComponentExecutionContext(selectUserId);
};
@@ -1,3 +1,5 @@
export { setFrontComponentExecutionContext } from './context/frontComponentContext';
export { navigate, setNavigate } from './functions/navigate';
export { useFrontComponentExecutionContext } from './hooks/useFrontComponentExecutionContext';
export { useUserId } from './hooks/useUserId';
export type { FrontComponentExecutionContext } from './types/FrontComponentExecutionContext';
export type { FrontComponentExecutionContextStore } from './types/FrontComponentExecutionContextStore';
@@ -1,3 +1,3 @@
export type FrontComponentExecutionContext = {
userId: string;
userId: string | null;
};
@@ -1,6 +0,0 @@
import { type FrontComponentExecutionContext } from './FrontComponentExecutionContext';
export type FrontComponentExecutionContextStore = {
getSnapshot: () => FrontComponentExecutionContext | undefined;
subscribe: (listener: () => void) => () => void;
};
+2 -1
View File
@@ -52,8 +52,9 @@ export { PermissionFlag } from './roles/permission-flag-type';
// Front Component API exports
export { useFrontComponentExecutionContext } from './front-component-api';
export { navigate } from './front-component-api';
export { useUserId } from './front-component-api';
export type { FrontComponentExecutionContext } from './front-component-api';
export type { FrontComponentExecutionContextStore } from './front-component-api';
// Front Component Common exports
export type { AllowedHtmlElement } from './front-component-common';
@@ -0,0 +1,4 @@
export type NavigateOptions = {
replace?: boolean;
state?: unknown;
};
@@ -114,6 +114,7 @@ export type { IsSerializedRelation } from './IsSerializedRelation.type';
export type { LogicFunctionEvent } from './LogicFunctionEvent';
export { MessageParticipantRole } from './MessageParticipantRole';
export type { ModifiedProperties } from './ModifiedProperties';
export type { NavigateOptions } from './NavigateOptions';
export type { NonNullableRequired } from './NonNullableRequired';
export type { Nullable } from './Nullable';
export type { NullablePartial } from './NullablePartial';