From 2b29918bf8f65feaed7e6009890bb8069c3c29c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Bosi?= <71827178+bosiraphael@users.noreply.github.com> Date: Mon, 9 Feb 2026 14:38:35 +0100 Subject: [PATCH] [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 --- .../twenty-front/src/hooks/useNavigateApp.ts | 7 +- .../components/FrontComponentRenderer.tsx | 12 +- .../useFrontComponentExecutionContext.ts | 45 ++++++ .../utils/mockFrontComponent.ts | 104 ++----------- packages/twenty-sdk/.storybook/main.ts | 7 + packages/twenty-sdk/eslint.config.mjs | 28 ++++ .../front-component-external-modules.ts | 8 +- ...form-to-remote-dom-worker-format-plugin.ts | 7 +- .../react-globals-plugin.ts | 138 ++++++------------ .../strip-comments-plugin.ts | 31 ++++ .../twenty-sdk-globals-plugin.ts | 26 ++++ .../twenty-sdk-ui-globals-plugin.ts | 26 ++++ .../twenty-shared-globals-plugin.ts | 38 +++++ .../utils/collect-named-imports.ts | 45 ++++++ .../create-front-component-build-options.ts | 2 +- .../utils/create-globals-plugin.ts | 96 ++++++++++++ .../get-front-component-build-plugins.ts | 8 + .../manifest-extract-config-from-file.ts | 28 +++- .../components/FrontComponentRenderer.tsx | 31 +++- .../twenty-sdk/src/front-component/index.ts | 8 +- ...ontComponentHostCommunicationApiEffect.tsx | 20 +++ .../FrontComponentUpdateContextEffect.tsx | 3 +- .../components/FrontComponentWorkerEffect.tsx | 30 +++- .../FrontComponentExecutionContextStore.ts | 28 ---- .../remote/utils/exposeGlobals.ts | 5 + .../remote/worker/remote-worker.ts | 42 ++++-- .../types/FrontComponentExecutionContext.ts | 1 + .../FrontComponentHostCommunicationApi.ts | 10 ++ .../front-component/types/WorkerExports.ts | 1 + .../context/frontComponentContext.ts | 35 +++++ .../front-component-api/functions/navigate.ts | 27 ++++ .../useFrontComponentExecutionContext.ts | 55 ++++--- .../front-component-api/hooks/useUserId.ts | 10 ++ .../src/sdk/front-component-api/index.ts | 4 +- .../types/FrontComponentExecutionContext.ts | 2 +- .../FrontComponentExecutionContextStore.ts | 6 - packages/twenty-sdk/src/sdk/index.ts | 3 +- .../src/types/NavigateOptions.ts | 4 + packages/twenty-shared/src/types/index.ts | 1 + 39 files changed, 689 insertions(+), 293 deletions(-) create mode 100644 packages/twenty-front/src/modules/front-components/hooks/useFrontComponentExecutionContext.ts create mode 100644 packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/strip-comments-plugin.ts create mode 100644 packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/twenty-sdk-globals-plugin.ts create mode 100644 packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/twenty-sdk-ui-globals-plugin.ts create mode 100644 packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/twenty-shared-globals-plugin.ts create mode 100644 packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/utils/collect-named-imports.ts create mode 100644 packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/utils/create-globals-plugin.ts create mode 100644 packages/twenty-sdk/src/front-component/remote/components/FrontComponentHostCommunicationApiEffect.tsx delete mode 100644 packages/twenty-sdk/src/front-component/remote/context/FrontComponentExecutionContextStore.ts create mode 100644 packages/twenty-sdk/src/front-component/remote/utils/exposeGlobals.ts create mode 100644 packages/twenty-sdk/src/front-component/types/FrontComponentHostCommunicationApi.ts create mode 100644 packages/twenty-sdk/src/sdk/front-component-api/context/frontComponentContext.ts create mode 100644 packages/twenty-sdk/src/sdk/front-component-api/functions/navigate.ts create mode 100644 packages/twenty-sdk/src/sdk/front-component-api/hooks/useUserId.ts delete mode 100644 packages/twenty-sdk/src/sdk/front-component-api/types/FrontComponentExecutionContextStore.ts create mode 100644 packages/twenty-shared/src/types/NavigateOptions.ts diff --git a/packages/twenty-front/src/hooks/useNavigateApp.ts b/packages/twenty-front/src/hooks/useNavigateApp.ts index 7b1fb56880..25e8df8d9d 100644 --- a/packages/twenty-front/src/hooks/useNavigateApp.ts +++ b/packages/twenty-front/src/hooks/useNavigateApp.ts @@ -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>[1], queryParams?: Record, - options?: { - replace?: boolean; - state?: any; - }, + options?: NavigateOptions, ) => { const path = getAppPath(to, params, queryParams); return navigate(path, options); diff --git a/packages/twenty-front/src/modules/front-components/components/FrontComponentRenderer.tsx b/packages/twenty-front/src/modules/front-components/components/FrontComponentRenderer.tsx index 10836937ae..4a56887df9 100644 --- a/packages/twenty-front/src/modules/front-components/components/FrontComponentRenderer.tsx +++ b/packages/twenty-front/src/modules/front-components/components/FrontComponentRenderer.tsx @@ -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 = ({ ); diff --git a/packages/twenty-front/src/modules/front-components/hooks/useFrontComponentExecutionContext.ts b/packages/twenty-front/src/modules/front-components/hooks/useFrontComponentExecutionContext.ts new file mode 100644 index 0000000000..79e8c0df26 --- /dev/null +++ b/packages/twenty-front/src/modules/front-components/hooks/useFrontComponentExecutionContext.ts @@ -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[1], + queryParams, + options, + ); + }; + + const executionContext: FrontComponentExecutionContext = { + userId: currentUser?.id ?? null, + }; + + const frontComponentHostCommunicationApi: FrontComponentHostCommunicationApi = + { + navigate, + }; + + return { + executionContext, + frontComponentHostCommunicationApi, + }; +}; 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 c8d3da9820..0441cca5ed 100644 --- a/packages/twenty-front/src/modules/front-components/utils/mockFrontComponent.ts +++ b/packages/twenty-front/src/modules/front-components/utils/mockFrontComponent.ts @@ -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; diff --git a/packages/twenty-sdk/.storybook/main.ts b/packages/twenty-sdk/.storybook/main.ts index 6251587dc5..46dd3b40bf 100644 --- a/packages/twenty-sdk/.storybook/main.ts +++ b/packages/twenty-sdk/.storybook/main.ts @@ -31,6 +31,13 @@ const config: StorybookConfig = { '@': path.resolve(dirname, '../src'), }, }, + optimizeDeps: { + ...viteConfig.optimizeDeps, + include: [ + ...(viteConfig.optimizeDeps?.include ?? []), + 'transliteration', + ], + }, }; }, }; diff --git a/packages/twenty-sdk/eslint.config.mjs b/packages/twenty-sdk/eslint.config.mjs index 237549301d..f569b44994 100644 --- a/packages/twenty-sdk/eslint.config.mjs +++ b/packages/twenty-sdk/eslint.config.mjs @@ -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', }, }, { diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/constants/front-component-external-modules.ts b/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/constants/front-component-external-modules.ts index a124b9d252..affc3cc593 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/constants/front-component-external-modules.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/constants/front-component-external-modules.ts @@ -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']; 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 index 95abc42eee..c5beb0ba6c 100644 --- 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 @@ -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) { 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 index 58b25adb03..ff9e5ef219 100644 --- 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 @@ -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+(?:(?\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; +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 => { - const collectedReactImports = new Set(); +const collectReactImports = ( + sourceContent: string, +): Map> => { + 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 => { - const exportStatements: string[] = []; +const generateReactExports = ({ + namedImports, +}: { + namedImports: Set; +}): 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>(); - - 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: - 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(); - - 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, }, -}; +}); diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/strip-comments-plugin.ts b/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/strip-comments-plugin.ts new file mode 100644 index 0000000000..0a2b1a0b52 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/strip-comments-plugin.ts @@ -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'); + } + } + }); + }, +}; diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/twenty-sdk-globals-plugin.ts b/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/twenty-sdk-globals-plugin.ts new file mode 100644 index 0000000000..62e8ea5784 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/twenty-sdk-globals-plugin.ts @@ -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+(?:\{(?[^}]*)\})?\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'), +}); diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/twenty-sdk-ui-globals-plugin.ts b/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/twenty-sdk-ui-globals-plugin.ts new file mode 100644 index 0000000000..4d815eae72 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/twenty-sdk-ui-globals-plugin.ts @@ -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+\{(?[^}]*)\}\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'), +}); diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/twenty-shared-globals-plugin.ts b/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/twenty-shared-globals-plugin.ts new file mode 100644 index 0000000000..428dd38bd1 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/twenty-shared-globals-plugin.ts @@ -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+\{(?[^}]*)\}\s*from\s*['"]twenty-shared(?:\/(?[^'"]*?))?['"];?/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'); + }, +}); diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/utils/collect-named-imports.ts b/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/utils/collect-named-imports.ts new file mode 100644 index 0000000000..247b75655e --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/utils/collect-named-imports.ts @@ -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> => { + const collectedImports = new Map>(); + + 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; +}; diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/utils/create-front-component-build-options.ts b/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/utils/create-front-component-build-options.ts index 63417087c5..387ff9effc 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/utils/create-front-component-build-options.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/utils/create-front-component-build-options.ts @@ -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; diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/utils/create-globals-plugin.ts b/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/utils/create-globals-plugin.ts new file mode 100644 index 0000000000..9aedc5c7db --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/utils/create-globals-plugin.ts @@ -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>; + generateExports: (params: { + namedImports: Set; + moduleSubPath: string; + }) => string; + namespace?: string; + staticContents?: Record; +}; + +export const createGlobalsPlugin = ( + config: GlobalsPluginConfig, +): esbuild.Plugin => { + const namespace = config.namespace ?? config.pluginName; + + return { + name: config.pluginName, + setup: (build) => { + const importsByFilePath = new Map>>(); + + 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(); + + return { + contents: config.generateExports({ + namedImports: namedImportsForSubPath, + moduleSubPath, + }), + loader: 'js' as const, + }; + }); + }, + }; +}; diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/utils/get-front-component-build-plugins.ts b/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/utils/get-front-component-build-plugins.ts index 28f78693be..73359cc477 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/utils/get-front-component-build-plugins.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/utils/get-front-component-build-plugins.ts @@ -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, ]; diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-extract-config-from-file.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-extract-config-from-file.ts index 767cf95dda..89c8b54acd 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-extract-config-from-file.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-extract-config-from-file.ts @@ -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 ({ filePath, @@ -53,6 +74,7 @@ const loadModule = async ({ ...(reactPath && { react: reactPath }), ...(reactDomPath && { 'react-dom': reactDomPath }), }, + plugins: [manifestMockPlugin], logLevel: 'silent', }); diff --git a/packages/twenty-sdk/src/front-component/host/components/FrontComponentRenderer.tsx b/packages/twenty-sdk/src/front-component/host/components/FrontComponentRenderer.tsx index 0a8c037364..f6a35bb1ed 100644 --- a/packages/twenty-sdk/src/front-component/host/components/FrontComponentRenderer.tsx +++ b/packages/twenty-sdk/src/front-component/host/components/FrontComponentRenderer.tsx @@ -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(null); - const [thread, setThread] = useState | null>( - null, - ); + const [thread, setThread] = useState | null>(null); const [error, setError] = useState(null); const MemoizedFrontComponentWorkerEffect = useMemo(() => { return ( ); - }, [componentUrl, setError, setReceiver, setThread]); + }, [ + componentUrl, + frontComponentHostCommunicationApi, + setError, + setReceiver, + setThread, + ]); return ( <> @@ -54,10 +66,13 @@ export const FrontComponentRenderer = ({ )} {isDefined(thread) && ( - + <> + + + )} {isDefined(receiver) && ( diff --git a/packages/twenty-sdk/src/front-component/index.ts b/packages/twenty-sdk/src/front-component/index.ts index fad9220aca..15bb2a2c83 100644 --- a/packages/twenty-sdk/src/front-component/index.ts +++ b/packages/twenty-sdk/src/front-component/index.ts @@ -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'; diff --git a/packages/twenty-sdk/src/front-component/remote/components/FrontComponentHostCommunicationApiEffect.tsx b/packages/twenty-sdk/src/front-component/remote/components/FrontComponentHostCommunicationApiEffect.tsx new file mode 100644 index 0000000000..58756a0acd --- /dev/null +++ b/packages/twenty-sdk/src/front-component/remote/components/FrontComponentHostCommunicationApiEffect.tsx @@ -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; +}; + +export const FrontComponentHostCommunicationApiEffect = ({ + thread, +}: FrontComponentHostCommunicationApiEffectProps) => { + useEffect(() => { + thread.imports.initializeHostCommunicationApi().catch((error) => { + console.error('Failed to initialize host communication API:', error); + }); + }, [thread]); + + return null; +}; diff --git a/packages/twenty-sdk/src/front-component/remote/components/FrontComponentUpdateContextEffect.tsx b/packages/twenty-sdk/src/front-component/remote/components/FrontComponentUpdateContextEffect.tsx index 20c94edae6..930ef9f2ac 100644 --- a/packages/twenty-sdk/src/front-component/remote/components/FrontComponentUpdateContextEffect.tsx +++ b/packages/twenty-sdk/src/front-component/remote/components/FrontComponentUpdateContextEffect.tsx @@ -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; + thread: ThreadWebWorker; executionContext: FrontComponentExecutionContext; }; diff --git a/packages/twenty-sdk/src/front-component/remote/components/FrontComponentWorkerEffect.tsx b/packages/twenty-sdk/src/front-component/remote/components/FrontComponentWorkerEffect.tsx index 7872a79831..9d4c611b54 100644 --- a/packages/twenty-sdk/src/front-component/remote/components/FrontComponentWorkerEffect.tsx +++ b/packages/twenty-sdk/src/front-component/remote/components/FrontComponentWorkerEffect.tsx @@ -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>; setThread: React.Dispatch< - React.SetStateAction | null> + React.SetStateAction | null> >; setError: React.Dispatch>; }; 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(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 diff --git a/packages/twenty-sdk/src/front-component/remote/context/FrontComponentExecutionContextStore.ts b/packages/twenty-sdk/src/front-component/remote/context/FrontComponentExecutionContextStore.ts deleted file mode 100644 index 708e7fa175..0000000000 --- a/packages/twenty-sdk/src/front-component/remote/context/FrontComponentExecutionContextStore.ts +++ /dev/null @@ -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(); - - 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(); diff --git a/packages/twenty-sdk/src/front-component/remote/utils/exposeGlobals.ts b/packages/twenty-sdk/src/front-component/remote/utils/exposeGlobals.ts new file mode 100644 index 0000000000..ce0ac2cc7f --- /dev/null +++ b/packages/twenty-sdk/src/front-component/remote/utils/exposeGlobals.ts @@ -0,0 +1,5 @@ +export const exposeGlobals = (globals: Record): void => { + for (const [key, value] of Object.entries(globals)) { + (globalThis as Record)[key] = value; + } +}; diff --git a/packages/twenty-sdk/src/front-component/remote/worker/remote-worker.ts b/packages/twenty-sdk/src/front-component/remote/worker/remote-worker.ts index 3b473d9b27..873bb9d01f 100644 --- a/packages/twenty-sdk/src/front-component/remote/worker/remote-worker.ts +++ b/packages/twenty-sdk/src/front-component/remote/worker/remote-worker.ts @@ -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).React = React; -(globalThis as Record).RemoteComponents = RemoteComponents; -(globalThis as Record).jsx = jsx; -(globalThis as Record).jsxs = jsxs; -(globalThis as Record).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(); + 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, +}); diff --git a/packages/twenty-sdk/src/front-component/types/FrontComponentExecutionContext.ts b/packages/twenty-sdk/src/front-component/types/FrontComponentExecutionContext.ts index 732a36147a..fc16248356 100644 --- a/packages/twenty-sdk/src/front-component/types/FrontComponentExecutionContext.ts +++ b/packages/twenty-sdk/src/front-component/types/FrontComponentExecutionContext.ts @@ -1,3 +1,4 @@ +// Serializable execution context that can be passed via postMessage (no functions) export type FrontComponentExecutionContext = { userId: string | null; }; diff --git a/packages/twenty-sdk/src/front-component/types/FrontComponentHostCommunicationApi.ts b/packages/twenty-sdk/src/front-component/types/FrontComponentHostCommunicationApi.ts new file mode 100644 index 0000000000..ef088c2e23 --- /dev/null +++ b/packages/twenty-sdk/src/front-component/types/FrontComponentHostCommunicationApi.ts @@ -0,0 +1,10 @@ +import { type AppPath, type NavigateOptions } from 'twenty-shared/types'; + +export type FrontComponentHostCommunicationApi = { + navigate: ( + to: AppPath, + params?: Record, + queryParams?: Record, + options?: NavigateOptions, + ) => Promise; +}; diff --git a/packages/twenty-sdk/src/front-component/types/WorkerExports.ts b/packages/twenty-sdk/src/front-component/types/WorkerExports.ts index 2218b641f3..fcf73acfbd 100644 --- a/packages/twenty-sdk/src/front-component/types/WorkerExports.ts +++ b/packages/twenty-sdk/src/front-component/types/WorkerExports.ts @@ -7,5 +7,6 @@ export type WorkerExports = { connection: RemoteConnection, context: HostToWorkerRenderContext, ) => Promise; + initializeHostCommunicationApi: () => Promise; updateContext: (context: FrontComponentExecutionContext) => Promise; }; diff --git a/packages/twenty-sdk/src/sdk/front-component-api/context/frontComponentContext.ts b/packages/twenty-sdk/src/sdk/front-component-api/context/frontComponentContext.ts new file mode 100644 index 0000000000..f43002a42a --- /dev/null +++ b/packages/twenty-sdk/src/sdk/front-component-api/context/frontComponentContext.ts @@ -0,0 +1,35 @@ +import { type FrontComponentExecutionContext } from '../types/FrontComponentExecutionContext'; + +type Listener = () => void; + +let executionContext: FrontComponentExecutionContext | undefined; + +const listeners = new Set(); + +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); +}; diff --git a/packages/twenty-sdk/src/sdk/front-component-api/functions/navigate.ts b/packages/twenty-sdk/src/sdk/front-component-api/functions/navigate.ts new file mode 100644 index 0000000000..4e5994a0c8 --- /dev/null +++ b/packages/twenty-sdk/src/sdk/front-component-api/functions/navigate.ts @@ -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, + queryParams?: Record, + options?: NavigateOptions, +) => Promise; + +let navigateFunction: NavigateFunction | undefined; + +export const setNavigate = (fn: NavigateFunction): void => { + navigateFunction = fn; +}; + +export const navigate: NavigateFunction = ( + to: AppPath, + params?: Record, + queryParams?: Record, + options?: NavigateOptions, +): Promise => { + if (!isDefined(navigateFunction)) { + throw new Error('navigateFunction is not set'); + } + return navigateFunction(to, params, queryParams, options); +}; diff --git a/packages/twenty-sdk/src/sdk/front-component-api/hooks/useFrontComponentExecutionContext.ts b/packages/twenty-sdk/src/sdk/front-component-api/hooks/useFrontComponentExecutionContext.ts index c89b322569..bc5e161598 100644 --- a/packages/twenty-sdk/src/sdk/front-component-api/hooks/useFrontComponentExecutionContext.ts +++ b/packages/twenty-sdk/src/sdk/front-component-api/hooks/useFrontComponentExecutionContext.ts @@ -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) - .frontComponentExecutionContextStore as - | FrontComponentExecutionContextStore - | undefined; +export const useFrontComponentExecutionContext = ( + 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; }; diff --git a/packages/twenty-sdk/src/sdk/front-component-api/hooks/useUserId.ts b/packages/twenty-sdk/src/sdk/front-component-api/hooks/useUserId.ts new file mode 100644 index 0000000000..6035d14d57 --- /dev/null +++ b/packages/twenty-sdk/src/sdk/front-component-api/hooks/useUserId.ts @@ -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); +}; diff --git a/packages/twenty-sdk/src/sdk/front-component-api/index.ts b/packages/twenty-sdk/src/sdk/front-component-api/index.ts index fc03b9744d..bea683b418 100644 --- a/packages/twenty-sdk/src/sdk/front-component-api/index.ts +++ b/packages/twenty-sdk/src/sdk/front-component-api/index.ts @@ -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'; diff --git a/packages/twenty-sdk/src/sdk/front-component-api/types/FrontComponentExecutionContext.ts b/packages/twenty-sdk/src/sdk/front-component-api/types/FrontComponentExecutionContext.ts index 0b6fb8874e..732a36147a 100644 --- a/packages/twenty-sdk/src/sdk/front-component-api/types/FrontComponentExecutionContext.ts +++ b/packages/twenty-sdk/src/sdk/front-component-api/types/FrontComponentExecutionContext.ts @@ -1,3 +1,3 @@ export type FrontComponentExecutionContext = { - userId: string; + userId: string | null; }; diff --git a/packages/twenty-sdk/src/sdk/front-component-api/types/FrontComponentExecutionContextStore.ts b/packages/twenty-sdk/src/sdk/front-component-api/types/FrontComponentExecutionContextStore.ts deleted file mode 100644 index bcc043399c..0000000000 --- a/packages/twenty-sdk/src/sdk/front-component-api/types/FrontComponentExecutionContextStore.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { type FrontComponentExecutionContext } from './FrontComponentExecutionContext'; - -export type FrontComponentExecutionContextStore = { - getSnapshot: () => FrontComponentExecutionContext | undefined; - subscribe: (listener: () => void) => () => void; -}; diff --git a/packages/twenty-sdk/src/sdk/index.ts b/packages/twenty-sdk/src/sdk/index.ts index 98c5945dc9..0f8c63bf18 100644 --- a/packages/twenty-sdk/src/sdk/index.ts +++ b/packages/twenty-sdk/src/sdk/index.ts @@ -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'; diff --git a/packages/twenty-shared/src/types/NavigateOptions.ts b/packages/twenty-shared/src/types/NavigateOptions.ts new file mode 100644 index 0000000000..f8a3ac845f --- /dev/null +++ b/packages/twenty-shared/src/types/NavigateOptions.ts @@ -0,0 +1,4 @@ +export type NavigateOptions = { + replace?: boolean; + state?: unknown; +}; diff --git a/packages/twenty-shared/src/types/index.ts b/packages/twenty-shared/src/types/index.ts index b1cc3c1deb..9e737ef0c8 100644 --- a/packages/twenty-shared/src/types/index.ts +++ b/packages/twenty-shared/src/types/index.ts @@ -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';