[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:
@@ -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);
|
||||
|
||||
+6
-6
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
+45
@@ -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;
|
||||
|
||||
@@ -31,6 +31,13 @@ const config: StorybookConfig = {
|
||||
'@': path.resolve(dirname, '../src'),
|
||||
},
|
||||
},
|
||||
optimizeDeps: {
|
||||
...viteConfig.optimizeDeps,
|
||||
include: [
|
||||
...(viteConfig.optimizeDeps?.include ?? []),
|
||||
'transliteration',
|
||||
],
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,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'];
|
||||
|
||||
+6
-1
@@ -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) {
|
||||
|
||||
+43
-95
@@ -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,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
+31
@@ -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');
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
+26
@@ -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'),
|
||||
});
|
||||
+26
@@ -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'),
|
||||
});
|
||||
+38
@@ -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');
|
||||
},
|
||||
});
|
||||
+45
@@ -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;
|
||||
};
|
||||
+1
-1
@@ -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;
|
||||
|
||||
+96
@@ -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,
|
||||
};
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
+8
@@ -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,
|
||||
];
|
||||
|
||||
+25
-3
@@ -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';
|
||||
|
||||
+20
@@ -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;
|
||||
};
|
||||
+2
-1
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
+27
-3
@@ -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
|
||||
|
||||
-28
@@ -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);
|
||||
};
|
||||
+34
-21
@@ -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
-1
@@ -1,3 +1,3 @@
|
||||
export type FrontComponentExecutionContext = {
|
||||
userId: string;
|
||||
userId: string | null;
|
||||
};
|
||||
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
import { type FrontComponentExecutionContext } from './FrontComponentExecutionContext';
|
||||
|
||||
export type FrontComponentExecutionContextStore = {
|
||||
getSnapshot: () => FrontComponentExecutionContext | undefined;
|
||||
subscribe: (listener: () => void) => () => void;
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user