From 8190cd5fbf5f1b220f1a4c89976b9b163d8e1621 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Bosi?= <71827178+bosiraphael@users.noreply.github.com> Date: Sun, 15 Feb 2026 23:50:57 +0100 Subject: [PATCH] [FRONT COMPONENTS] Allow style librairies in remote dom (#17936) https://github.com/user-attachments/assets/ce1b2b06-872f-41d0-844a-61db91696624 --------- Co-authored-by: Charles Bochet --- packages/twenty-sdk/.gitignore | 1 + packages/twenty-sdk/.storybook/main.ts | 4 + packages/twenty-sdk/package.json | 1 + packages/twenty-sdk/project.json | 28 +- .../build-source-examples.ts | 147 ++++- .../create-front-component-build-options.ts | 46 -- .../generate-remote-dom-elements.ts | 131 ++--- .../remote-dom/generators/constants.ts | 23 + .../constants/CustomElementNames.ts | 4 - .../constants/InternalElementClasses.ts | 4 - .../generators/constants/OutputFiles.ts | 5 - .../generators/constants/TypeNames.ts | 7 - .../remote-dom/generators/constants/index.ts | 4 - .../generators/host-registry.generator.ts | 213 ++----- .../generators/remote-components.generator.ts | 15 +- .../generators/remote-elements.generator.ts | 119 ++-- .../scripts/remote-dom/generators/schemas.ts | 6 +- .../generators/utils/add-exported-type.ts | 13 - .../generators/utils/add-file-header.ts | 7 - .../generators/utils/add-statement.ts | 8 - .../generators/utils/extract-html-tag.ts | 3 - .../utils/generate-properties-config.ts | 20 - .../utils/generate-properties-type.ts | 21 - .../generators/utils/generated-file-header.ts | 8 - .../remote-dom/generators/utils/index.ts | 8 - .../generators/utils/schema-type-to-ts.ts | 10 - .../scripts/remote-dom/utils/logger.ts | 135 ----- ...to-remote-dom-worker-format-plugin.spec.ts | 191 ------- .../__tests__/react-globals-plugin.spec.ts | 266 --------- .../front-component-external-modules.ts | 2 +- .../jsx-runtime-remote-wrapper-plugin.ts | 283 ++++++++++ ...form-to-remote-dom-worker-format-plugin.ts | 20 +- .../preact-alias-plugin.ts | 30 + .../react-globals-plugin.ts | 76 --- .../twenty-sdk-globals-plugin.ts | 26 - .../twenty-sdk-ui-globals-plugin.ts | 26 - .../twenty-shared-globals-plugin.ts | 38 -- .../types/ParsedImportSpecifier.ts | 4 - .../utils/collect-named-imports.ts | 45 -- .../utils/create-globals-plugin.ts | 96 ---- .../extract-names-from-import-specifier.ts | 20 - .../get-front-component-build-plugins.ts | 22 +- ...eplace-html-tags-with-remote-components.ts | 39 -- ...define-front-component-to-direct-export.ts | 7 +- .../__stories__/BundleSizes.stories.tsx | 213 +++++++ .../FrontComponentRenderer.stories.tsx | 119 ++-- .../__stories__/UILibraries.stories.tsx | 144 +++++ .../chakra-example.front-component.tsx | 79 ++- .../emotion-example.front-component.tsx | 86 ++- .../mui-example.front-component.tsx | 38 +- .../sdk-context-example.front-component.tsx | 116 ++++ .../shadcn-example.front-component.tsx | 168 +++--- ...led-components-example.front-component.tsx | 86 ++- .../tailwind-example.front-component.tsx | 95 ++-- .../twenty-ui-example.front-component.tsx | 89 ++- .../getBuiltStoryComponentPathForRender.ts | 8 +- .../host/components/RemoteStyleRenderer.tsx | 34 ++ .../host/generated/host-component-registry.ts | 519 +++--------------- .../host/generated/host-index.ts | 10 - .../host/utils/createHtmlHostWrapper.ts | 152 +++++ .../polyfills/MockCSSStyleSheet.ts | 41 ++ .../polyfills/installStyleBridge.ts | 252 +++++++++ .../components/FrontComponentWorkerEffect.tsx | 1 - .../remote/generated/remote-components.ts | 14 +- .../remote/generated/remote-elements.ts | 28 +- .../remote/generated/remote-index.ts | 11 - .../remote/mock/mock-front-component.ts | 51 -- .../installStylePropertyOnRemoteElements.ts | 213 +++++++ .../utils/patchRemoteElementSetAttribute.ts | 43 ++ .../remote/worker/remote-worker.ts | 37 +- .../types/FrontComponentExecutionContext.ts | 1 - .../constants/HtmlTagToRemoteComponent.ts | 25 +- .../context/frontComponentContext.ts | 28 +- .../front-component-api/functions/navigate.ts | 11 +- packages/twenty-sdk/src/sdk/index.ts | 36 +- packages/twenty-sdk/src/ui/index.ts | 4 + packages/twenty-sdk/tsconfig.json | 4 +- packages/twenty-sdk/tsconfig.lib.json | 5 +- packages/twenty-sdk/vite.config.browser.ts | 1 - packages/twenty-sdk/vite.config.sdk.ts | 53 ++ packages/twenty-shared/package.json | 1 + packages/twenty-shared/project.json | 18 + .../twenty-shared/src/individual-entry.ts | 11 + packages/twenty-shared/tsconfig.json | 6 +- packages/twenty-shared/tsconfig.lib.json | 3 +- .../twenty-shared/vite.config.individual.ts | 66 +++ packages/twenty-shared/vite.config.ts | 1 + packages/twenty-ui/package.json | 6 +- packages/twenty-ui/project.json | 18 + .../src/display/status/components/Status.tsx | 10 +- packages/twenty-ui/src/individual-entry.ts | 16 + packages/twenty-ui/src/theme/index.ts | 1 + .../src/theme/provider/ThemeProvider.tsx | 4 +- packages/twenty-ui/vite.config.individual.ts | 85 +++ packages/twenty-ui/vite.config.ts | 4 +- yarn.lock | 8 + 96 files changed, 2836 insertions(+), 2419 deletions(-) delete mode 100644 packages/twenty-sdk/scripts/front-component-stories/utils/create-front-component-build-options.ts create mode 100644 packages/twenty-sdk/scripts/remote-dom/generators/constants.ts delete mode 100644 packages/twenty-sdk/scripts/remote-dom/generators/constants/CustomElementNames.ts delete mode 100644 packages/twenty-sdk/scripts/remote-dom/generators/constants/InternalElementClasses.ts delete mode 100644 packages/twenty-sdk/scripts/remote-dom/generators/constants/OutputFiles.ts delete mode 100644 packages/twenty-sdk/scripts/remote-dom/generators/constants/TypeNames.ts delete mode 100644 packages/twenty-sdk/scripts/remote-dom/generators/constants/index.ts delete mode 100644 packages/twenty-sdk/scripts/remote-dom/generators/utils/add-exported-type.ts delete mode 100644 packages/twenty-sdk/scripts/remote-dom/generators/utils/add-file-header.ts delete mode 100644 packages/twenty-sdk/scripts/remote-dom/generators/utils/add-statement.ts delete mode 100644 packages/twenty-sdk/scripts/remote-dom/generators/utils/extract-html-tag.ts delete mode 100644 packages/twenty-sdk/scripts/remote-dom/generators/utils/generate-properties-config.ts delete mode 100644 packages/twenty-sdk/scripts/remote-dom/generators/utils/generate-properties-type.ts delete mode 100644 packages/twenty-sdk/scripts/remote-dom/generators/utils/generated-file-header.ts delete mode 100644 packages/twenty-sdk/scripts/remote-dom/generators/utils/schema-type-to-ts.ts delete mode 100644 packages/twenty-sdk/scripts/remote-dom/utils/logger.ts delete mode 100644 packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/__tests__/jsx-transform-to-remote-dom-worker-format-plugin.spec.ts delete mode 100644 packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/__tests__/react-globals-plugin.spec.ts create mode 100644 packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/jsx-runtime-remote-wrapper-plugin.ts create mode 100644 packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/preact-alias-plugin.ts delete mode 100644 packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/react-globals-plugin.ts delete mode 100644 packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/twenty-sdk-globals-plugin.ts delete mode 100644 packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/twenty-sdk-ui-globals-plugin.ts delete mode 100644 packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/twenty-shared-globals-plugin.ts delete mode 100644 packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/types/ParsedImportSpecifier.ts delete mode 100644 packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/utils/collect-named-imports.ts delete mode 100644 packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/utils/create-globals-plugin.ts delete mode 100644 packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/utils/extract-names-from-import-specifier.ts delete mode 100644 packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/utils/replace-html-tags-with-remote-components.ts create mode 100644 packages/twenty-sdk/src/front-component-renderer/__stories__/BundleSizes.stories.tsx create mode 100644 packages/twenty-sdk/src/front-component-renderer/__stories__/UILibraries.stories.tsx create mode 100644 packages/twenty-sdk/src/front-component-renderer/__stories__/example-sources/sdk-context-example.front-component.tsx create mode 100644 packages/twenty-sdk/src/front-component-renderer/host/components/RemoteStyleRenderer.tsx delete mode 100644 packages/twenty-sdk/src/front-component-renderer/host/generated/host-index.ts create mode 100644 packages/twenty-sdk/src/front-component-renderer/host/utils/createHtmlHostWrapper.ts create mode 100644 packages/twenty-sdk/src/front-component-renderer/polyfills/MockCSSStyleSheet.ts create mode 100644 packages/twenty-sdk/src/front-component-renderer/polyfills/installStyleBridge.ts delete mode 100644 packages/twenty-sdk/src/front-component-renderer/remote/generated/remote-index.ts delete mode 100644 packages/twenty-sdk/src/front-component-renderer/remote/mock/mock-front-component.ts create mode 100644 packages/twenty-sdk/src/front-component-renderer/remote/utils/installStylePropertyOnRemoteElements.ts create mode 100644 packages/twenty-sdk/src/front-component-renderer/remote/utils/patchRemoteElementSetAttribute.ts create mode 100644 packages/twenty-sdk/vite.config.sdk.ts create mode 100644 packages/twenty-shared/src/individual-entry.ts create mode 100644 packages/twenty-shared/vite.config.individual.ts create mode 100644 packages/twenty-ui/src/individual-entry.ts create mode 100644 packages/twenty-ui/vite.config.individual.ts diff --git a/packages/twenty-sdk/.gitignore b/packages/twenty-sdk/.gitignore index 34170380ee..3d5681e1b6 100644 --- a/packages/twenty-sdk/.gitignore +++ b/packages/twenty-sdk/.gitignore @@ -2,3 +2,4 @@ node_modules .twenty storybook-static src/front-component-renderer/__stories__/example-sources-built +src/front-component-renderer/__stories__/example-sources-built-preact diff --git a/packages/twenty-sdk/.storybook/main.ts b/packages/twenty-sdk/.storybook/main.ts index b8425730f7..321be7b79b 100644 --- a/packages/twenty-sdk/.storybook/main.ts +++ b/packages/twenty-sdk/.storybook/main.ts @@ -28,6 +28,10 @@ const config: StorybookConfig = { from: '../src/front-component-renderer/__stories__/example-sources-built', to: '/built', }, + { + from: '../src/front-component-renderer/__stories__/example-sources-built-preact', + to: '/built-preact', + }, ], viteFinal: async (viteConfig) => { diff --git a/packages/twenty-sdk/package.json b/packages/twenty-sdk/package.json index 6ad2fbec49..123e077ca1 100644 --- a/packages/twenty-sdk/package.json +++ b/packages/twenty-sdk/package.json @@ -64,6 +64,7 @@ "inquirer": "^10.0.0", "jsonc-parser": "^3.2.0", "lodash.camelcase": "^4.3.0", + "preact": "^10.28.3", "react": "^18.2.0", "react-dom": "^18.2.0", "typescript": "^5.9.2", diff --git a/packages/twenty-sdk/project.json b/packages/twenty-sdk/project.json index 26882e7d43..37fa59e766 100644 --- a/packages/twenty-sdk/project.json +++ b/packages/twenty-sdk/project.json @@ -116,6 +116,23 @@ } ] }, + "build:sdk": { + "executor": "nx:run-commands", + "cache": true, + "dependsOn": [ + "^build" + ], + "inputs": [ + "{projectRoot}/src/sdk/**/*" + ], + "outputs": [ + "{projectRoot}/dist/sdk" + ], + "options": { + "cwd": "{projectRoot}", + "command": "npx vite build -c vite.config.sdk.ts" + } + }, "generate-remote-dom-elements": { "executor": "nx:run-commands", "cache": true, @@ -146,7 +163,16 @@ "executor": "nx:run-commands", "cache": true, "dependsOn": [ - "generate-remote-dom-elements" + "generate-remote-dom-elements", + "build:sdk", + { + "target": "build:individual", + "projects": "twenty-ui" + }, + { + "target": "build:individual", + "projects": "twenty-shared" + } ], "inputs": [ "{projectRoot}/scripts/front-component-stories/**/*", diff --git a/packages/twenty-sdk/scripts/front-component-stories/build-source-examples.ts b/packages/twenty-sdk/scripts/front-component-stories/build-source-examples.ts index fd425cb87f..54fa2522da 100644 --- a/packages/twenty-sdk/scripts/front-component-stories/build-source-examples.ts +++ b/packages/twenty-sdk/scripts/front-component-stories/build-source-examples.ts @@ -3,12 +3,67 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { createFrontComponentBuildOptions } from './utils/create-front-component-build-options'; +import { getFrontComponentBuildPlugins } from '../../src/cli/utilities/build/common/front-component-build/utils/get-front-component-build-plugins'; const dirname = path.dirname(fileURLToPath(import.meta.url)); -const exampleSourcesDir = path.resolve(dirname, '../../src/front-component-renderer/__stories__/example-sources'); -const exampleSourcesBuiltDir = path.resolve(dirname, '../../src/front-component-renderer/__stories__/example-sources-built'); -const sdkRoot = path.resolve(dirname, '../../../..'); +const exampleSourcesDir = path.resolve( + dirname, + '../../src/front-component-renderer/__stories__/example-sources', +); +const exampleSourcesBuiltDir = path.resolve( + dirname, + '../../src/front-component-renderer/__stories__/example-sources-built', +); +const exampleSourcesBuiltPreactDir = path.resolve( + dirname, + '../../src/front-component-renderer/__stories__/example-sources-built-preact', +); + +const rootNodeModules = path.resolve(dirname, '../../../../node_modules'); + +const twentyUiIndividualIndex = path.resolve( + dirname, + '../../../twenty-ui/dist/individual/individual-entry.js', +); + +const sdkIndividualIndex = path.resolve( + dirname, + '../../dist/sdk/index.js', +); + +const twentySharedIndividualDir = path.resolve( + dirname, + '../../../twenty-shared/dist/individual', +); + +const TWENTY_SHARED_SUBMODULES = [ + 'ai', + 'application', + 'constants', + 'database-events', + 'metadata', + 'testing', + 'translations', + 'types', + 'utils', + 'workflow', + 'workspace', +]; + +const twentySharedAliases = Object.fromEntries( + TWENTY_SHARED_SUBMODULES.map((submodule) => [ + `twenty-shared/${submodule}`, + path.join(twentySharedIndividualDir, submodule, 'index.js'), + ]), +); + +const storyAlias = { + react: path.join(rootNodeModules, 'react'), + 'react-dom': path.join(rootNodeModules, 'react-dom'), + '@/sdk': sdkIndividualIndex, + 'twenty-sdk/ui': twentyUiIndividualIndex, + ...twentySharedAliases, +}; const STORY_COMPONENTS = [ 'static.front-component', @@ -21,35 +76,101 @@ const STORY_COMPONENTS = [ 'shadcn-example.front-component', 'mui-example.front-component', 'twenty-ui-example.front-component', + 'sdk-context-example.front-component', ]; -export const buildSourceExamples = async (): Promise => { - fs.mkdirSync(exampleSourcesBuiltDir, { recursive: true }); - +const resolveEntryPoints = (): Record => { const entryPoints: Record = {}; for (const name of STORY_COMPONENTS) { const filePath = path.join(exampleSourcesDir, `${name}.tsx`); + if (!fs.existsSync(filePath)) { throw new Error( `Story component source file not found: ${filePath}\n` + `Ensure the file exists in ${exampleSourcesDir} and the name in STORY_COMPONENTS is correct.`, ); } + entryPoints[name] = filePath; } - const buildOptions = createFrontComponentBuildOptions({ - entryPoints, - outdir: exampleSourcesBuiltDir, - tsconfigPath: path.join(dirname, '../../tsconfig.json'), + return entryPoints; +}; + +type BundleSizeEntry = { + name: string; + reactBytes: number; + preactBytes: number; +}; + +const collectBundleSizes = (): BundleSizeEntry[] => + STORY_COMPONENTS.map((name) => { + const reactFile = path.join(exampleSourcesBuiltDir, `${name}.mjs`); + const preactFile = path.join( + exampleSourcesBuiltPreactDir, + `${name}.mjs`, + ); + + return { + name, + reactBytes: fs.existsSync(reactFile) + ? fs.statSync(reactFile).size + : 0, + preactBytes: fs.existsSync(preactFile) + ? fs.statSync(preactFile).size + : 0, + }; }); - await esbuild.build(buildOptions); +const buildSourceExamples = async (): Promise => { + const entryPoints = resolveEntryPoints(); + const tsconfigPath = path.join(dirname, '../../tsconfig.json'); + + const commonOptions: esbuild.BuildOptions = { + entryPoints, + bundle: true, + splitting: false, + format: 'esm', + outExtension: { '.js': '.mjs' }, + tsconfig: tsconfigPath, + jsx: 'automatic', + sourcemap: true, + metafile: true, + logLevel: 'silent', + minify: true, + alias: storyAlias, + }; + + fs.mkdirSync(exampleSourcesBuiltDir, { recursive: true }); + + await esbuild.build({ + ...commonOptions, + outdir: exampleSourcesBuiltDir, + plugins: getFrontComponentBuildPlugins(), + }); console.log( - `Built ${STORY_COMPONENTS.length} story components to ${exampleSourcesBuiltDir}`, + `Built ${STORY_COMPONENTS.length} React story components to ${exampleSourcesBuiltDir}`, ); + + fs.mkdirSync(exampleSourcesBuiltPreactDir, { recursive: true }); + + await esbuild.build({ + ...commonOptions, + outdir: exampleSourcesBuiltPreactDir, + plugins: getFrontComponentBuildPlugins({ usePreact: true }), + }); + + console.log( + `Built ${STORY_COMPONENTS.length} Preact story components to ${exampleSourcesBuiltPreactDir}`, + ); + + const sizes = collectBundleSizes(); + const manifestPath = path.join(exampleSourcesBuiltDir, 'bundle-sizes.json'); + + fs.writeFileSync(manifestPath, JSON.stringify(sizes, null, 2)); + console.log(`Wrote bundle size manifest to ${manifestPath}`); }; buildSourceExamples().catch((error) => { diff --git a/packages/twenty-sdk/scripts/front-component-stories/utils/create-front-component-build-options.ts b/packages/twenty-sdk/scripts/front-component-stories/utils/create-front-component-build-options.ts deleted file mode 100644 index dc809d5d90..0000000000 --- a/packages/twenty-sdk/scripts/front-component-stories/utils/create-front-component-build-options.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type * as esbuild from 'esbuild'; - -import { FRONT_COMPONENT_EXTERNAL_MODULES } from '../../../src/cli/utilities/build/common/front-component-build/constants/front-component-external-modules'; -import { getFrontComponentBuildPlugins } from '../../../src/cli/utilities/build/common/front-component-build/utils/get-front-component-build-plugins'; - -export type FrontComponentBuildOptions = { - entryPoints: esbuild.BuildOptions['entryPoints']; - outdir: string; - tsconfigPath?: string; - externalModules?: string[]; - logLevel?: esbuild.LogLevel; - platform?: esbuild.Platform; - minify?: boolean; - metafile?: boolean; - sourcemap?: boolean; -}; - -export const createFrontComponentBuildOptions = ({ - entryPoints, - outdir, - tsconfigPath, - externalModules = FRONT_COMPONENT_EXTERNAL_MODULES, - logLevel = 'silent', - platform, - minify, - metafile = true, - sourcemap = true, -}: FrontComponentBuildOptions): esbuild.BuildOptions => { - return { - entryPoints, - bundle: true, - splitting: false, - format: 'esm', - platform, - outdir, - outExtension: { '.js': '.mjs' }, - external: externalModules, - tsconfig: tsconfigPath, - jsx: 'automatic', - sourcemap, - metafile, - logLevel, - minify, - plugins: getFrontComponentBuildPlugins(), - }; -}; diff --git a/packages/twenty-sdk/scripts/remote-dom/generate-remote-dom-elements.ts b/packages/twenty-sdk/scripts/remote-dom/generate-remote-dom-elements.ts index 3907fb163b..08cbffda5e 100644 --- a/packages/twenty-sdk/scripts/remote-dom/generate-remote-dom-elements.ts +++ b/packages/twenty-sdk/scripts/remote-dom/generate-remote-dom-elements.ts @@ -1,90 +1,71 @@ import * as prettier from '@prettier/sync'; import * as fs from 'fs'; import * as path from 'path'; -import { fileURLToPath } from 'url'; import { IndentationText, Project, QuoteKind } from 'ts-morph'; +import { fileURLToPath } from 'url'; import { ALLOWED_HTML_ELEMENTS } from '../../src/sdk/front-component-api/constants/AllowedHtmlElements'; import { COMMON_HTML_EVENTS } from '../../src/sdk/front-component-api/constants/CommonHtmlEvents'; -import { EVENT_TO_REACT } from '../../src/sdk/front-component-api/constants/EventToReact'; import { HTML_COMMON_PROPERTIES } from '../../src/sdk/front-component-api/constants/HtmlCommonProperties'; import { type ComponentSchema, - extractHtmlTag, generateHostRegistry, generateRemoteComponents, generateRemoteElements, HtmlElementConfigArrayZ, OUTPUT_FILES, } from './generators'; -import { - logCount, - logDetail, - logEmpty, - logError, - logFileWritten, - logGroupLabel, - logSectionHeader, - logSeparator, - logSuccess, - logTitle, - setVerbose, -} from './utils/logger'; - -const parseVerboseFlag = (): boolean => { - return process.argv.includes('--verbose'); -}; const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); const PACKAGE_PATH = path.resolve(SCRIPT_DIR, '../..'); -const FRONT_COMPONENT_PATH = path.join(PACKAGE_PATH, 'src/front-component-renderer'); +const FRONT_COMPONENT_PATH = path.join( + PACKAGE_PATH, + 'src/front-component-renderer', +); const HOST_GENERATED_DIR = path.join(FRONT_COMPONENT_PATH, 'host/generated'); const REMOTE_GENERATED_DIR = path.join( FRONT_COMPONENT_PATH, 'remote/generated', ); -const formatZodError = (error: { - issues: { path: PropertyKey[]; message: string }[]; -}): string => { - return error.issues - .map((issue) => ` - ${issue.path.join('.')}: ${issue.message}`) - .join('\n'); -}; +const extractHtmlTag = (tag: string): string => tag.slice(5); const getHtmlElementSchemas = (): ComponentSchema[] => { const result = HtmlElementConfigArrayZ.safeParse(ALLOWED_HTML_ELEMENTS); if (!result.success) { - throw new Error( - `Invalid HTML element configuration:\n${formatZodError(result.error)}`, - ); + const details = result.error.issues + .map((issue) => ` - ${issue.path.join('.')}: ${issue.message}`) + .join('\n'); + throw new Error(`Invalid HTML element configuration:\n${details}`); } return result.data.map((element) => ({ name: element.name, - tagName: element.name, customElementName: element.tag, properties: { ...HTML_COMMON_PROPERTIES, ...element.properties, }, events: COMMON_HTML_EVENTS, - isHtmlElement: true, htmlTag: extractHtmlTag(element.tag), })); }; -const createProject = (): Project => { - return new Project({ - manipulationSettings: { - indentationText: IndentationText.TwoSpaces, - quoteKind: QuoteKind.Single, - useTrailingCommas: true, +const getUtilityComponentSchemas = (): ComponentSchema[] => [ + { + name: 'RemoteStyle', + customElementName: 'remote-style', + properties: { + cssText: { type: 'string', optional: true }, + styleKey: { type: 'string', optional: true }, }, - }); -}; + events: [], + customHostRenderer: 'RemoteStyleRenderer', + customHostRendererPath: '../components/RemoteStyleRenderer', + }, +]; const writeGeneratedFile = ( dir: string, @@ -100,67 +81,31 @@ const writeGeneratedFile = ( endOfLine: 'lf', }); fs.writeFileSync(filePath, formattedContent, 'utf-8'); - logFileWritten(filePath); -}; - -const ensureDirectoriesExist = (): void => { - if (!fs.existsSync(HOST_GENERATED_DIR)) { - fs.mkdirSync(HOST_GENERATED_DIR, { recursive: true }); - } - if (!fs.existsSync(REMOTE_GENERATED_DIR)) { - fs.mkdirSync(REMOTE_GENERATED_DIR, { recursive: true }); - } }; const main = (): void => { - const verbose = parseVerboseFlag(); - setVerbose(verbose); + const htmlElements = getHtmlElementSchemas(); + const utilityComponents = getUtilityComponentSchemas(); + const allComponents = [...htmlElements, ...utilityComponents]; - logTitle('Remote DOM Elements Generator'); + fs.mkdirSync(HOST_GENERATED_DIR, { recursive: true }); + fs.mkdirSync(REMOTE_GENERATED_DIR, { recursive: true }); - let htmlElements: ComponentSchema[]; + const project = new Project({ + manipulationSettings: { + indentationText: IndentationText.TwoSpaces, + quoteKind: QuoteKind.Single, + useTrailingCommas: true, + }, + }); - try { - htmlElements = getHtmlElementSchemas(); - } catch (error) { - logError('Validation failed:', error); - process.exit(1); - } - - logSeparator(); - logSectionHeader('Summary'); - - logCount('HTML Elements', htmlElements.length, 'element', 'elements'); - logDetail( - `Tags: ${htmlElements.map((element) => element.htmlTag).join(', ')}`, - ); - logDetail(`Events: ${COMMON_HTML_EVENTS.length} common events per element`); - - const allComponents = [...htmlElements]; - - ensureDirectoriesExist(); - - const project = createProject(); - - logSeparator(); - logSectionHeader('Writing Files'); - - logGroupLabel('Host'); - - const hostRegistry = generateHostRegistry( - project, - allComponents, - EVENT_TO_REACT, - ); + const hostRegistry = generateHostRegistry(project, allComponents); writeGeneratedFile( HOST_GENERATED_DIR, OUTPUT_FILES.HOST_REGISTRY, hostRegistry.getFullText(), ); - logEmpty(); - logGroupLabel('Remote'); - const remoteElements = generateRemoteElements( project, allComponents, @@ -179,12 +124,6 @@ const main = (): void => { OUTPUT_FILES.REMOTE_COMPONENTS, remoteComponents.getFullText(), ); - - logSeparator(); - logSuccess('Done!', 'All generated files created.'); - logDetail(`Host: ${HOST_GENERATED_DIR}`); - logDetail(`Remote: ${REMOTE_GENERATED_DIR}`); - logEmpty(); }; main(); diff --git a/packages/twenty-sdk/scripts/remote-dom/generators/constants.ts b/packages/twenty-sdk/scripts/remote-dom/generators/constants.ts new file mode 100644 index 0000000000..f61c8312ce --- /dev/null +++ b/packages/twenty-sdk/scripts/remote-dom/generators/constants.ts @@ -0,0 +1,23 @@ +export const CUSTOM_ELEMENT_NAMES = { + ROOT: 'remote-root', + FRAGMENT: 'remote-fragment', +} as const; + +export const INTERNAL_ELEMENT_CLASSES = { + ROOT: 'RemoteRootElement', + FRAGMENT: 'RemoteFragmentElement', +} as const; + +export const OUTPUT_FILES = { + REMOTE_ELEMENTS: 'remote-elements.ts', + REMOTE_COMPONENTS: 'remote-components.ts', + HOST_REGISTRY: 'host-component-registry.ts', +} as const; + +export const TYPE_NAMES = { + COMMON_PROPERTIES: 'HtmlCommonProperties', + COMMON_EVENTS: 'HtmlCommonEvents', + COMMON_PROPERTIES_CONFIG: 'HTML_COMMON_PROPERTIES_CONFIG', + COMMON_EVENTS_ARRAY: 'HTML_COMMON_EVENTS_ARRAY', + EMPTY_RECORD: 'Record', +} as const; diff --git a/packages/twenty-sdk/scripts/remote-dom/generators/constants/CustomElementNames.ts b/packages/twenty-sdk/scripts/remote-dom/generators/constants/CustomElementNames.ts deleted file mode 100644 index 330263275b..0000000000 --- a/packages/twenty-sdk/scripts/remote-dom/generators/constants/CustomElementNames.ts +++ /dev/null @@ -1,4 +0,0 @@ -export const CUSTOM_ELEMENT_NAMES = { - ROOT: 'remote-root', - FRAGMENT: 'remote-fragment', -} as const; diff --git a/packages/twenty-sdk/scripts/remote-dom/generators/constants/InternalElementClasses.ts b/packages/twenty-sdk/scripts/remote-dom/generators/constants/InternalElementClasses.ts deleted file mode 100644 index 01b8455b50..0000000000 --- a/packages/twenty-sdk/scripts/remote-dom/generators/constants/InternalElementClasses.ts +++ /dev/null @@ -1,4 +0,0 @@ -export const INTERNAL_ELEMENT_CLASSES = { - ROOT: 'RemoteRootElement', - FRAGMENT: 'RemoteFragmentElement', -} as const; diff --git a/packages/twenty-sdk/scripts/remote-dom/generators/constants/OutputFiles.ts b/packages/twenty-sdk/scripts/remote-dom/generators/constants/OutputFiles.ts deleted file mode 100644 index f8101944dd..0000000000 --- a/packages/twenty-sdk/scripts/remote-dom/generators/constants/OutputFiles.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const OUTPUT_FILES = { - REMOTE_ELEMENTS: 'remote-elements.ts', - REMOTE_COMPONENTS: 'remote-components.ts', - HOST_REGISTRY: 'host-component-registry.ts', -} as const; diff --git a/packages/twenty-sdk/scripts/remote-dom/generators/constants/TypeNames.ts b/packages/twenty-sdk/scripts/remote-dom/generators/constants/TypeNames.ts deleted file mode 100644 index d6b8a80a5f..0000000000 --- a/packages/twenty-sdk/scripts/remote-dom/generators/constants/TypeNames.ts +++ /dev/null @@ -1,7 +0,0 @@ -export const TYPE_NAMES = { - COMMON_PROPERTIES: 'HtmlCommonProperties', - COMMON_EVENTS: 'HtmlCommonEvents', - COMMON_PROPERTIES_CONFIG: 'HTML_COMMON_PROPERTIES_CONFIG', - COMMON_EVENTS_ARRAY: 'HTML_COMMON_EVENTS_ARRAY', - EMPTY_RECORD: 'Record', -} as const; diff --git a/packages/twenty-sdk/scripts/remote-dom/generators/constants/index.ts b/packages/twenty-sdk/scripts/remote-dom/generators/constants/index.ts deleted file mode 100644 index 3841110c44..0000000000 --- a/packages/twenty-sdk/scripts/remote-dom/generators/constants/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { CUSTOM_ELEMENT_NAMES } from './CustomElementNames'; -export { INTERNAL_ELEMENT_CLASSES } from './InternalElementClasses'; -export { OUTPUT_FILES } from './OutputFiles'; -export { TYPE_NAMES } from './TypeNames'; diff --git a/packages/twenty-sdk/scripts/remote-dom/generators/host-registry.generator.ts b/packages/twenty-sdk/scripts/remote-dom/generators/host-registry.generator.ts index d100a7813e..06caf77fd2 100644 --- a/packages/twenty-sdk/scripts/remote-dom/generators/host-registry.generator.ts +++ b/packages/twenty-sdk/scripts/remote-dom/generators/host-registry.generator.ts @@ -3,165 +3,40 @@ import type { Project, SourceFile } from 'ts-morph'; import { isDefined } from 'twenty-shared/utils'; import { CUSTOM_ELEMENT_NAMES } from './constants'; import { type ComponentSchema } from './schemas'; -import { addFileHeader, addStatement } from './utils'; -const generateRuntimeUtilities = ( - eventToReactMapping: Record, -): string => { - const eventMapEntries = Object.entries(eventToReactMapping) - .map(([domEvent, reactProp]) => ` on${domEvent}: '${reactProp}',`) - .join('\n'); +const getCustomRendererImports = ( + components: ComponentSchema[], +): Map => { + const importsByPath = new Map(); - return `const INTERNAL_PROPS = new Set(['element', 'receiver', 'components']); + for (const component of components) { + if ( + isDefined(component.customHostRenderer) && + isDefined(component.customHostRendererPath) + ) { + const existing = + importsByPath.get(component.customHostRendererPath) ?? []; -const EVENT_NAME_MAP: Record = { -${eventMapEntries} -}; - -const parseStyle = (styleString: string | undefined): React.CSSProperties | undefined => { - if (!styleString || typeof styleString !== 'string') { - return styleString as React.CSSProperties | undefined; - } - - const style: Record = {}; - const declarations = styleString.split(';').filter(Boolean); - - for (const declaration of declarations) { - const colonIndex = declaration.indexOf(':'); - if (colonIndex === -1) continue; - - const property = declaration.slice(0, colonIndex).trim(); - const value = declaration.slice(colonIndex + 1).trim(); - - const camelProperty = property.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase()); - style[camelProperty] = value; - } - - return style; -}; - -const serializeEvent = (event: unknown): SerializedEventData => { - if (!event || typeof event !== 'object') { - return { type: 'unknown' }; - } - - const domEvent = event as Record; - const serialized: SerializedEventData = { - type: typeof domEvent.type === 'string' ? domEvent.type : 'unknown', - }; - - if ('altKey' in domEvent) serialized.altKey = domEvent.altKey as boolean; - if ('ctrlKey' in domEvent) serialized.ctrlKey = domEvent.ctrlKey as boolean; - if ('metaKey' in domEvent) serialized.metaKey = domEvent.metaKey as boolean; - if ('shiftKey' in domEvent) serialized.shiftKey = domEvent.shiftKey as boolean; - - if ('clientX' in domEvent) serialized.clientX = domEvent.clientX as number; - if ('clientY' in domEvent) serialized.clientY = domEvent.clientY as number; - if ('pageX' in domEvent) serialized.pageX = domEvent.pageX as number; - if ('pageY' in domEvent) serialized.pageY = domEvent.pageY as number; - if ('screenX' in domEvent) serialized.screenX = domEvent.screenX as number; - if ('screenY' in domEvent) serialized.screenY = domEvent.screenY as number; - if ('button' in domEvent) serialized.button = domEvent.button as number; - if ('buttons' in domEvent) serialized.buttons = domEvent.buttons as number; - - if ('key' in domEvent) serialized.key = domEvent.key as string; - if ('code' in domEvent) serialized.code = domEvent.code as string; - if ('repeat' in domEvent) serialized.repeat = domEvent.repeat as boolean; - - if ('deltaX' in domEvent) serialized.deltaX = domEvent.deltaX as number; - if ('deltaY' in domEvent) serialized.deltaY = domEvent.deltaY as number; - if ('deltaZ' in domEvent) serialized.deltaZ = domEvent.deltaZ as number; - if ('deltaMode' in domEvent) serialized.deltaMode = domEvent.deltaMode as number; - - const target = domEvent.target as Record | undefined; - if (target && typeof target === 'object') { - if ('value' in target && typeof target.value === 'string') { - serialized.value = target.value; - } - if ('checked' in target && typeof target.checked === 'boolean') { - serialized.checked = target.checked; - } - if ('scrollTop' in target && typeof target.scrollTop === 'number') { - serialized.scrollTop = target.scrollTop; - } - if ('scrollLeft' in target && typeof target.scrollLeft === 'number') { - serialized.scrollLeft = target.scrollLeft; - } - } - - return serialized; -}; - -const wrapEventHandler = (handler: (detail: SerializedEventData) => void) => { - return (event: unknown) => { - handler(serializeEvent(event)); - }; -}; - -const filterProps = (props: T): T => { - const filtered: Record = {}; - for (const [key, value] of Object.entries(props)) { - if (INTERNAL_PROPS.has(key) || value === undefined) continue; - - if (key === 'style') { - filtered.style = parseStyle(value as string | undefined); - } else { - const normalizedKey = EVENT_NAME_MAP[key.toLowerCase()] || key; - if (normalizedKey.startsWith('on') && typeof value === 'function') { - filtered[normalizedKey] = wrapEventHandler(value as (detail: SerializedEventData) => void); - } else { - filtered[normalizedKey] = value; + if (!existing.includes(component.customHostRenderer)) { + existing.push(component.customHostRenderer); } + + importsByPath.set(component.customHostRendererPath, existing); } } - return filtered as T; -};`; + + return importsByPath; }; -// HTML void elements cannot have children -// https://developer.mozilla.org/en-US/docs/Glossary/Void_element -const VOID_ELEMENTS = new Set([ - 'input', - 'br', - 'hr', - 'img', - 'area', - 'base', - 'col', - 'embed', - 'link', - 'meta', - 'source', - 'track', - 'wbr', -]); - -const generateWrapperComponent = (component: ComponentSchema): string => { - const isVoidElement = VOID_ELEMENTS.has(component.htmlTag ?? ''); - - if (isVoidElement) { - return `const ${component.name}Wrapper = ({ children: _children, ...props }: { children?: React.ReactNode } & Record) => { - return React.createElement('${component.htmlTag}', filterProps(props)); -};`; - } - - if (component.isHtmlElement) { - return `const ${component.name}Wrapper = ({ children, ...props }: { children?: React.ReactNode } & Record) => { - return React.createElement('${component.htmlTag}', filterProps(props), children); -};`; - } - - return `const ${component.name}Wrapper = ({ children, ...props }: { children?: React.ReactNode } & Record) => { - return React.createElement(${component.componentImport}, filterProps(props), children); -};`; -}; - -const generateRegistryMap = (components: ComponentSchema[]): string => { +const generateRegistryEntries = (components: ComponentSchema[]): string => { const entries = components - .map( - (component) => - ` ['${component.customElementName}', createRemoteComponentRenderer(${component.name}Wrapper)],`, - ) + .map((component) => { + if (isDefined(component.customHostRenderer)) { + return ` ['${component.customElementName}', createRemoteComponentRenderer(${component.customHostRenderer})],`; + } + + return ` ['${component.customElementName}', createRemoteComponentRenderer(createHtmlHostWrapper('${component.htmlTag}'))],`; + }) .join('\n'); return `type ComponentRegistryValue = @@ -177,7 +52,6 @@ ${entries} export const generateHostRegistry = ( project: Project, components: ComponentSchema[], - eventToReactMapping: Record, ): SourceFile => { const sourceFile = project.createSourceFile( 'host-component-registry.ts', @@ -185,43 +59,26 @@ export const generateHostRegistry = ( { overwrite: true }, ); - sourceFile.addImportDeclaration({ - moduleSpecifier: 'react', - defaultImport: 'React', - }); - sourceFile.addImportDeclaration({ moduleSpecifier: '@remote-dom/react/host', namedImports: ['RemoteFragmentRenderer', 'createRemoteComponentRenderer'], }); sourceFile.addImportDeclaration({ - moduleSpecifier: '../../../sdk/front-component-api/constants/SerializedEventData', - namedImports: [{ name: 'SerializedEventData', isTypeOnly: true }], + moduleSpecifier: '../utils/createHtmlHostWrapper', + namedImports: ['createHtmlHostWrapper'], }); - for (const component of components) { - if ( - !component.isHtmlElement && - isDefined(component.componentPath) && - isDefined(component.componentImport) - ) { - sourceFile.addImportDeclaration({ - moduleSpecifier: component.componentPath, - namedImports: [component.componentImport], - }); - } + const customRendererImports = getCustomRendererImports(components); + + for (const [modulePath, namedImports] of customRendererImports) { + sourceFile.addImportDeclaration({ + moduleSpecifier: modulePath, + namedImports, + }); } - addStatement(sourceFile, generateRuntimeUtilities(eventToReactMapping)); - - for (const component of components) { - addStatement(sourceFile, generateWrapperComponent(component)); - } - - addStatement(sourceFile, generateRegistryMap(components)); - - addFileHeader(sourceFile); + sourceFile.addStatements(generateRegistryEntries(components)); return sourceFile; }; diff --git a/packages/twenty-sdk/scripts/remote-dom/generators/remote-components.generator.ts b/packages/twenty-sdk/scripts/remote-dom/generators/remote-components.generator.ts index ea56cf52d3..a97d004ff3 100644 --- a/packages/twenty-sdk/scripts/remote-dom/generators/remote-components.generator.ts +++ b/packages/twenty-sdk/scripts/remote-dom/generators/remote-components.generator.ts @@ -1,15 +1,14 @@ import type { Project, SourceFile } from 'ts-morph'; -import { EVENT_TO_REACT } from '@/sdk/front-component-api/constants/EventToReact'; +import { EVENT_TO_REACT } from '../../../src/sdk/front-component-api/constants/EventToReact'; import { type ComponentSchema } from './schemas'; -import { addExportedConst, addFileHeader } from './utils'; +import { addExportedConst } from './utils'; const generateComponentDefinition = ( sourceFile: SourceFile, component: ComponentSchema, ): void => { const hasEvents = component.events.length > 0; - const componentExportName = component.tagName; let initializer: string; @@ -30,7 +29,7 @@ ${eventProps} initializer = `createRemoteComponent('${component.customElementName}', ${component.name}Element)`; } - addExportedConst(sourceFile, componentExportName, initializer); + addExportedConst(sourceFile, component.name, initializer); }; export const generateRemoteComponents = ( @@ -46,20 +45,14 @@ export const generateRemoteComponents = ( namedImports: ['createRemoteComponent'], }); - const elementImports = components.map( - (component) => `${component.name}Element`, - ); - sourceFile.addImportDeclaration({ moduleSpecifier: './remote-elements', - namedImports: elementImports, + namedImports: components.map((component) => `${component.name}Element`), }); for (const component of components) { generateComponentDefinition(sourceFile, component); } - addFileHeader(sourceFile); - return sourceFile; }; diff --git a/packages/twenty-sdk/scripts/remote-dom/generators/remote-elements.generator.ts b/packages/twenty-sdk/scripts/remote-dom/generators/remote-elements.generator.ts index 88ab4aba2f..6ea7e5eed0 100644 --- a/packages/twenty-sdk/scripts/remote-dom/generators/remote-elements.generator.ts +++ b/packages/twenty-sdk/scripts/remote-dom/generators/remote-elements.generator.ts @@ -5,29 +5,37 @@ import { VariableDeclarationKind, } from 'ts-morph'; +import { isDefined } from 'twenty-shared/utils'; import { CUSTOM_ELEMENT_NAMES, INTERNAL_ELEMENT_CLASSES, TYPE_NAMES, } from './constants'; import { type ComponentSchema, type PropertySchema } from './schemas'; -import { - addFileHeader, - schemaTypeToConstructor, - schemaTypeToTs, -} from './utils'; +import { schemaTypeToConstructor } from './utils'; -type ElementGenerationOptions = { - useSharedEvents: boolean; - useSharedPropertiesConfig: boolean; +const schemaTypeToTs = (type: PropertySchema['type']): string => type; + +const generatePropertyEntries = ( + properties: Record, +): string[] => + Object.entries(properties).map(([name, schema]) => { + const optional = schema.optional ? '?' : ''; + return `'${name}'${optional}: ${schemaTypeToTs(schema.type)}`; + }); + +const writePropertyEntries = ( + writer: CodeBlockWriter, + properties: Record, +): void => { + for (const [name, schema] of Object.entries(properties)) { + writer.writeLine( + `'${name}': { type: ${schemaTypeToConstructor(schema.type)} },`, + ); + } }; -type ComponentWithSpecificProps = { - component: ComponentSchema; - specificProperties: Record; -}; - -const getElementSpecificProperties = ( +const getSpecificProperties = ( component: ComponentSchema, commonPropertyNames: Set, ): Record => { @@ -40,16 +48,6 @@ const getElementSpecificProperties = ( return specific; }; -const generatePropertyEntries = ( - properties: Record, -): string[] => { - return Object.entries(properties).map(([name, schema]) => { - const tsType = schemaTypeToTs(schema.type); - const optional = schema.optional ? '?' : ''; - return `'${name}'${optional}: ${tsType}`; - }); -}; - const generateCommonPropertiesType = ( sourceFile: SourceFile, commonProperties: Record, @@ -123,8 +121,9 @@ const generateCommonPropertiesConfig = ( initializer: (writer) => { writer.block(() => { for (const [name, schema] of Object.entries(commonProperties)) { - const constructorType = schemaTypeToConstructor(schema.type); - writer.writeLine(`'${name}': { type: ${constructorType} },`); + writer.writeLine( + `'${name}': { type: ${schemaTypeToConstructor(schema.type)} },`, + ); } }); }, @@ -146,7 +145,7 @@ const generateElementPropertyType = ( isExported: true, name: `${component.name}Properties`, type: (writer) => { - if (component.isHtmlElement) { + if (isDefined(component.htmlTag)) { writer.write(`${TYPE_NAMES.COMMON_PROPERTIES} & `); } writer.block(() => { @@ -159,38 +158,27 @@ const generateElementPropertyType = ( } }; -const writePropertyEntries = ( - writer: CodeBlockWriter, - properties: Record, -): void => { - for (const [name, schema] of Object.entries(properties)) { - const constructorType = schemaTypeToConstructor(schema.type); - writer.writeLine(`'${name}': { type: ${constructorType} },`); - } -}; - const generateElementDefinition = ( sourceFile: SourceFile, component: ComponentSchema, specificProperties: Record, - options: ElementGenerationOptions, + useSharedEvents: boolean, + useSharedPropertiesConfig: boolean, ): void => { - const useSharedEvents = options.useSharedEvents && component.isHtmlElement; - const { useSharedPropertiesConfig } = options; + const isHtml = isDefined(component.htmlTag); + const useShared = useSharedEvents && isHtml; const hasEvents = component.events.length > 0; const hasSpecificProps = Object.keys(specificProperties).length > 0; const hasProps = Object.keys(component.properties).length > 0; const propsType = hasSpecificProps ? `${component.name}Properties` - : hasProps && component.isHtmlElement + : hasProps && isHtml ? TYPE_NAMES.COMMON_PROPERTIES : TYPE_NAMES.EMPTY_RECORD; - const slotsType = TYPE_NAMES.EMPTY_RECORD; - const eventsType = hasEvents - ? useSharedEvents + ? useShared ? TYPE_NAMES.COMMON_EVENTS : `{ ${component.events.map((event) => `${event}(event: RemoteEvent): void`).join('; ')} }` : TYPE_NAMES.EMPTY_RECORD; @@ -207,7 +195,7 @@ const generateElementDefinition = ( writer.indent(() => { writer.writeLine(`${propsType},`); writer.writeLine('Record,'); - writer.writeLine(`${slotsType},`); + writer.writeLine(`${TYPE_NAMES.EMPTY_RECORD},`); writer.write(eventsType); }); writer.newLine(); @@ -222,7 +210,7 @@ const generateElementDefinition = ( writer.write('('); writer.block(() => { if (hasProps) { - if (hasSpecificProps && component.isHtmlElement) { + if (hasSpecificProps && isHtml) { writer.write('properties: '); writer.block(() => { writer.writeLine( @@ -232,7 +220,7 @@ const generateElementDefinition = ( }); writer.write(','); writer.newLine(); - } else if (useSharedPropertiesConfig && component.isHtmlElement) { + } else if (useSharedPropertiesConfig && isHtml) { writer.write( `properties: ${TYPE_NAMES.COMMON_PROPERTIES_CONFIG},`, ); @@ -248,7 +236,7 @@ const generateElementDefinition = ( } if (hasEvents) { writer.write( - useSharedEvents + useShared ? `events: [...${TYPE_NAMES.COMMON_EVENTS_ARRAY}],` : `events: [${component.events.map((event) => `'${event}'`).join(', ')}],`, ); @@ -306,18 +294,6 @@ const generateTagNameMapDeclaration = ( }); }; -const prepareComponentsWithSpecificProps = ( - components: ComponentSchema[], - commonPropertyNames: Set, -): ComponentWithSpecificProps[] => { - return components.map((component) => ({ - component, - specificProperties: component.isHtmlElement - ? getElementSpecificProperties(component, commonPropertyNames) - : component.properties, - })); -}; - export const generateRemoteElements = ( project: Project, components: ComponentSchema[], @@ -331,11 +307,6 @@ export const generateRemoteElements = ( const useSharedEvents = commonEvents.length > 0; const useSharedPropertiesConfig = Object.keys(commonProperties).length > 0; - const options: ElementGenerationOptions = { - useSharedEvents, - useSharedPropertiesConfig, - }; - sourceFile.addImportDeclaration({ moduleSpecifier: '@remote-dom/core/elements', namedImports: [ @@ -347,15 +318,12 @@ export const generateRemoteElements = ( }); sourceFile.addImportDeclaration({ - moduleSpecifier: '../../../sdk/front-component-api/constants/SerializedEventData', + moduleSpecifier: + '../../../sdk/front-component-api/constants/SerializedEventData', namedImports: [{ name: 'SerializedEventData', isTypeOnly: true }], }); const commonPropertyNames = new Set(Object.keys(commonProperties)); - const componentsWithProps = prepareComponentsWithSpecificProps( - components, - commonPropertyNames, - ); generateCommonPropertiesType(sourceFile, commonProperties); @@ -367,13 +335,18 @@ export const generateRemoteElements = ( generateCommonPropertiesConfig(sourceFile, commonProperties); } - for (const { component, specificProperties } of componentsWithProps) { + for (const component of components) { + const specificProperties = isDefined(component.htmlTag) + ? getSpecificProperties(component, commonPropertyNames) + : component.properties; + generateElementPropertyType(sourceFile, component, specificProperties); generateElementDefinition( sourceFile, component, specificProperties, - options, + useSharedEvents, + useSharedPropertiesConfig, ); } @@ -385,7 +358,5 @@ export const generateRemoteElements = ( generateTagNameMapDeclaration(sourceFile, components); - addFileHeader(sourceFile); - return sourceFile; }; diff --git a/packages/twenty-sdk/scripts/remote-dom/generators/schemas.ts b/packages/twenty-sdk/scripts/remote-dom/generators/schemas.ts index 7f8e003d84..66f2dc8ff2 100644 --- a/packages/twenty-sdk/scripts/remote-dom/generators/schemas.ts +++ b/packages/twenty-sdk/scripts/remote-dom/generators/schemas.ts @@ -17,14 +17,12 @@ export const HtmlElementConfigArrayZ = z.array(HtmlElementConfigZ); export const ComponentSchemaZ = z.object({ name: z.string().min(1), - tagName: z.string().min(1), customElementName: z.string().min(1), properties: z.record(z.string(), PropertySchemaZ), events: z.array(z.string()).readonly(), - isHtmlElement: z.boolean(), htmlTag: z.string().optional(), - componentImport: z.string().optional(), - componentPath: z.string().optional(), + customHostRenderer: z.string().optional(), + customHostRendererPath: z.string().optional(), }); export type PropertySchema = z.infer; diff --git a/packages/twenty-sdk/scripts/remote-dom/generators/utils/add-exported-type.ts b/packages/twenty-sdk/scripts/remote-dom/generators/utils/add-exported-type.ts deleted file mode 100644 index b1f4f812e0..0000000000 --- a/packages/twenty-sdk/scripts/remote-dom/generators/utils/add-exported-type.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { type SourceFile } from 'ts-morph'; - -export const addExportedType = ( - sourceFile: SourceFile, - name: string, - type: string, -): void => { - sourceFile.addTypeAlias({ - isExported: true, - name, - type, - }); -}; diff --git a/packages/twenty-sdk/scripts/remote-dom/generators/utils/add-file-header.ts b/packages/twenty-sdk/scripts/remote-dom/generators/utils/add-file-header.ts deleted file mode 100644 index 98b1f8ecf2..0000000000 --- a/packages/twenty-sdk/scripts/remote-dom/generators/utils/add-file-header.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { type SourceFile } from 'ts-morph'; - -import { GENERATED_FILE_HEADER } from './generated-file-header'; - -export const addFileHeader = (sourceFile: SourceFile): void => { - sourceFile.insertText(0, GENERATED_FILE_HEADER + '\n\n'); -}; diff --git a/packages/twenty-sdk/scripts/remote-dom/generators/utils/add-statement.ts b/packages/twenty-sdk/scripts/remote-dom/generators/utils/add-statement.ts deleted file mode 100644 index 892cfb0d1e..0000000000 --- a/packages/twenty-sdk/scripts/remote-dom/generators/utils/add-statement.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { type SourceFile } from 'ts-morph'; - -export const addStatement = ( - sourceFile: SourceFile, - statement: string, -): void => { - sourceFile.addStatements(statement); -}; diff --git a/packages/twenty-sdk/scripts/remote-dom/generators/utils/extract-html-tag.ts b/packages/twenty-sdk/scripts/remote-dom/generators/utils/extract-html-tag.ts deleted file mode 100644 index dea4fd10c7..0000000000 --- a/packages/twenty-sdk/scripts/remote-dom/generators/utils/extract-html-tag.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const extractHtmlTag = (tag: string): string => { - return tag.startsWith('html-') ? tag.slice(5) : tag; -}; diff --git a/packages/twenty-sdk/scripts/remote-dom/generators/utils/generate-properties-config.ts b/packages/twenty-sdk/scripts/remote-dom/generators/utils/generate-properties-config.ts deleted file mode 100644 index ef9bf10f8a..0000000000 --- a/packages/twenty-sdk/scripts/remote-dom/generators/utils/generate-properties-config.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { type PropertySchema } from '../schemas'; -import { schemaTypeToConstructor } from './schema-type-to-constructor'; - -export const generatePropertiesConfig = ( - properties: Record, -): string => { - const entries = Object.entries(properties); - if (entries.length === 0) { - return '{}'; - } - - const props = entries - .map(([name, schema]) => { - const constructorType = schemaTypeToConstructor(schema.type); - return `'${name}': { type: ${constructorType} }`; - }) - .join(', '); - - return `{ ${props} }`; -}; diff --git a/packages/twenty-sdk/scripts/remote-dom/generators/utils/generate-properties-type.ts b/packages/twenty-sdk/scripts/remote-dom/generators/utils/generate-properties-type.ts deleted file mode 100644 index 44a9ca06c8..0000000000 --- a/packages/twenty-sdk/scripts/remote-dom/generators/utils/generate-properties-type.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { type PropertySchema } from '../schemas'; -import { schemaTypeToTs } from './schema-type-to-ts'; - -export const generatePropertiesType = ( - properties: Record, -): string => { - const entries = Object.entries(properties); - if (entries.length === 0) { - return 'Record'; - } - - const props = entries - .map(([name, schema]) => { - const tsType = schemaTypeToTs(schema.type); - const optional = schema.optional ? '?' : ''; - return `'${name}'${optional}: ${tsType}`; - }) - .join('; '); - - return `{ ${props} }`; -}; diff --git a/packages/twenty-sdk/scripts/remote-dom/generators/utils/generated-file-header.ts b/packages/twenty-sdk/scripts/remote-dom/generators/utils/generated-file-header.ts deleted file mode 100644 index 2f2350d06c..0000000000 --- a/packages/twenty-sdk/scripts/remote-dom/generators/utils/generated-file-header.ts +++ /dev/null @@ -1,8 +0,0 @@ -export const GENERATED_FILE_HEADER = `/* - * _____ _ - *|_ _|_ _____ _ __ | |_ _ _ - * | | \\ \\ /\\ / / _ \\ '_ \\| __| | | | Auto-generated file - * | | \\ V V / __/ | | | |_| |_| | Any edits to this will be overridden - * |_| \\_/\\_/ \\___|_| |_|\\__|\\__, | - * |___/ - */`; diff --git a/packages/twenty-sdk/scripts/remote-dom/generators/utils/index.ts b/packages/twenty-sdk/scripts/remote-dom/generators/utils/index.ts index d128bd8eac..41a7f81a3c 100644 --- a/packages/twenty-sdk/scripts/remote-dom/generators/utils/index.ts +++ b/packages/twenty-sdk/scripts/remote-dom/generators/utils/index.ts @@ -1,10 +1,2 @@ export { addExportedConst } from './add-exported-const'; -export { addExportedType } from './add-exported-type'; -export { addFileHeader } from './add-file-header'; -export { addStatement } from './add-statement'; -export { extractHtmlTag } from './extract-html-tag'; -export { generatePropertiesType } from './generate-properties-type'; -export { GENERATED_FILE_HEADER } from './generated-file-header'; export { schemaTypeToConstructor } from './schema-type-to-constructor'; -export { schemaTypeToTs } from './schema-type-to-ts'; - diff --git a/packages/twenty-sdk/scripts/remote-dom/generators/utils/schema-type-to-ts.ts b/packages/twenty-sdk/scripts/remote-dom/generators/utils/schema-type-to-ts.ts deleted file mode 100644 index 6dfca2cf77..0000000000 --- a/packages/twenty-sdk/scripts/remote-dom/generators/utils/schema-type-to-ts.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { type PropertySchema } from '../schemas'; - -const SCHEMA_TYPE_TO_TS: Record = { - boolean: 'boolean', - number: 'number', - string: 'string', -}; - -export const schemaTypeToTs = (type: PropertySchema['type']): string => - SCHEMA_TYPE_TO_TS[type]; diff --git a/packages/twenty-sdk/scripts/remote-dom/utils/logger.ts b/packages/twenty-sdk/scripts/remote-dom/utils/logger.ts deleted file mode 100644 index 54d3e6ee36..0000000000 --- a/packages/twenty-sdk/scripts/remote-dom/utils/logger.ts +++ /dev/null @@ -1,135 +0,0 @@ -import chalk from 'chalk'; - -const SEPARATOR_WIDTH = 60; - -let _verbose = false; - -export const setVerbose = (value: boolean): void => { - _verbose = value; -}; - -export const isVerbose = (): boolean => _verbose; - -export const logSeparator = (): void => { - if (!_verbose) return; - console.log(''); - console.log(chalk.gray('─'.repeat(SEPARATOR_WIDTH))); - console.log(''); -}; - -export const logTitle = (text: string): void => { - if (!_verbose) return; - console.log(''); - console.log(chalk.bold.white(` ${text}`)); - console.log(''); - console.log(chalk.gray('─'.repeat(SEPARATOR_WIDTH))); - console.log(''); -}; - -export const logSectionHeader = (text: string): void => { - if (!_verbose) return; - console.log(chalk.bold.white(` ${text}`)); - console.log(''); -}; - -export const logCategory = (name: string): void => { - if (!_verbose) return; - console.log(chalk.green(' ▸ ') + chalk.green.bold(name)); -}; - -export const logSubItem = (label: string, value: string): void => { - if (!_verbose) return; - console.log( - chalk.gray(' ') + - chalk.green(label) + - chalk.gray(' -> ') + - chalk.white(value), - ); -}; - -const pluralize = (count: number, singular: string, plural: string): string => - count === 1 ? singular : plural; - -export const logCount = ( - label: string, - count: number, - singularUnit: string, - pluralUnit?: string, -): void => { - if (!_verbose) return; - const unit = pluralize(count, singularUnit, pluralUnit ?? singularUnit + 's'); - console.log( - chalk.green(` ${label} `) + - chalk.white.bold(`${count}`) + - chalk.gray(` ${unit}`), - ); -}; - -export const logDetail = (text: string): void => { - if (!_verbose) return; - console.log(chalk.gray(` ${text}`)); -}; - -export const logDimText = (text: string): void => { - if (!_verbose) return; - console.log(chalk.gray(text)); -}; - -export const logFileWritten = (filePath: string): void => { - if (!_verbose) return; - console.log(chalk.green(' ✓ ') + chalk.gray(filePath)); -}; - -export const logGroupLabel = (text: string): void => { - if (!_verbose) return; - console.log(chalk.green(` ${text}`)); -}; - -export const logSuccess = (message: string, detail?: string): void => { - if (!_verbose) return; - const detailSuffix = detail ? chalk.gray(` ${detail}`) : ''; - - console.log(chalk.green(` ✔ `) + chalk.green.bold(message) + detailSuffix); -}; - -export const logError = (message: string, error?: unknown): void => { - console.error(chalk.red.bold(` ✖ ${message}`), error ?? ''); -}; - -export const logWarning = (message: string): void => { - if (!_verbose) return; - console.warn(chalk.yellow(` ${message}`)); -}; - -export const logEmpty = (): void => { - if (!_verbose) return; - console.log(''); -}; - -export const logLine = (text: string): void => { - if (!_verbose) return; - console.log(text); -}; - -export const logCountInline = ( - count: number, - singularUnit: string, - pluralUnit?: string, - prefix?: string, -): string => { - const unit = pluralize(count, singularUnit, pluralUnit ?? singularUnit + 's'); - const prefixText = prefix ? chalk.gray(`${prefix} `) : ''; - - return prefixText + chalk.white.bold(`${count}`) + chalk.gray(` ${unit}`); -}; - -export const formatProps = (count: number): string => - chalk.green(`${count} ${pluralize(count, 'prop', 'props')}`); - -export const formatEvents = (count: number, names: string[]): string => - chalk.yellow(`${count} ${pluralize(count, 'event', 'events')}`) + - chalk.gray(` [${names.join(', ')}]`); - -export const formatSlots = (count: number, names: string[]): string => - chalk.magenta(`${count} ${pluralize(count, 'slot', 'slots')}`) + - chalk.gray(` [${names.join(', ')}]`); diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/__tests__/jsx-transform-to-remote-dom-worker-format-plugin.spec.ts b/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/__tests__/jsx-transform-to-remote-dom-worker-format-plugin.spec.ts deleted file mode 100644 index 4ce5d6f77f..0000000000 --- a/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/__tests__/jsx-transform-to-remote-dom-worker-format-plugin.spec.ts +++ /dev/null @@ -1,191 +0,0 @@ -import { transformJsxToRemoteComponents } from '@/cli/utilities/build/common/front-component-build/jsx-transform-to-remote-dom-worker-format-plugin'; - -describe('transformJsxToRemoteComponents', () => { - describe('basic tag transformations', () => { - it('should transform div tags', () => { - const input = '
Hello
'; - const expected = - 'Hello'; - expect(transformJsxToRemoteComponents(input)).toBe(expected); - }); - - it('should transform span tags', () => { - const input = 'Text'; - const expected = - 'Text'; - expect(transformJsxToRemoteComponents(input)).toBe(expected); - }); - - it('should transform button tags', () => { - const input = ''; - const expected = - 'Click me'; - expect(transformJsxToRemoteComponents(input)).toBe(expected); - }); - - it('should transform self-closing tags', () => { - const input = '
'; - const expected = ''; - expect(transformJsxToRemoteComponents(input)).toBe(expected); - }); - - it('should transform img tags with attributes', () => { - const input = 'Test'; - const expected = ''; - expect(transformJsxToRemoteComponents(input)).toBe(expected); - }); - }); - - describe('nested elements', () => { - it('should transform nested elements', () => { - const input = '
Nested
'; - const expected = - 'Nested'; - expect(transformJsxToRemoteComponents(input)).toBe(expected); - }); - - it('should transform deeply nested elements', () => { - const input = '
  • Item
'; - const expected = - 'Item'; - expect(transformJsxToRemoteComponents(input)).toBe(expected); - }); - }); - - describe('attributes preservation', () => { - it('should preserve className attribute', () => { - const input = '
Content
'; - const expected = - 'Content'; - expect(transformJsxToRemoteComponents(input)).toBe(expected); - }); - - it('should preserve onClick handler', () => { - const input = ''; - const expected = - 'Click'; - expect(transformJsxToRemoteComponents(input)).toBe(expected); - }); - - it('should preserve multiple attributes', () => { - const input = - ''; - const expected = - ''; - expect(transformJsxToRemoteComponents(input)).toBe(expected); - }); - }); - - describe('custom components should not be transformed', () => { - it('should not transform PascalCase components', () => { - const input = 'Content'; - expect(transformJsxToRemoteComponents(input)).toBe(input); - }); - - it('should not transform components starting with uppercase', () => { - const input = ''; - expect(transformJsxToRemoteComponents(input)).toBe(input); - }); - - it('should transform HTML tags but not custom components in mixed content', () => { - const input = '
'; - const expected = - ''; - expect(transformJsxToRemoteComponents(input)).toBe(expected); - }); - }); - - describe('fragments should not be transformed', () => { - it('should not transform empty fragments', () => { - const input = '<>'; - expect(transformJsxToRemoteComponents(input)).toBe(input); - }); - - it('should preserve fragments with content', () => { - const input = '<>
Content
'; - const expected = - '<>Content'; - expect(transformJsxToRemoteComponents(input)).toBe(expected); - }); - }); - - describe('all supported HTML elements', () => { - const testCases: [string, string][] = [ - ['div', 'HtmlDiv'], - ['span', 'HtmlSpan'], - ['section', 'HtmlSection'], - ['article', 'HtmlArticle'], - ['header', 'HtmlHeader'], - ['footer', 'HtmlFooter'], - ['main', 'HtmlMain'], - ['nav', 'HtmlNav'], - ['aside', 'HtmlAside'], - ['p', 'HtmlP'], - ['h1', 'HtmlH1'], - ['h2', 'HtmlH2'], - ['h3', 'HtmlH3'], - ['h4', 'HtmlH4'], - ['h5', 'HtmlH5'], - ['h6', 'HtmlH6'], - ['strong', 'HtmlStrong'], - ['em', 'HtmlEm'], - ['small', 'HtmlSmall'], - ['code', 'HtmlCode'], - ['pre', 'HtmlPre'], - ['blockquote', 'HtmlBlockquote'], - ['a', 'HtmlA'], - ['img', 'HtmlImg'], - ['ul', 'HtmlUl'], - ['ol', 'HtmlOl'], - ['li', 'HtmlLi'], - ['form', 'HtmlForm'], - ['label', 'HtmlLabel'], - ['input', 'HtmlInput'], - ['textarea', 'HtmlTextarea'], - ['select', 'HtmlSelect'], - ['option', 'HtmlOption'], - ['button', 'HtmlButton'], - ['table', 'HtmlTable'], - ['thead', 'HtmlThead'], - ['tbody', 'HtmlTbody'], - ['tfoot', 'HtmlTfoot'], - ['tr', 'HtmlTr'], - ['th', 'HtmlTh'], - ['td', 'HtmlTd'], - ['br', 'HtmlBr'], - ['hr', 'HtmlHr'], - ]; - - it.each(testCases)( - 'should transform <%s> to RemoteComponents.%s', - (tag, component) => { - const input = `<${tag}>Content`; - const expected = `Content`; - expect(transformJsxToRemoteComponents(input)).toBe(expected); - }, - ); - }); - - describe('edge cases', () => { - it('should handle multiline JSX', () => { - const input = ` -
- Hello -
- `; - const result = transformJsxToRemoteComponents(input); - expect(result).toContain(''); - expect(result).toContain(''); - expect(result).toContain(''); - expect(result).toContain(''); - }); - - it('should handle JSX expressions', () => { - const input = - '
{items.map(item => {item.name})}
'; - const expected = - '{items.map(item => {item.name})}'; - expect(transformJsxToRemoteComponents(input)).toBe(expected); - }); - }); -}); diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/__tests__/react-globals-plugin.spec.ts b/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/__tests__/react-globals-plugin.spec.ts deleted file mode 100644 index 6e51fd645c..0000000000 --- a/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/__tests__/react-globals-plugin.spec.ts +++ /dev/null @@ -1,266 +0,0 @@ -import * as esbuild from 'esbuild'; -import * as fs from 'fs'; -import * as path from 'path'; - -import { reactGlobalsPlugin } from '../react-globals-plugin'; - -describe('reactGlobalsPlugin', () => { - const tempDir = path.join(__dirname, '.temp-test'); - const tempFile = path.join(tempDir, 'test-component.tsx'); - - beforeAll(() => { - if (!fs.existsSync(tempDir)) { - fs.mkdirSync(tempDir, { recursive: true }); - } - }); - - afterAll(() => { - if (fs.existsSync(tempDir)) { - fs.rmSync(tempDir, { recursive: true, force: true }); - } - }); - - const buildWithPlugin = async (code: string): Promise => { - fs.writeFileSync(tempFile, code, 'utf-8'); - - const result = await esbuild.build({ - entryPoints: [tempFile], - bundle: true, - write: false, - format: 'esm', - jsx: 'automatic', - plugins: [reactGlobalsPlugin], - }); - - return result.outputFiles[0].text; - }; - - describe('react/jsx-runtime imports', () => { - it('should replace jsx import with globalThis.jsx', async () => { - const code = ` - import { jsx } from 'react/jsx-runtime'; - export const Component = () => jsx('div', {}); - `; - - const result = await buildWithPlugin(code); - - expect(result).toContain('globalThis.jsx'); - expect(result).not.toContain('from "react/jsx-runtime"'); - }); - - it('should replace jsxs import with globalThis.jsxs', async () => { - const code = ` - import { jsxs } from 'react/jsx-runtime'; - export const Component = () => jsxs('div', {}); - `; - - const result = await buildWithPlugin(code); - - expect(result).toContain('globalThis.jsxs'); - expect(result).not.toContain('from "react/jsx-runtime"'); - }); - - it('should replace Fragment import with globalThis.React.Fragment', async () => { - const code = ` - import { Fragment } from 'react/jsx-runtime'; - export const Component = () => Fragment; - `; - - const result = await buildWithPlugin(code); - - expect(result).toContain('globalThis.React.Fragment'); - expect(result).not.toContain('from "react/jsx-runtime"'); - }); - }); - - describe('react imports', () => { - it('should replace useState with globalThis.React.useState', async () => { - const code = ` - import { useState } from 'react'; - export const Component = () => { - const [state, setState] = useState(0); - return state; - }; - `; - - const result = await buildWithPlugin(code); - - expect(result).toContain('globalThis.React.useState'); - expect(result).not.toContain('from "react"'); - }); - - it('should replace multiple hooks with globalThis.React equivalents', async () => { - const code = ` - import { useState, useEffect, useCallback } from 'react'; - export const Component = () => { - const [state, setState] = useState(0); - useEffect(() => {}, []); - const cb = useCallback(() => {}, []); - return state; - }; - `; - - const result = await buildWithPlugin(code); - - expect(result).toContain('globalThis.React.useState'); - expect(result).toContain('globalThis.React.useEffect'); - expect(result).toContain('globalThis.React.useCallback'); - expect(result).not.toContain('from "react"'); - }); - - it('should replace default React import with globalThis.React', async () => { - const code = ` - import React from 'react'; - export const Component = () => React.createElement('div'); - `; - - const result = await buildWithPlugin(code); - - expect(result).toContain('globalThis.React'); - expect(result).not.toContain('from "react"'); - }); - }); - - describe('JSX transformation with plugin', () => { - it('should transform JSX using globalThis.jsx', async () => { - const code = ` - export const Component = () =>
Hello
; - `; - - const result = await buildWithPlugin(code); - - expect(result).toContain('globalThis.jsx'); - expect(result).not.toContain('from "react/jsx-runtime"'); - }); - - it('should transform JSX with multiple children using globalThis.jsxs', async () => { - const code = ` - export const Component = () => ( -
- One - Two -
- ); - `; - - const result = await buildWithPlugin(code); - - expect(result).toContain('globalThis.jsxs'); - expect(result).not.toContain('from "react/jsx-runtime"'); - }); - }); - - describe('only include used React exports', () => { - it('should only include used React exports', async () => { - const code = ` - import { useState, useEffect } from 'react'; - export const Component = () => { - const [state, setState] = useState(0); - useEffect(() => {}, []); - return state; - }; - `; - - const result = await buildWithPlugin(code); - - expect(result).toContain('globalThis.React.useState'); - expect(result).toContain('globalThis.React.useEffect'); - expect(result).not.toContain('globalThis.React.useCallback'); - expect(result).not.toContain('globalThis.React.useMemo'); - expect(result).not.toContain('globalThis.React.useRef'); - expect(result).not.toContain('globalThis.React.useReducer'); - }); - - it('should handle aliased imports', async () => { - const code = ` - import { useState as useLocalState } from 'react'; - export const Component = () => { - const [state] = useLocalState(0); - return state; - }; - `; - - const result = await buildWithPlugin(code); - - expect(result).toContain('globalThis.React.useState'); - expect(result).not.toContain('globalThis.React.useEffect'); - }); - - it('should handle mixed default and named imports', async () => { - const code = ` - import React, { useState } from 'react'; - export const Component = () => { - const [state] = useState(0); - return React.createElement('div', null, state); - }; - `; - - const result = await buildWithPlugin(code); - - expect(result).toContain('globalThis.React.useState'); - expect(result).toContain('globalThis.React'); - expect(result).not.toContain('globalThis.React.useEffect'); - }); - }); - - describe('multiple entry points', () => { - it('should handle multiple files with different React imports', async () => { - const fileA = path.join(tempDir, 'component-a.tsx'); - const fileB = path.join(tempDir, 'component-b.tsx'); - - fs.writeFileSync( - fileA, - ` - import { useState } from 'react'; - export const ComponentA = () => { - const [state] = useState(0); - return state; - }; - `, - 'utf-8', - ); - - fs.writeFileSync( - fileB, - ` - import { useEffect } from 'react'; - export const ComponentB = () => { - useEffect(() => {}, []); - return null; - }; - `, - 'utf-8', - ); - - const result = await esbuild.build({ - entryPoints: [fileA, fileB], - bundle: true, - write: false, - format: 'esm', - jsx: 'automatic', - outdir: tempDir, - plugins: [reactGlobalsPlugin], - }); - - const outputA = result.outputFiles.find( - (f) => path.basename(f.path) === 'component-a.js', - )?.text; - const outputB = result.outputFiles.find( - (f) => path.basename(f.path) === 'component-b.js', - )?.text; - - expect(outputA).toBeDefined(); - expect(outputB).toBeDefined(); - - // Each file should only include the React exports it needs - expect(outputA).toContain('globalThis.React.useState'); - expect(outputB).toContain('globalThis.React.useEffect'); - expect(outputA).not.toContain('globalThis.React.useEffect'); - expect(outputB).not.toContain('globalThis.React.useState'); - - // No raw react imports should remain - expect(outputA).not.toContain('from "react"'); - expect(outputB).not.toContain('from "react"'); - }); - }); -}); 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 affc3cc593..75d6ef324c 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 +1 @@ -export const FRONT_COMPONENT_EXTERNAL_MODULES: string[] = ['react-dom']; +export const FRONT_COMPONENT_EXTERNAL_MODULES: string[] = []; diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/jsx-runtime-remote-wrapper-plugin.ts b/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/jsx-runtime-remote-wrapper-plugin.ts new file mode 100644 index 0000000000..d5f6b41ad6 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/jsx-runtime-remote-wrapper-plugin.ts @@ -0,0 +1,283 @@ +import type * as esbuild from 'esbuild'; + +const SHARED_HELPERS = ` +export var customElementMap = globalThis.__HTML_TAG_TO_CUSTOM_ELEMENT_TAG__ || {}; + +var _injectedStyleKeys = {}; + +export function injectStyleViaHead(cssText) { + if (!cssText) return; + var hash = 0; + for (var i = 0; i < cssText.length; i++) { + hash = ((hash << 5) - hash + cssText.charCodeAt(i)) | 0; + } + var key = 'jsx-style-' + hash; + if (_injectedStyleKeys[key]) return; + _injectedStyleKeys[key] = true; + var el = document.createElement('style'); + el.setAttribute('data-jsx-style', key); + el.textContent = cssText; + document.head.appendChild(el); +} + +export function extractCssText(children) { + if (typeof children === 'string') return children; + if (Array.isArray(children)) + return children + .filter(function (c) { return typeof c === 'string'; }) + .join(''); + return ''; +} + +var _reactToDomEvent = { + ondoubleclick: 'ondblclick', +}; + +function _isEventProp(name) { + return ( + name.length > 2 && + name.charCodeAt(0) === 111 && + name.charCodeAt(1) === 110 && + name.charCodeAt(2) >= 65 && + name.charCodeAt(2) <= 90 + ); +} + +export function splitEventProps(props) { + if (!props) return { cleanProps: props, events: null }; + var events = null; + var cleanProps = null; + for (var k in props) { + if (_isEventProp(k) && typeof props[k] === 'function') { + if (!events) { + events = {}; + cleanProps = {}; + for (var j in props) { + if (j === k) break; + cleanProps[j] = props[j]; + } + } + events[k] = props[k]; + } else if (events) { + cleanProps[k] = props[k]; + } + } + return { cleanProps: cleanProps || props, events: events }; +} + +export function makeEventRef(events, userRef) { + return function(el) { + if (el) { + for (var name in events) { + var domName = _reactToDomEvent[name.toLowerCase()] || name.toLowerCase(); + el[domName] = events[name]; + } + } + if (typeof userRef === 'function') userRef(el); + else if (userRef != null && typeof userRef === 'object') userRef.current = el; + }; +} +`.trim(); + +const JSX_RUNTIME_WRAPPER = ` +import { + jsx as _originalJsx, + jsxs as _originalJsxs, + Fragment, +} from '__real_react_jsx_runtime__'; + +import { + customElementMap, + injectStyleViaHead, + extractCssText, + splitEventProps, + makeEventRef, +} from '__jsx_shared_helpers__'; + +function _wrapJsxFactory(originalFactory) { + return function wrappedJsx(type, props, key) { + if (typeof type === 'string') { + if (type === 'style') { + var css = + props && props.dangerouslySetInnerHTML + ? props.dangerouslySetInnerHTML.__html || '' + : extractCssText(props && props.children); + injectStyleViaHead(css); + return null; + } + + var customTag = customElementMap[type]; + if (customTag) { + var split = splitEventProps(props); + if (split.events) { + var cp = split.cleanProps; + cp.ref = makeEventRef(split.events, cp.ref); + return originalFactory(customTag, cp, key); + } + return originalFactory(customTag, props, key); + } + } + return originalFactory(type, props, key); + }; +} + +export var jsx = _wrapJsxFactory(_originalJsx); +export var jsxs = _wrapJsxFactory(_originalJsxs); +export { Fragment }; +`.trim(); + +const REACT_WRAPPER = ` +export * from '__real_react__'; +import _React from '__real_react__'; + +import { + customElementMap, + injectStyleViaHead, + extractCssText, + splitEventProps, + makeEventRef, +} from '__jsx_shared_helpers__'; + +var _originalCreateElement = _React.createElement; + +function createElement(type) { + var args = arguments; + if (typeof type === 'string') { + if (type === 'style') { + var props = args.length > 1 ? args[1] : null; + if (props) { + var css = props.dangerouslySetInnerHTML + ? props.dangerouslySetInnerHTML.__html || '' + : extractCssText(props.children); + injectStyleViaHead(css); + } + return null; + } + + var customTag = customElementMap[type]; + if (customTag) { + var ceProps = args.length > 1 ? args[1] : null; + var split = splitEventProps(ceProps); + if (split.events) { + var cp = split.cleanProps || {}; + cp.ref = makeEventRef(split.events, cp.ref); + var newArgs = [customTag, cp]; + for (var i = 2; i < args.length; i++) newArgs.push(args[i]); + return _originalCreateElement.apply(null, newArgs); + } + var newArgs2 = [customTag]; + for (var i2 = 1; i2 < args.length; i2++) newArgs2.push(args[i2]); + return _originalCreateElement.apply(null, newArgs2); + } + } + return _originalCreateElement.apply(null, args); +} + +export { createElement }; +export default Object.assign({}, _React, { createElement: createElement }); +`.trim(); + +type JsxRuntimeRemoteWrapperPluginOptions = { + usePreact?: boolean; +}; + +export const createJsxRuntimeRemoteWrapperPlugin = ( + options?: JsxRuntimeRemoteWrapperPluginOptions, +): esbuild.Plugin => { + const usePreact = options?.usePreact ?? false; + + const jsxRuntimeModule = usePreact + ? 'preact/jsx-runtime' + : 'react/jsx-runtime'; + const reactModule = usePreact ? 'preact/compat' : 'react'; + + return { + name: 'jsx-runtime-remote-wrapper', + setup: (build) => { + let realJsxRuntimePath: string | undefined; + let realReactPath: string | undefined; + + build.onResolve({ filter: /^__jsx_shared_helpers__$/ }, () => ({ + path: '__jsx_shared_helpers__', + namespace: 'jsx-shared-helpers', + })); + + build.onLoad({ filter: /.*/, namespace: 'jsx-shared-helpers' }, () => ({ + contents: SHARED_HELPERS, + loader: 'js' as const, + })); + + build.onResolve({ filter: /^react\/jsx-runtime$/ }, async (args) => { + if (args.pluginData?.skipJsxWrapper) { + return undefined; + } + + if (!realJsxRuntimePath) { + const resolved = await build.resolve(jsxRuntimeModule, { + kind: args.kind, + resolveDir: args.resolveDir, + pluginData: { skipJsxWrapper: true }, + }); + + realJsxRuntimePath = resolved.path; + } + + return { + path: 'react/jsx-runtime', + namespace: 'jsx-runtime-wrapper', + }; + }); + + build.onResolve({ filter: /^__real_react_jsx_runtime__$/ }, () => { + if (!realJsxRuntimePath) { + throw new Error( + 'jsx-runtime-remote-wrapper: real jsx-runtime path not resolved yet', + ); + } + + return { path: realJsxRuntimePath }; + }); + + build.onLoad({ filter: /.*/, namespace: 'jsx-runtime-wrapper' }, () => ({ + contents: JSX_RUNTIME_WRAPPER, + loader: 'js' as const, + })); + + build.onResolve({ filter: /^react$/ }, async (args) => { + if (args.pluginData?.skipJsxWrapper) { + return undefined; + } + + if (!realReactPath) { + const resolved = await build.resolve(reactModule, { + kind: args.kind, + resolveDir: args.resolveDir, + pluginData: { skipJsxWrapper: true }, + }); + + realReactPath = resolved.path; + } + + return { + path: 'react', + namespace: 'react-wrapper', + }; + }); + + build.onResolve({ filter: /^__real_react__$/ }, () => { + if (!realReactPath) { + throw new Error( + 'jsx-runtime-remote-wrapper: real react path not resolved yet', + ); + } + + return { path: realReactPath }; + }); + + build.onLoad({ filter: /.*/, namespace: 'react-wrapper' }, () => ({ + contents: REACT_WRAPPER, + loader: 'js' as const, + })); + }, + }; +}; 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 c5beb0ba6c..77978e1a7e 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 @@ -1,11 +1,8 @@ import type * as esbuild from 'esbuild'; import * as fs from 'node:fs/promises'; -import { replaceHtmlTagsWithRemoteComponents } from './utils/replace-html-tags-with-remote-components'; import { unwrapDefineFrontComponentToDirectExport } from './utils/unwrap-define-front-component-to-direct-export'; -export { replaceHtmlTagsWithRemoteComponents as transformJsxToRemoteComponents } from './utils/replace-html-tags-with-remote-components'; - export const jsxTransformToRemoteDomWorkerFormatPlugin: esbuild.Plugin = { name: 'jsx-transform-to-remote-dom-worker-format-plugin', setup: (esbuildBuild) => { @@ -15,20 +12,9 @@ export const jsxTransformToRemoteDomWorkerFormatPlugin: esbuild.Plugin = { try { const frontComponentSourceCode = await fs.readFile(path, 'utf8'); - const sourceWithRemoteComponents = - replaceHtmlTagsWithRemoteComponents(frontComponentSourceCode); - - const hasRemoteComponentReplacements = - sourceWithRemoteComponents !== frontComponentSourceCode; - - const sourceWithUnwrappedFrontComponent = - unwrapDefineFrontComponentToDirectExport( - sourceWithRemoteComponents, - ); - - const transformedContents = hasRemoteComponentReplacements - ? `var RemoteComponents = globalThis.RemoteComponents;\n${sourceWithUnwrappedFrontComponent}` - : sourceWithUnwrappedFrontComponent; + const transformedContents = unwrapDefineFrontComponentToDirectExport( + frontComponentSourceCode, + ); return { contents: transformedContents, loader: 'tsx' }; } catch (transformError) { diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/preact-alias-plugin.ts b/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/preact-alias-plugin.ts new file mode 100644 index 0000000000..8b347d9c4b --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/preact-alias-plugin.ts @@ -0,0 +1,30 @@ +import type * as esbuild from 'esbuild'; + +export const createPreactAliasPlugin = (): esbuild.Plugin => ({ + name: 'preact-alias', + setup: (build) => { + let preactCompatClientPath: string | undefined; + + build.onResolve({ filter: /^react-dom\/client$/ }, async (args) => { + if (!preactCompatClientPath) { + const resolved = await build.resolve('preact/compat/client', { + kind: args.kind, + resolveDir: args.resolveDir, + }); + + preactCompatClientPath = resolved.path; + } + + return { path: preactCompatClientPath }; + }); + + build.onResolve({ filter: /^react-dom$/ }, async (args) => { + const resolved = await build.resolve('preact/compat', { + kind: args.kind, + resolveDir: args.resolveDir, + }); + + return { path: resolved.path }; + }); + }, +}); diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/react-globals-plugin.ts b/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/react-globals-plugin.ts deleted file mode 100644 index ff9e5ef219..0000000000 --- a/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/react-globals-plugin.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { isDefined } from 'twenty-shared/utils'; - -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; - -const REACT_MODULE_FILTER_PATTERN = /^react(\/jsx-runtime)?$/; - -const JSX_RUNTIME_EXPORTS = ` -export var jsx = /* @__PURE__ */ (() => globalThis.jsx)(); -export var jsxs = /* @__PURE__ */ (() => globalThis.jsxs)(); -export var Fragment = /* @__PURE__ */ (() => globalThis.React.Fragment)(); -`.trim(); - -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.groups?.defaultImport; - - if (defaultImportName) { - if (!namedImports.has('')) { - namedImports.set('', new Set()); - } - - namedImports.get('')!.add('default'); - } - } - - REACT_IMPORT_PATTERN.lastIndex = 0; - - return namedImports; -}; - -const generateReactExports = ({ - namedImports, -}: { - namedImports: Set; -}): string => { - const exportLines: string[] = []; - - for (const reactImportName of namedImports) { - if (reactImportName === 'default') { - exportLines.push( - 'export default /* @__PURE__ */ (() => globalThis.React)();', - ); - } else { - exportLines.push( - `export var ${reactImportName} = /* @__PURE__ */ (() => globalThis.React.${reactImportName})();`, - ); - } - } - - return exportLines.join('\n'); -}; - -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/twenty-sdk-globals-plugin.ts b/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/twenty-sdk-globals-plugin.ts deleted file mode 100644 index 62e8ea5784..0000000000 --- a/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/twenty-sdk-globals-plugin.ts +++ /dev/null @@ -1,26 +0,0 @@ -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 deleted file mode 100644 index 4d815eae72..0000000000 --- a/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/twenty-sdk-ui-globals-plugin.ts +++ /dev/null @@ -1,26 +0,0 @@ -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 deleted file mode 100644 index 428dd38bd1..0000000000 --- a/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/twenty-shared-globals-plugin.ts +++ /dev/null @@ -1,38 +0,0 @@ -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/types/ParsedImportSpecifier.ts b/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/types/ParsedImportSpecifier.ts deleted file mode 100644 index 19d8542fd3..0000000000 --- a/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/types/ParsedImportSpecifier.ts +++ /dev/null @@ -1,4 +0,0 @@ -export type ParsedImportSpecifier = { - originalName: string; - aliasName: string; -}; 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 deleted file mode 100644 index 247b75655e..0000000000 --- a/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/utils/collect-named-imports.ts +++ /dev/null @@ -1,45 +0,0 @@ -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-globals-plugin.ts b/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/utils/create-globals-plugin.ts deleted file mode 100644 index 9aedc5c7db..0000000000 --- a/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/utils/create-globals-plugin.ts +++ /dev/null @@ -1,96 +0,0 @@ -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/extract-names-from-import-specifier.ts b/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/utils/extract-names-from-import-specifier.ts deleted file mode 100644 index 91d26f5acf..0000000000 --- a/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/utils/extract-names-from-import-specifier.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { isDefined } from 'twenty-shared/utils'; - -import { type ParsedImportSpecifier } from '../types/ParsedImportSpecifier'; - -const ALIASED_IMPORT_PATTERN = /^([\w$]+)\s+as\s+([\w$]+)$/; - -export const extractNamesFromImportSpecifier = ( - importSpecifier: string, -): ParsedImportSpecifier => { - const trimmedSpecifier = importSpecifier.trim(); - const aliasMatch = trimmedSpecifier.match(ALIASED_IMPORT_PATTERN); - - if (isDefined(aliasMatch)) { - const [, originalName, aliasName] = aliasMatch; - - return { originalName, aliasName }; - } - - return { originalName: trimmedSpecifier, aliasName: trimmedSpecifier }; -}; diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/utils/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 73359cc477..e00de62080 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 @@ -1,17 +1,21 @@ import type * as esbuild from 'esbuild'; +import { createJsxRuntimeRemoteWrapperPlugin } from '../jsx-runtime-remote-wrapper-plugin'; import { jsxTransformToRemoteDomWorkerFormatPlugin } from '../jsx-transform-to-remote-dom-worker-format-plugin'; -import { reactGlobalsPlugin } from '../react-globals-plugin'; +import { createPreactAliasPlugin } from '../preact-alias-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, +type GetFrontComponentBuildPluginsOptions = { + usePreact?: boolean; +}; + +export const getFrontComponentBuildPlugins = ( + options?: GetFrontComponentBuildPluginsOptions, +): esbuild.Plugin[] => [ + createJsxRuntimeRemoteWrapperPlugin( + options?.usePreact ? { usePreact: true } : undefined, + ), + ...(options?.usePreact ? [createPreactAliasPlugin()] : []), jsxTransformToRemoteDomWorkerFormatPlugin, stripCommentsPlugin, ]; diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/utils/replace-html-tags-with-remote-components.ts b/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/utils/replace-html-tags-with-remote-components.ts deleted file mode 100644 index 777b51aef5..0000000000 --- a/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/utils/replace-html-tags-with-remote-components.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { isDefined } from 'twenty-shared/utils'; - -import { HTML_TAG_TO_REMOTE_COMPONENT } from '../../../../../../sdk/front-component-api'; - -const REMOTE_COMPONENTS_GLOBAL_NAMESPACE = 'RemoteComponents'; - -const buildHtmlTagToRemoteComponentPattern = (): RegExp => { - const supportedHtmlTagNames = Object.keys(HTML_TAG_TO_REMOTE_COMPONENT).join( - '|', - ); - - return new RegExp( - `(<\\/?)\\b(${supportedHtmlTagNames})\\b(?=[\\s>\\/>])`, - 'g', - ); -}; - -const HTML_TAG_TO_REMOTE_COMPONENT_PATTERN = - buildHtmlTagToRemoteComponentPattern(); - -export const replaceHtmlTagsWithRemoteComponents = ( - sourceCode: string, -): string => { - return sourceCode.replace( - HTML_TAG_TO_REMOTE_COMPONENT_PATTERN, - (fullMatch, tagPrefix: string, htmlTagName: string) => { - const remoteComponentName = - HTML_TAG_TO_REMOTE_COMPONENT[ - htmlTagName as keyof typeof HTML_TAG_TO_REMOTE_COMPONENT - ]; - - if (isDefined(remoteComponentName)) { - return `${tagPrefix}${REMOTE_COMPONENTS_GLOBAL_NAMESPACE}.${remoteComponentName}`; - } - - return fullMatch; - }, - ); -}; diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/utils/unwrap-define-front-component-to-direct-export.ts b/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/utils/unwrap-define-front-component-to-direct-export.ts index 95abf55323..441edc89bd 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/utils/unwrap-define-front-component-to-direct-export.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/utils/unwrap-define-front-component-to-direct-export.ts @@ -28,9 +28,14 @@ export const unwrapDefineFrontComponentToDirectExport = ( `$1 ${wrappedComponentName}`, ); + transformedSource = + `import { createRoot as __createRoot } from 'react-dom/client';\n` + + `import { jsx as __frontComponentJsx } from 'react/jsx-runtime';\n` + + transformedSource; + transformedSource = transformedSource.replace( DEFINE_FRONT_COMPONENT_EXPORT_PATTERN, - `export default globalThis.jsx(${wrappedComponentName}, {});`, + `export default function __renderFrontComponent(__container) { __createRoot(__container).render(__frontComponentJsx(${wrappedComponentName}, {})); }`, ); } diff --git a/packages/twenty-sdk/src/front-component-renderer/__stories__/BundleSizes.stories.tsx b/packages/twenty-sdk/src/front-component-renderer/__stories__/BundleSizes.stories.tsx new file mode 100644 index 0000000000..4bfc8a0adb --- /dev/null +++ b/packages/twenty-sdk/src/front-component-renderer/__stories__/BundleSizes.stories.tsx @@ -0,0 +1,213 @@ +import { type Meta, type StoryObj } from '@storybook/react-vite'; + +import bundleSizes from './example-sources-built/bundle-sizes.json'; + +type BundleSizeEntry = { + name: string; + reactBytes: number; + preactBytes: number; +}; + +const formatBytes = (bytes: number): string => { + if (bytes === 0) return '0 B'; + + const kb = bytes / 1024; + + if (kb < 1024) { + return `${kb.toFixed(1)} KB`; + } + + return `${(kb / 1024).toFixed(2)} MB`; +}; + +const formatLabel = (name: string): string => + name + .replace('.front-component', '') + .split('-') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); + +const BAR_MAX_WIDTH = 400; + +const BundleSizesTable = () => { + const entries = (bundleSizes as BundleSizeEntry[]).sort( + (a, b) => b.reactBytes - a.reactBytes, + ); + + const maxBytes = Math.max(...entries.map((entry) => entry.reactBytes)); + + return ( +
+

+ Front Component Bundle Sizes +

+

+ Each bar shows the minified .mjs size (React vs Preact runtime) +

+ + + + + + + + + + + + + {entries.map((entry) => { + const reactWidth = + (entry.reactBytes / maxBytes) * BAR_MAX_WIDTH; + const preactWidth = + (entry.preactBytes / maxBytes) * BAR_MAX_WIDTH; + const saving = + entry.reactBytes > 0 + ? ( + ((entry.reactBytes - entry.preactBytes) / + entry.reactBytes) * + 100 + ).toFixed(0) + : '0'; + + return ( + + + + + + + + ); + })} + +
ComponentSize + React + + Preact + + Saving +
+ {formatLabel(entry.name)} + +
+
+
+
+
+ {formatBytes(entry.reactBytes)} + + {formatBytes(entry.preactBytes)} + + {saving}% +
+ +
+ + + React + + + + Preact + +
+
+ ); +}; + +const meta: Meta = { + title: 'FrontComponent/BundleSizes', + component: BundleSizesTable, + parameters: { + layout: 'padded', + }, +}; + +export default meta; +type Story = StoryObj; + +export const Overview: Story = {}; diff --git a/packages/twenty-sdk/src/front-component-renderer/__stories__/FrontComponentRenderer.stories.tsx b/packages/twenty-sdk/src/front-component-renderer/__stories__/FrontComponentRenderer.stories.tsx index 9492efd123..f9175f3202 100644 --- a/packages/twenty-sdk/src/front-component-renderer/__stories__/FrontComponentRenderer.stories.tsx +++ b/packages/twenty-sdk/src/front-component-renderer/__stories__/FrontComponentRenderer.stories.tsx @@ -8,7 +8,7 @@ import { getBuiltStoryComponentPathForRender } from './utils/getBuiltStoryCompon const errorHandler = fn(); const meta: Meta = { - title: 'FrontComponent/FrontComponentRenderer', + title: 'FrontComponent/Feature', component: FrontComponentRenderer, parameters: { layout: 'centered', @@ -25,10 +25,21 @@ const meta: Meta = { export default meta; type Story = StoryObj; -export const Static: Story = { +const createComponentStory = ( + name: string, + options?: { runtime?: 'preact'; play?: Story['play'] }, +): Story => ({ args: { - componentUrl: getBuiltStoryComponentPathForRender('static.front-component'), + componentUrl: getBuiltStoryComponentPathForRender( + `${name}.front-component`, + options?.runtime, + ), }, + ...(options?.play ? { play: options.play } : {}), +}); + +export const Static: Story = { + ...createComponentStory('static'), play: async ({ canvasElement }) => { const canvas = within(canvasElement); @@ -54,9 +65,7 @@ export const Static: Story = { }; export const Interactive: Story = { - args: { - componentUrl: getBuiltStoryComponentPathForRender('interactive.front-component'), - }, + ...createComponentStory('interactive'), play: async ({ canvasElement }) => { const canvas = within(canvasElement); @@ -74,9 +83,7 @@ export const Interactive: Story = { }; export const Lifecycle: Story = { - args: { - componentUrl: getBuiltStoryComponentPathForRender('lifecycle.front-component'), - }, + ...createComponentStory('lifecycle'), play: async ({ canvasElement }) => { const canvas = within(canvasElement); @@ -94,66 +101,8 @@ export const Lifecycle: Story = { }, }; -export const ChakraExample: Story = { - args: { - componentUrl: getBuiltStoryComponentPathForRender( - 'chakra-example.front-component', - ), - }, -}; - -export const TailwindExample: Story = { - args: { - componentUrl: getBuiltStoryComponentPathForRender( - 'tailwind-example.front-component', - ), - }, -}; - -export const EmotionExample: Story = { - args: { - componentUrl: getBuiltStoryComponentPathForRender( - 'emotion-example.front-component', - ), - }, -}; - -export const StyledComponentsExample: Story = { - args: { - componentUrl: getBuiltStoryComponentPathForRender( - 'styled-components-example.front-component', - ), - }, -}; - -export const ShadcnExample: Story = { - args: { - componentUrl: getBuiltStoryComponentPathForRender( - 'shadcn-example.front-component', - ), - }, -}; - -export const MuiExample: Story = { - args: { - componentUrl: getBuiltStoryComponentPathForRender( - 'mui-example.front-component', - ), - }, -}; - -export const TwentyUiExample: Story = { - args: { - componentUrl: getBuiltStoryComponentPathForRender( - 'twenty-ui-example.front-component', - ), - }, -}; - export const ErrorHandling: Story = { - args: { - componentUrl: getBuiltStoryComponentPathForRender('nonexistent.front-component'), - }, + ...createComponentStory('nonexistent'), play: async () => { await waitFor( () => { @@ -163,3 +112,37 @@ export const ErrorHandling: Story = { ); }, }; + +export const SdkContext: Story = { + ...createComponentStory('sdk-context-example'), + args: { + ...createComponentStory('sdk-context-example').args, + executionContext: { userId: 'test-user-abc-123' }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await canvas.findByTestId( + 'sdk-context-component', + {}, + { timeout: 30000 }, + ); + + const userIdElement = await canvas.findByTestId('sdk-context-user-id'); + expect(userIdElement).toBeVisible(); + expect(userIdElement).toHaveTextContent('test-user-abc-123'); + + const jsonElement = await canvas.findByTestId('sdk-context-json'); + expect(jsonElement).toHaveTextContent('"userId": "test-user-abc-123"'); + + const button = await canvas.findByTestId('sdk-context-button'); + await userEvent.click(button); + + const renderCount = await canvas.findByTestId( + 'sdk-context-render-count', + ); + expect(renderCount).toHaveTextContent('Renders: 1'); + + expect(userIdElement).toHaveTextContent('test-user-abc-123'); + }, +}; diff --git a/packages/twenty-sdk/src/front-component-renderer/__stories__/UILibraries.stories.tsx b/packages/twenty-sdk/src/front-component-renderer/__stories__/UILibraries.stories.tsx new file mode 100644 index 0000000000..ea9655da48 --- /dev/null +++ b/packages/twenty-sdk/src/front-component-renderer/__stories__/UILibraries.stories.tsx @@ -0,0 +1,144 @@ +import { type Meta, type StoryObj } from '@storybook/react-vite'; +import { expect, fn, userEvent, within } from 'storybook/test'; + +import { FrontComponentRenderer } from '../host/components/FrontComponentRenderer'; + +import { getBuiltStoryComponentPathForRender } from './utils/getBuiltStoryComponentPathForRender'; + +const errorHandler = fn(); + +const meta: Meta = { + title: 'FrontComponent/UI Libraries', + component: FrontComponentRenderer, + parameters: { + layout: 'centered', + }, + args: { + onError: errorHandler, + applicationAccessToken: 'fake-token', + }, + beforeEach: () => { + errorHandler.mockClear(); + }, +}; + +export default meta; +type Story = StoryObj; + +const createComponentStory = ( + name: string, + options?: { runtime?: 'preact'; play?: Story['play'] }, +): Story => ({ + args: { + componentUrl: getBuiltStoryComponentPathForRender( + `${name}.front-component`, + options?.runtime, + ), + }, + ...(options?.play ? { play: options.play } : {}), +}); + +const createCounterTest = + (testIdPrefix: string): Story['play'] => + async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await canvas.findByTestId( + `${testIdPrefix}-component`, + {}, + { timeout: 30000 }, + ); + + expect(await canvas.findByText('Count: 0')).toBeVisible(); + + const button = await canvas.findByTestId(`${testIdPrefix}-button`); + await userEvent.click(button); + expect(await canvas.findByText('Count: 1')).toBeVisible(); + + await userEvent.click(button); + expect(await canvas.findByText('Count: 2')).toBeVisible(); + }; + +const chakraTest = createCounterTest('chakra'); + +export const ChakraReact: Story = createComponentStory('chakra-example', { + play: chakraTest, +}); +export const ChakraPreact: Story = createComponentStory('chakra-example', { + runtime: 'preact', + play: chakraTest, +}); + +const tailwindTest = createCounterTest('tailwind'); + +export const TailwindReact: Story = createComponentStory('tailwind-example', { + play: tailwindTest, +}); +export const TailwindPreact: Story = createComponentStory('tailwind-example', { + runtime: 'preact', + play: tailwindTest, +}); + +const emotionTest = createCounterTest('emotion'); + +export const EmotionReact: Story = createComponentStory('emotion-example', { + play: emotionTest, +}); +export const EmotionPreact: Story = createComponentStory('emotion-example', { + runtime: 'preact', + play: emotionTest, +}); + +const styledComponentsTest = createCounterTest('styled-components'); + +export const StyledComponentsReact: Story = createComponentStory( + 'styled-components-example', + { play: styledComponentsTest }, +); +export const StyledComponentsPreact: Story = createComponentStory( + 'styled-components-example', + { runtime: 'preact', play: styledComponentsTest }, +); + +const shadcnTest = createCounterTest('shadcn'); + +export const ShadcnReact: Story = createComponentStory('shadcn-example', { + play: shadcnTest, +}); +export const ShadcnPreact: Story = createComponentStory('shadcn-example', { + runtime: 'preact', + play: shadcnTest, +}); + +const muiTest = createCounterTest('mui'); + +export const MuiReact: Story = createComponentStory('mui-example', { + play: muiTest, +}); +export const MuiPreact: Story = createComponentStory('mui-example', { + runtime: 'preact', + play: muiTest, +}); + +const twentyUiTest: Story['play'] = async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await canvas.findByTestId('twenty-ui-component', {}, { timeout: 30000 }); + + expect(await canvas.findByText('Count: 0')).toBeVisible(); + + const button = await canvas.findByText('Increment'); + await userEvent.click(button); + expect(await canvas.findByText('Count: 1')).toBeVisible(); + + await userEvent.click(button); + expect(await canvas.findByText('Count: 2')).toBeVisible(); +}; + +export const TwentyUiReact: Story = createComponentStory('twenty-ui-example', { + play: twentyUiTest, +}); +export const TwentyUiPreact: Story = createComponentStory( + 'twenty-ui-example', + { runtime: 'preact', play: twentyUiTest }, +); diff --git a/packages/twenty-sdk/src/front-component-renderer/__stories__/example-sources/chakra-example.front-component.tsx b/packages/twenty-sdk/src/front-component-renderer/__stories__/example-sources/chakra-example.front-component.tsx index da234c452c..0ad4b0a89b 100644 --- a/packages/twenty-sdk/src/front-component-renderer/__stories__/example-sources/chakra-example.front-component.tsx +++ b/packages/twenty-sdk/src/front-component-renderer/__stories__/example-sources/chakra-example.front-component.tsx @@ -1,14 +1,77 @@ -import { Button, ChakraProvider, defaultSystem } from '@chakra-ui/react'; +import { useState } from 'react'; +import { + Badge, + Box, + Button, + ChakraProvider, + defaultSystem, + Heading, + HStack, + Text, + VStack, +} from '@chakra-ui/react'; import { defineFrontComponent } from 'twenty-sdk'; -export const ChakraComponent = () => { +const ChakraComponent = () => { + const [count, setCount] = useState(0); + return ( -
- -
+ + + + Chakra UI + + + Component library with built-in design tokens and responsive styles. + + + + Badge + + + Styled + + + Outline + + + + Count: {count} + + + + + + +
); }; @@ -16,6 +79,6 @@ export const ChakraComponent = () => { export default defineFrontComponent({ universalIdentifier: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', name: 'chakra-component', - description: 'A front component with a Chakra UI button', + description: 'A front component with Chakra UI', component: ChakraComponent, }); diff --git a/packages/twenty-sdk/src/front-component-renderer/__stories__/example-sources/emotion-example.front-component.tsx b/packages/twenty-sdk/src/front-component-renderer/__stories__/example-sources/emotion-example.front-component.tsx index 33fdbba378..7b5f416fb2 100644 --- a/packages/twenty-sdk/src/front-component-renderer/__stories__/example-sources/emotion-example.front-component.tsx +++ b/packages/twenty-sdk/src/front-component-renderer/__stories__/example-sources/emotion-example.front-component.tsx @@ -2,35 +2,70 @@ import styled from '@emotion/styled'; import { useState } from 'react'; import { defineFrontComponent } from '@/sdk'; -const Container = styled.div` - padding: 20px; +const Card = styled.div` + padding: 24px; background-color: #fefce8; border: 2px solid #facc15; border-radius: 12px; font-family: system-ui, sans-serif; + max-width: 360px; + display: flex; + flex-direction: column; + gap: 16px; `; const Heading = styled.h2` color: #854d0e; font-weight: 700; font-size: 18px; - margin-bottom: 12px; + margin: 0; +`; + +const Description = styled.p` + color: #a16207; + font-size: 14px; + margin: 0; + line-height: 1.5; +`; + +const ChipRow = styled.div` + display: flex; + gap: 8px; + flex-wrap: wrap; +`; + +const Chip = styled.span<{ color: string }>` + display: inline-block; + padding: 4px 10px; + background-color: ${({ color }) => color}; + color: white; + border-radius: 12px; + font-size: 12px; + font-weight: 600; `; const Count = styled.p` - font-size: 32px; + font-size: 24px; font-weight: 800; color: #ca8a04; - margin-bottom: 16px; + margin: 0; `; -const StyledButton = styled.button` - padding: 10px 20px; - background-color: #eab308; - color: white; - border: none; +const ButtonRow = styled.div` + display: flex; + gap: 8px; +`; + +const StyledButton = styled.button<{ variant?: 'outline' }>` + padding: 8px 16px; + background-color: ${({ variant }) => + variant === 'outline' ? 'transparent' : '#eab308'}; + color: ${({ variant }) => (variant === 'outline' ? '#854d0e' : 'white')}; + border: ${({ variant }) => + variant === 'outline' ? '1px solid #d4a90a' : 'none'}; border-radius: 6px; font-weight: 600; + font-size: 13px; cursor: pointer; `; @@ -38,16 +73,29 @@ const EmotionComponent = () => { const [count, setCount] = useState(0); return ( - - Emotion Styled Component + + Emotion + + CSS-in-JS library with tagged template literals and object styles. + + + Badge + Styled + Outline + Count: {count} - setCount((previous) => previous + 1)} - > - Increment - - + + setCount((previous) => previous + 1)} + > + Increment + + setCount(0)}> + Reset + + + ); }; diff --git a/packages/twenty-sdk/src/front-component-renderer/__stories__/example-sources/mui-example.front-component.tsx b/packages/twenty-sdk/src/front-component-renderer/__stories__/example-sources/mui-example.front-component.tsx index 7ad3d0290f..566c903b05 100644 --- a/packages/twenty-sdk/src/front-component-renderer/__stories__/example-sources/mui-example.front-component.tsx +++ b/packages/twenty-sdk/src/front-component-renderer/__stories__/example-sources/mui-example.front-component.tsx @@ -1,4 +1,5 @@ import Button from '@mui/material/Button'; +import MuiChip from '@mui/material/Chip'; import { useState } from 'react'; import { defineFrontComponent } from '@/sdk'; @@ -11,21 +12,43 @@ const MuiComponent = () => { style={{ padding: 24, fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif', + backgroundColor: '#e3f2fd', + border: '2px solid #42a5f5', + borderRadius: 12, + maxWidth: 360, + display: 'flex', + flexDirection: 'column', + gap: 16, }} >

- Material UI Component + Material UI

+

+ Google's design system with comprehensive component library. +

+
+ + + +

Count: {count}

@@ -33,11 +56,12 @@ const MuiComponent = () => { - diff --git a/packages/twenty-sdk/src/front-component-renderer/__stories__/example-sources/sdk-context-example.front-component.tsx b/packages/twenty-sdk/src/front-component-renderer/__stories__/example-sources/sdk-context-example.front-component.tsx new file mode 100644 index 0000000000..70fbbd076b --- /dev/null +++ b/packages/twenty-sdk/src/front-component-renderer/__stories__/example-sources/sdk-context-example.front-component.tsx @@ -0,0 +1,116 @@ +import { useState } from 'react'; +import { + defineFrontComponent, + useFrontComponentExecutionContext, + useUserId, +} from '@/sdk'; + +const CARD_STYLE = { + padding: 24, + display: 'flex', + flexDirection: 'column' as const, + gap: 16, + fontFamily: 'system-ui, sans-serif', + background: '#f0f9ff', + borderRadius: 12, + border: '2px solid #38bdf8', + maxWidth: 400, +}; + +const LABEL_STYLE = { + fontSize: 12, + fontWeight: 600, + color: '#64748b', + textTransform: 'uppercase' as const, + letterSpacing: 1, +}; + +const VALUE_STYLE = { + fontSize: 16, + fontWeight: 700, + color: '#0c4a6e', + wordBreak: 'break-all' as const, +}; + +const SdkContextComponent = () => { + const [renderCount, setRenderCount] = useState(0); + + const userId = useUserId(); + + const fullContext = useFrontComponentExecutionContext( + (context) => context, + ); + + return ( +
+

+ SDK Context +

+ +
+

User ID (useUserId)

+

+ {userId ?? 'null'} +

+
+ +
+

Full Context (JSON)

+
+          {JSON.stringify(fullContext, null, 2) ?? 'undefined'}
+        
+
+ +
+ + + Renders: {renderCount} + +
+
+ ); +}; + +export default defineFrontComponent({ + universalIdentifier: 'test-sdkc-0000-0000-0000-000000000011', + name: 'sdk-context-component', + description: + 'A front component that uses SDK context hooks (useFrontComponentExecutionContext, useUserId)', + component: SdkContextComponent, +}); diff --git a/packages/twenty-sdk/src/front-component-renderer/__stories__/example-sources/shadcn-example.front-component.tsx b/packages/twenty-sdk/src/front-component-renderer/__stories__/example-sources/shadcn-example.front-component.tsx index 2d914b135c..4fd1093f4c 100644 --- a/packages/twenty-sdk/src/front-component-renderer/__stories__/example-sources/shadcn-example.front-component.tsx +++ b/packages/twenty-sdk/src/front-component-renderer/__stories__/example-sources/shadcn-example.front-component.tsx @@ -1,136 +1,100 @@ import { useState } from 'react'; import { defineFrontComponent } from '@/sdk'; -// shadcn UI outputs pre-built components that use Tailwind utility classes -// with Radix UI primitives underneath. This example simulates that pattern: -// className-based styling with a bundled CSS subset. const SHADCN_CSS = ` *,*::before,*::after{box-sizing:border-box;margin:0;padding:0} .inline-flex{display:inline-flex} .flex{display:flex} .flex-col{display:flex;flex-direction:column} +.flex-wrap{flex-wrap:wrap} .items-center{align-items:center} .justify-center{justify-content:center} .gap-2{gap:.5rem} -.gap-3{gap:.75rem} +.gap-4{gap:1rem} .rounded-md{border-radius:.375rem} -.rounded-lg{border-radius:.5rem} -.border{border-width:1px} -.border-input{border-color:#e2e8f0} -.bg-background{background-color:#fff} -.bg-primary{background-color:#0f172a} -.bg-muted{background-color:#f1f5f9} -.bg-destructive{background-color:#ef4444} -.p-4{padding:1rem} +.rounded-xl{border-radius:.75rem} +.rounded-full{border-radius:9999px} +.border-2{border-width:2px} +.border-slate-300{border-color:#cbd5e1} +.bg-white{background-color:#fff} +.bg-slate-900{background-color:#0f172a} +.bg-slate-100{background-color:#f1f5f9} +.bg-red-500{background-color:#ef4444} +.bg-emerald-500{background-color:#10b981} +.bg-violet-500{background-color:#8b5cf6} .p-6{padding:1.5rem} +.px-3{padding-left:.75rem;padding-right:.75rem} .px-4{padding-left:1rem;padding-right:1rem} +.py-1{padding-top:.25rem;padding-bottom:.25rem} .py-2{padding-top:.5rem;padding-bottom:.5rem} -.h-10{height:2.5rem} -.w-full{width:100%} +.h-9{height:2.25rem} +.text-xs{font-size:.75rem;line-height:1rem} .text-sm{font-size:.875rem;line-height:1.25rem} .text-lg{font-size:1.125rem;line-height:1.75rem} .text-2xl{font-size:1.5rem;line-height:2rem} .font-medium{font-weight:500} .font-semibold{font-weight:600} -.text-primary{color:#0f172a} -.text-primary-foreground{color:#fff} -.text-muted-foreground{color:#64748b} -.text-destructive-foreground{color:#fff} -.shadow-sm{box-shadow:0 1px 2px 0 rgb(0 0 0/.05)} +.font-bold{font-weight:700} +.font-extrabold{font-weight:800} +.text-slate-900{color:#0f172a} +.text-white{color:#fff} +.text-slate-500{color:#64748b} +.text-slate-700{color:#334155} +.max-w-sm{max-width:24rem} .cursor-pointer{cursor:pointer} -.ring-offset-background{--ring-offset:0px} +.border{border-width:1px} +.border-slate-200{border-color:#e2e8f0} .transition-colors{transition:color .15s,background-color .15s,border-color .15s} `; -// Simulated shadcn Button component (normally generated by shadcn CLI) -const Button = ({ - children, - variant = 'default', - className = '', - ...props -}: { - children: React.ReactNode; - variant?: 'default' | 'destructive' | 'outline'; - className?: string; - 'data-testid'?: string; - onClick?: () => void; -}) => { - const baseClasses = - 'inline-flex items-center justify-center rounded-md text-sm font-medium h-10 px-4 py-2 cursor-pointer transition-colors'; - - const variantClasses = { - default: 'bg-primary text-primary-foreground', - destructive: 'bg-destructive text-destructive-foreground', - outline: 'border border-input bg-background', - }; - - return ( - - ); -}; - -// Simulated shadcn Card component -const Card = ({ - children, - className = '', - ...props -}: { - children: React.ReactNode; - className?: string; - 'data-testid'?: string; -}) => ( -
- {children} -
-); - const ShadcnComponent = () => { const [count, setCount] = useState(0); return ( <> - -
-

- shadcn UI Component -

-

- Simulates the shadcn pattern: Tailwind utilities + composable - primitives. -

-
- - Count: {count} - -
-
- - -
+
+

shadcn / ui

+

+ Composable primitives powered by Radix UI and Tailwind CSS. +

+
+ + Badge + + + Styled + + + Composable +
- +

+ Count: {count} +

+
+ + +
+
); }; diff --git a/packages/twenty-sdk/src/front-component-renderer/__stories__/example-sources/styled-components-example.front-component.tsx b/packages/twenty-sdk/src/front-component-renderer/__stories__/example-sources/styled-components-example.front-component.tsx index 67e406db64..74282508ed 100644 --- a/packages/twenty-sdk/src/front-component-renderer/__stories__/example-sources/styled-components-example.front-component.tsx +++ b/packages/twenty-sdk/src/front-component-renderer/__stories__/example-sources/styled-components-example.front-component.tsx @@ -2,35 +2,70 @@ import { useState } from 'react'; import styled from 'styled-components'; import { defineFrontComponent } from '@/sdk'; -const Container = styled.div` - padding: 20px; +const Card = styled.div` + padding: 24px; background-color: #fdf2f8; border: 2px solid #ec4899; border-radius: 12px; font-family: system-ui, sans-serif; + max-width: 360px; + display: flex; + flex-direction: column; + gap: 16px; `; const Heading = styled.h2` color: #9d174d; font-weight: 700; font-size: 18px; - margin-bottom: 12px; + margin: 0; +`; + +const Description = styled.p` + color: #be185d; + font-size: 14px; + margin: 0; + line-height: 1.5; +`; + +const ChipRow = styled.div` + display: flex; + gap: 8px; + flex-wrap: wrap; +`; + +const Chip = styled.span<{ $color: string }>` + display: inline-block; + padding: 4px 10px; + background-color: ${({ $color }) => $color}; + color: white; + border-radius: 12px; + font-size: 12px; + font-weight: 600; `; const Count = styled.p` - font-size: 32px; + font-size: 24px; font-weight: 800; color: #db2777; - margin-bottom: 16px; + margin: 0; `; -const StyledButton = styled.button` - padding: 10px 20px; - background-color: #ec4899; - color: white; - border: none; +const ButtonRow = styled.div` + display: flex; + gap: 8px; +`; + +const StyledButton = styled.button<{ $variant?: 'outline' }>` + padding: 8px 16px; + background-color: ${({ $variant }) => + $variant === 'outline' ? 'transparent' : '#ec4899'}; + color: ${({ $variant }) => ($variant === 'outline' ? '#9d174d' : 'white')}; + border: ${({ $variant }) => + $variant === 'outline' ? '1px solid #ec4899' : 'none'}; border-radius: 6px; font-weight: 600; + font-size: 13px; cursor: pointer; `; @@ -38,16 +73,29 @@ const StyledComponentsComponent = () => { const [count, setCount] = useState(0); return ( - - Styled Components Example + + Styled Components + + CSS-in-JS with tagged templates and automatic critical CSS extraction. + + + Badge + Styled + Outline + Count: {count} - setCount((previous) => previous + 1)} - > - Increment - - + + setCount((previous) => previous + 1)} + > + Increment + + setCount(0)}> + Reset + + + ); }; diff --git a/packages/twenty-sdk/src/front-component-renderer/__stories__/example-sources/tailwind-example.front-component.tsx b/packages/twenty-sdk/src/front-component-renderer/__stories__/example-sources/tailwind-example.front-component.tsx index 6140f0131a..ffae741575 100644 --- a/packages/twenty-sdk/src/front-component-renderer/__stories__/example-sources/tailwind-example.front-component.tsx +++ b/packages/twenty-sdk/src/front-component-renderer/__stories__/example-sources/tailwind-example.front-component.tsx @@ -1,35 +1,46 @@ import { defineFrontComponent } from '@/sdk'; import { useState } from 'react'; -// Tailwind CSS subset (only the utilities used by this component) -// In a real setup, this would be generated by the Tailwind CLI at build time. const TAILWIND_CSS = ` -.p-5{padding:1.25rem} -.mb-4{margin-bottom:1rem} -.mb-2{margin-bottom:.5rem} -.space-y-3>:not(:first-child){margin-top:.75rem} -.rounded-lg{border-radius:.5rem} +*,*::before,*::after{box-sizing:border-box;margin:0;padding:0} +.p-6{padding:1.5rem} +.space-y-4>:not(:first-child){margin-top:1rem} +.rounded-xl{border-radius:.75rem} .rounded-md{border-radius:.375rem} -.border{border-width:1px} -.border-gray-200{border-color:#e5e7eb} -.bg-white{background-color:#fff} +.rounded-full{border-radius:9999px} +.border-2{border-width:2px} +.border-blue-400{border-color:#60a5fa} +.bg-blue-50{background-color:#eff6ff} .bg-blue-600{background-color:#2563eb} -.bg-blue-700{background-color:#1d4ed8} -.bg-gray-50{background-color:#f9fafb} +.bg-blue-100{background-color:#dbeafe} +.bg-purple-500{background-color:#a855f7} +.bg-orange-500{background-color:#f97316} +.bg-green-500{background-color:#22c55e} +.px-3{padding-left:.75rem;padding-right:.75rem} .px-4{padding-left:1rem;padding-right:1rem} +.py-1{padding-top:.25rem;padding-bottom:.25rem} .py-2{padding-top:.5rem;padding-bottom:.5rem} +.text-xs{font-size:.75rem;line-height:1rem} .text-sm{font-size:.875rem;line-height:1.25rem} .text-lg{font-size:1.125rem;line-height:1.75rem} .text-2xl{font-size:1.5rem;line-height:2rem} .font-semibold{font-weight:600} .font-bold{font-weight:700} -.text-gray-500{color:#6b7280} -.text-gray-900{color:#111827} +.font-extrabold{font-weight:800} +.text-blue-800{color:#1e40af} .text-blue-600{color:#2563eb} +.text-blue-700{color:#1d4ed8} .text-white{color:#fff} -.shadow-sm{box-shadow:0 1px 2px 0 rgb(0 0 0/.05)} .cursor-pointer{cursor:pointer} -*,*::before,*::after{box-sizing:border-box;margin:0;padding:0} +.max-w-sm{max-width:24rem} +.flex{display:flex} +.flex-col{flex-direction:column} +.flex-wrap{flex-wrap:wrap} +.gap-2{gap:.5rem} +.gap-4{gap:1rem} +.items-center{align-items:center} +.border{border-width:1px} +.border-blue-200{border-color:#bfdbfe} `; const TailwindComponent = () => { @@ -40,29 +51,45 @@ const TailwindComponent = () => {
-

- Tailwind CSS Component -

-

- This component uses Tailwind utility classes via className. +

Tailwind CSS

+

+ Utility-first CSS framework with atomic class composition.

-
- - Count: {count} +
+ + Badge + + + Styled + + + Utility
- + Count: {count} +

+
+ + +
); diff --git a/packages/twenty-sdk/src/front-component-renderer/__stories__/example-sources/twenty-ui-example.front-component.tsx b/packages/twenty-sdk/src/front-component-renderer/__stories__/example-sources/twenty-ui-example.front-component.tsx index 356c3df8a1..54a4646459 100644 --- a/packages/twenty-sdk/src/front-component-renderer/__stories__/example-sources/twenty-ui-example.front-component.tsx +++ b/packages/twenty-sdk/src/front-component-renderer/__stories__/example-sources/twenty-ui-example.front-component.tsx @@ -1,34 +1,79 @@ import { useState } from 'react'; import { defineFrontComponent } from '@/sdk'; -import { Button, Chip, ChipVariant, H2Title, Tag } from 'twenty-sdk/ui'; +import { + Button, + Chip, + ChipVariant, + H2Title, + Status, + Tag, + THEME_LIGHT, + ThemeProvider, +} from 'twenty-sdk/ui'; + +const CARD_STYLE = { + padding: 24, + display: 'flex', + flexDirection: 'column' as const, + gap: 16, + fontFamily: 'system-ui, sans-serif', + background: '#fafafa', + borderRadius: 12, + border: '2px solid #e4e4e7', + maxWidth: 360, +}; + +const ROW_STYLE = { + display: 'flex', + flexWrap: 'wrap' as const, + gap: 8, + alignItems: 'center' as const, +}; const TwentyUiComponent = () => { const [count, setCount] = useState(0); return ( -
- -
- - -
-
-
-
+ ); }; diff --git a/packages/twenty-sdk/src/front-component-renderer/__stories__/utils/getBuiltStoryComponentPathForRender.ts b/packages/twenty-sdk/src/front-component-renderer/__stories__/utils/getBuiltStoryComponentPathForRender.ts index e62b1fbe8e..5841ba2218 100644 --- a/packages/twenty-sdk/src/front-component-renderer/__stories__/utils/getBuiltStoryComponentPathForRender.ts +++ b/packages/twenty-sdk/src/front-component-renderer/__stories__/utils/getBuiltStoryComponentPathForRender.ts @@ -1,9 +1,11 @@ -// Returns an absolute URL because the worker runs inside a Blob URL -// where relative paths cannot be resolved. +type StoryComponentVariant = 'react' | 'preact'; + export const getBuiltStoryComponentPathForRender = ( componentName: string, + variant: StoryComponentVariant = 'react', ): string => { const origin = typeof window !== 'undefined' ? window.location.origin : ''; + const basePath = variant === 'preact' ? '/built-preact' : '/built'; - return `${origin}/built/${componentName}.mjs`; + return `${origin}${basePath}/${componentName}.mjs`; }; diff --git a/packages/twenty-sdk/src/front-component-renderer/host/components/RemoteStyleRenderer.tsx b/packages/twenty-sdk/src/front-component-renderer/host/components/RemoteStyleRenderer.tsx new file mode 100644 index 0000000000..4333838462 --- /dev/null +++ b/packages/twenty-sdk/src/front-component-renderer/host/components/RemoteStyleRenderer.tsx @@ -0,0 +1,34 @@ +import { useEffect, useRef } from 'react'; +import { isDefined } from 'twenty-shared/utils'; + +type RemoteStyleRendererProps = { + cssText?: string; + styleKey?: string; +}; + +export const RemoteStyleRenderer = ({ + cssText, + styleKey, +}: RemoteStyleRendererProps) => { + const styleRef = useRef(null); + + useEffect(() => { + const styleElement = document.createElement('style'); + styleElement.setAttribute('data-remote-style', styleKey ?? ''); + document.head.appendChild(styleElement); + styleRef.current = styleElement; + + return () => { + document.head.removeChild(styleElement); + styleRef.current = null; + }; + }, [styleKey]); + + useEffect(() => { + if (isDefined(styleRef.current) && isDefined(cssText)) { + styleRef.current.textContent = cssText; + } + }, [cssText]); + + return null; +}; diff --git a/packages/twenty-sdk/src/front-component-renderer/host/generated/host-component-registry.ts b/packages/twenty-sdk/src/front-component-renderer/host/generated/host-component-registry.ts index 4401aa98d2..ea4e485b16 100644 --- a/packages/twenty-sdk/src/front-component-renderer/host/generated/host-component-registry.ts +++ b/packages/twenty-sdk/src/front-component-renderer/host/generated/host-component-registry.ts @@ -1,454 +1,87 @@ -/* - * _____ _ - *|_ _|_ _____ _ __ | |_ _ _ - * | | \ \ /\ / / _ \ '_ \| __| | | | Auto-generated file - * | | \ V V / __/ | | | |_| |_| | Any edits to this will be overridden - * |_| \_/\_/ \___|_| |_|\__|\__, | - * |___/ - */ - -import React from 'react'; import { RemoteFragmentRenderer, createRemoteComponentRenderer, } from '@remote-dom/react/host'; -import { type SerializedEventData } from '../../../sdk/front-component-api/constants/SerializedEventData'; -const INTERNAL_PROPS = new Set(['element', 'receiver', 'components']); - -const EVENT_NAME_MAP: Record = { - onclick: 'onClick', - ondblclick: 'onDoubleClick', - onmousedown: 'onMouseDown', - onmouseup: 'onMouseUp', - onmouseover: 'onMouseOver', - onmouseout: 'onMouseOut', - onmouseenter: 'onMouseEnter', - onmouseleave: 'onMouseLeave', - onkeydown: 'onKeyDown', - onkeyup: 'onKeyUp', - onkeypress: 'onKeyPress', - onfocus: 'onFocus', - onblur: 'onBlur', - onchange: 'onChange', - oninput: 'onInput', - onsubmit: 'onSubmit', - onscroll: 'onScroll', - onwheel: 'onWheel', - oncontextmenu: 'onContextMenu', - ondrag: 'onDrag', -}; - -const parseStyle = ( - styleString: string | undefined, -): React.CSSProperties | undefined => { - if (!styleString || typeof styleString !== 'string') { - return styleString as React.CSSProperties | undefined; - } - - const style: Record = {}; - const declarations = styleString.split(';').filter(Boolean); - - for (const declaration of declarations) { - const colonIndex = declaration.indexOf(':'); - if (colonIndex === -1) continue; - - const property = declaration.slice(0, colonIndex).trim(); - const value = declaration.slice(colonIndex + 1).trim(); - - const camelProperty = property.replace(/-([a-z])/g, (_, letter: string) => - letter.toUpperCase(), - ); - style[camelProperty] = value; - } - - return style; -}; - -const serializeEvent = (event: unknown): SerializedEventData => { - if (!event || typeof event !== 'object') { - return { type: 'unknown' }; - } - - const domEvent = event as Record; - const serialized: SerializedEventData = { - type: typeof domEvent.type === 'string' ? domEvent.type : 'unknown', - }; - - if ('altKey' in domEvent) serialized.altKey = domEvent.altKey as boolean; - if ('ctrlKey' in domEvent) serialized.ctrlKey = domEvent.ctrlKey as boolean; - if ('metaKey' in domEvent) serialized.metaKey = domEvent.metaKey as boolean; - if ('shiftKey' in domEvent) - serialized.shiftKey = domEvent.shiftKey as boolean; - - if ('clientX' in domEvent) serialized.clientX = domEvent.clientX as number; - if ('clientY' in domEvent) serialized.clientY = domEvent.clientY as number; - if ('pageX' in domEvent) serialized.pageX = domEvent.pageX as number; - if ('pageY' in domEvent) serialized.pageY = domEvent.pageY as number; - if ('screenX' in domEvent) serialized.screenX = domEvent.screenX as number; - if ('screenY' in domEvent) serialized.screenY = domEvent.screenY as number; - if ('button' in domEvent) serialized.button = domEvent.button as number; - if ('buttons' in domEvent) serialized.buttons = domEvent.buttons as number; - - if ('key' in domEvent) serialized.key = domEvent.key as string; - if ('code' in domEvent) serialized.code = domEvent.code as string; - if ('repeat' in domEvent) serialized.repeat = domEvent.repeat as boolean; - - if ('deltaX' in domEvent) serialized.deltaX = domEvent.deltaX as number; - if ('deltaY' in domEvent) serialized.deltaY = domEvent.deltaY as number; - if ('deltaZ' in domEvent) serialized.deltaZ = domEvent.deltaZ as number; - if ('deltaMode' in domEvent) - serialized.deltaMode = domEvent.deltaMode as number; - - const target = domEvent.target as Record | undefined; - if (target && typeof target === 'object') { - if ('value' in target && typeof target.value === 'string') { - serialized.value = target.value; - } - if ('checked' in target && typeof target.checked === 'boolean') { - serialized.checked = target.checked; - } - if ('scrollTop' in target && typeof target.scrollTop === 'number') { - serialized.scrollTop = target.scrollTop; - } - if ('scrollLeft' in target && typeof target.scrollLeft === 'number') { - serialized.scrollLeft = target.scrollLeft; - } - } - - return serialized; -}; - -const wrapEventHandler = (handler: (detail: SerializedEventData) => void) => { - return (event: unknown) => { - handler(serializeEvent(event)); - }; -}; - -const filterProps = (props: T): T => { - const filtered: Record = {}; - for (const [key, value] of Object.entries(props)) { - if (INTERNAL_PROPS.has(key) || value === undefined) continue; - - if (key === 'style') { - filtered.style = parseStyle(value as string | undefined); - } else { - const normalizedKey = EVENT_NAME_MAP[key.toLowerCase()] || key; - if (normalizedKey.startsWith('on') && typeof value === 'function') { - filtered[normalizedKey] = wrapEventHandler( - value as (detail: SerializedEventData) => void, - ); - } else { - filtered[normalizedKey] = value; - } - } - } - return filtered as T; -}; -const HtmlDivWrapper = ({ - children, - ...props -}: { children?: React.ReactNode } & Record) => { - return React.createElement('div', filterProps(props), children); -}; -const HtmlSpanWrapper = ({ - children, - ...props -}: { children?: React.ReactNode } & Record) => { - return React.createElement('span', filterProps(props), children); -}; -const HtmlSectionWrapper = ({ - children, - ...props -}: { children?: React.ReactNode } & Record) => { - return React.createElement('section', filterProps(props), children); -}; -const HtmlArticleWrapper = ({ - children, - ...props -}: { children?: React.ReactNode } & Record) => { - return React.createElement('article', filterProps(props), children); -}; -const HtmlHeaderWrapper = ({ - children, - ...props -}: { children?: React.ReactNode } & Record) => { - return React.createElement('header', filterProps(props), children); -}; -const HtmlFooterWrapper = ({ - children, - ...props -}: { children?: React.ReactNode } & Record) => { - return React.createElement('footer', filterProps(props), children); -}; -const HtmlMainWrapper = ({ - children, - ...props -}: { children?: React.ReactNode } & Record) => { - return React.createElement('main', filterProps(props), children); -}; -const HtmlNavWrapper = ({ - children, - ...props -}: { children?: React.ReactNode } & Record) => { - return React.createElement('nav', filterProps(props), children); -}; -const HtmlAsideWrapper = ({ - children, - ...props -}: { children?: React.ReactNode } & Record) => { - return React.createElement('aside', filterProps(props), children); -}; -const HtmlPWrapper = ({ - children, - ...props -}: { children?: React.ReactNode } & Record) => { - return React.createElement('p', filterProps(props), children); -}; -const HtmlH1Wrapper = ({ - children, - ...props -}: { children?: React.ReactNode } & Record) => { - return React.createElement('h1', filterProps(props), children); -}; -const HtmlH2Wrapper = ({ - children, - ...props -}: { children?: React.ReactNode } & Record) => { - return React.createElement('h2', filterProps(props), children); -}; -const HtmlH3Wrapper = ({ - children, - ...props -}: { children?: React.ReactNode } & Record) => { - return React.createElement('h3', filterProps(props), children); -}; -const HtmlH4Wrapper = ({ - children, - ...props -}: { children?: React.ReactNode } & Record) => { - return React.createElement('h4', filterProps(props), children); -}; -const HtmlH5Wrapper = ({ - children, - ...props -}: { children?: React.ReactNode } & Record) => { - return React.createElement('h5', filterProps(props), children); -}; -const HtmlH6Wrapper = ({ - children, - ...props -}: { children?: React.ReactNode } & Record) => { - return React.createElement('h6', filterProps(props), children); -}; -const HtmlStrongWrapper = ({ - children, - ...props -}: { children?: React.ReactNode } & Record) => { - return React.createElement('strong', filterProps(props), children); -}; -const HtmlEmWrapper = ({ - children, - ...props -}: { children?: React.ReactNode } & Record) => { - return React.createElement('em', filterProps(props), children); -}; -const HtmlSmallWrapper = ({ - children, - ...props -}: { children?: React.ReactNode } & Record) => { - return React.createElement('small', filterProps(props), children); -}; -const HtmlCodeWrapper = ({ - children, - ...props -}: { children?: React.ReactNode } & Record) => { - return React.createElement('code', filterProps(props), children); -}; -const HtmlPreWrapper = ({ - children, - ...props -}: { children?: React.ReactNode } & Record) => { - return React.createElement('pre', filterProps(props), children); -}; -const HtmlBlockquoteWrapper = ({ - children, - ...props -}: { children?: React.ReactNode } & Record) => { - return React.createElement('blockquote', filterProps(props), children); -}; -const HtmlAWrapper = ({ - children, - ...props -}: { children?: React.ReactNode } & Record) => { - return React.createElement('a', filterProps(props), children); -}; -const HtmlImgWrapper = ({ - children: _children, - ...props -}: { children?: React.ReactNode } & Record) => { - return React.createElement('img', filterProps(props)); -}; -const HtmlUlWrapper = ({ - children, - ...props -}: { children?: React.ReactNode } & Record) => { - return React.createElement('ul', filterProps(props), children); -}; -const HtmlOlWrapper = ({ - children, - ...props -}: { children?: React.ReactNode } & Record) => { - return React.createElement('ol', filterProps(props), children); -}; -const HtmlLiWrapper = ({ - children, - ...props -}: { children?: React.ReactNode } & Record) => { - return React.createElement('li', filterProps(props), children); -}; -const HtmlFormWrapper = ({ - children, - ...props -}: { children?: React.ReactNode } & Record) => { - return React.createElement('form', filterProps(props), children); -}; -const HtmlLabelWrapper = ({ - children, - ...props -}: { children?: React.ReactNode } & Record) => { - return React.createElement('label', filterProps(props), children); -}; -const HtmlInputWrapper = ({ - children: _children, - ...props -}: { children?: React.ReactNode } & Record) => { - return React.createElement('input', filterProps(props)); -}; -const HtmlTextareaWrapper = ({ - children, - ...props -}: { children?: React.ReactNode } & Record) => { - return React.createElement('textarea', filterProps(props), children); -}; -const HtmlSelectWrapper = ({ - children, - ...props -}: { children?: React.ReactNode } & Record) => { - return React.createElement('select', filterProps(props), children); -}; -const HtmlOptionWrapper = ({ - children, - ...props -}: { children?: React.ReactNode } & Record) => { - return React.createElement('option', filterProps(props), children); -}; -const HtmlButtonWrapper = ({ - children, - ...props -}: { children?: React.ReactNode } & Record) => { - return React.createElement('button', filterProps(props), children); -}; -const HtmlTableWrapper = ({ - children, - ...props -}: { children?: React.ReactNode } & Record) => { - return React.createElement('table', filterProps(props), children); -}; -const HtmlTheadWrapper = ({ - children, - ...props -}: { children?: React.ReactNode } & Record) => { - return React.createElement('thead', filterProps(props), children); -}; -const HtmlTbodyWrapper = ({ - children, - ...props -}: { children?: React.ReactNode } & Record) => { - return React.createElement('tbody', filterProps(props), children); -}; -const HtmlTfootWrapper = ({ - children, - ...props -}: { children?: React.ReactNode } & Record) => { - return React.createElement('tfoot', filterProps(props), children); -}; -const HtmlTrWrapper = ({ - children, - ...props -}: { children?: React.ReactNode } & Record) => { - return React.createElement('tr', filterProps(props), children); -}; -const HtmlThWrapper = ({ - children, - ...props -}: { children?: React.ReactNode } & Record) => { - return React.createElement('th', filterProps(props), children); -}; -const HtmlTdWrapper = ({ - children, - ...props -}: { children?: React.ReactNode } & Record) => { - return React.createElement('td', filterProps(props), children); -}; -const HtmlBrWrapper = ({ - children: _children, - ...props -}: { children?: React.ReactNode } & Record) => { - return React.createElement('br', filterProps(props)); -}; -const HtmlHrWrapper = ({ - children: _children, - ...props -}: { children?: React.ReactNode } & Record) => { - return React.createElement('hr', filterProps(props)); -}; +import { createHtmlHostWrapper } from '../utils/createHtmlHostWrapper'; +import { RemoteStyleRenderer } from '../components/RemoteStyleRenderer'; type ComponentRegistryValue = | ReturnType | typeof RemoteFragmentRenderer; export const componentRegistry: Map = new Map([ - ['html-div', createRemoteComponentRenderer(HtmlDivWrapper)], - ['html-span', createRemoteComponentRenderer(HtmlSpanWrapper)], - ['html-section', createRemoteComponentRenderer(HtmlSectionWrapper)], - ['html-article', createRemoteComponentRenderer(HtmlArticleWrapper)], - ['html-header', createRemoteComponentRenderer(HtmlHeaderWrapper)], - ['html-footer', createRemoteComponentRenderer(HtmlFooterWrapper)], - ['html-main', createRemoteComponentRenderer(HtmlMainWrapper)], - ['html-nav', createRemoteComponentRenderer(HtmlNavWrapper)], - ['html-aside', createRemoteComponentRenderer(HtmlAsideWrapper)], - ['html-p', createRemoteComponentRenderer(HtmlPWrapper)], - ['html-h1', createRemoteComponentRenderer(HtmlH1Wrapper)], - ['html-h2', createRemoteComponentRenderer(HtmlH2Wrapper)], - ['html-h3', createRemoteComponentRenderer(HtmlH3Wrapper)], - ['html-h4', createRemoteComponentRenderer(HtmlH4Wrapper)], - ['html-h5', createRemoteComponentRenderer(HtmlH5Wrapper)], - ['html-h6', createRemoteComponentRenderer(HtmlH6Wrapper)], - ['html-strong', createRemoteComponentRenderer(HtmlStrongWrapper)], - ['html-em', createRemoteComponentRenderer(HtmlEmWrapper)], - ['html-small', createRemoteComponentRenderer(HtmlSmallWrapper)], - ['html-code', createRemoteComponentRenderer(HtmlCodeWrapper)], - ['html-pre', createRemoteComponentRenderer(HtmlPreWrapper)], - ['html-blockquote', createRemoteComponentRenderer(HtmlBlockquoteWrapper)], - ['html-a', createRemoteComponentRenderer(HtmlAWrapper)], - ['html-img', createRemoteComponentRenderer(HtmlImgWrapper)], - ['html-ul', createRemoteComponentRenderer(HtmlUlWrapper)], - ['html-ol', createRemoteComponentRenderer(HtmlOlWrapper)], - ['html-li', createRemoteComponentRenderer(HtmlLiWrapper)], - ['html-form', createRemoteComponentRenderer(HtmlFormWrapper)], - ['html-label', createRemoteComponentRenderer(HtmlLabelWrapper)], - ['html-input', createRemoteComponentRenderer(HtmlInputWrapper)], - ['html-textarea', createRemoteComponentRenderer(HtmlTextareaWrapper)], - ['html-select', createRemoteComponentRenderer(HtmlSelectWrapper)], - ['html-option', createRemoteComponentRenderer(HtmlOptionWrapper)], - ['html-button', createRemoteComponentRenderer(HtmlButtonWrapper)], - ['html-table', createRemoteComponentRenderer(HtmlTableWrapper)], - ['html-thead', createRemoteComponentRenderer(HtmlTheadWrapper)], - ['html-tbody', createRemoteComponentRenderer(HtmlTbodyWrapper)], - ['html-tfoot', createRemoteComponentRenderer(HtmlTfootWrapper)], - ['html-tr', createRemoteComponentRenderer(HtmlTrWrapper)], - ['html-th', createRemoteComponentRenderer(HtmlThWrapper)], - ['html-td', createRemoteComponentRenderer(HtmlTdWrapper)], - ['html-br', createRemoteComponentRenderer(HtmlBrWrapper)], - ['html-hr', createRemoteComponentRenderer(HtmlHrWrapper)], + ['html-div', createRemoteComponentRenderer(createHtmlHostWrapper('div'))], + ['html-span', createRemoteComponentRenderer(createHtmlHostWrapper('span'))], + [ + 'html-section', + createRemoteComponentRenderer(createHtmlHostWrapper('section')), + ], + [ + 'html-article', + createRemoteComponentRenderer(createHtmlHostWrapper('article')), + ], + [ + 'html-header', + createRemoteComponentRenderer(createHtmlHostWrapper('header')), + ], + [ + 'html-footer', + createRemoteComponentRenderer(createHtmlHostWrapper('footer')), + ], + ['html-main', createRemoteComponentRenderer(createHtmlHostWrapper('main'))], + ['html-nav', createRemoteComponentRenderer(createHtmlHostWrapper('nav'))], + ['html-aside', createRemoteComponentRenderer(createHtmlHostWrapper('aside'))], + ['html-p', createRemoteComponentRenderer(createHtmlHostWrapper('p'))], + ['html-h1', createRemoteComponentRenderer(createHtmlHostWrapper('h1'))], + ['html-h2', createRemoteComponentRenderer(createHtmlHostWrapper('h2'))], + ['html-h3', createRemoteComponentRenderer(createHtmlHostWrapper('h3'))], + ['html-h4', createRemoteComponentRenderer(createHtmlHostWrapper('h4'))], + ['html-h5', createRemoteComponentRenderer(createHtmlHostWrapper('h5'))], + ['html-h6', createRemoteComponentRenderer(createHtmlHostWrapper('h6'))], + [ + 'html-strong', + createRemoteComponentRenderer(createHtmlHostWrapper('strong')), + ], + ['html-em', createRemoteComponentRenderer(createHtmlHostWrapper('em'))], + ['html-small', createRemoteComponentRenderer(createHtmlHostWrapper('small'))], + ['html-code', createRemoteComponentRenderer(createHtmlHostWrapper('code'))], + ['html-pre', createRemoteComponentRenderer(createHtmlHostWrapper('pre'))], + [ + 'html-blockquote', + createRemoteComponentRenderer(createHtmlHostWrapper('blockquote')), + ], + ['html-a', createRemoteComponentRenderer(createHtmlHostWrapper('a'))], + ['html-img', createRemoteComponentRenderer(createHtmlHostWrapper('img'))], + ['html-ul', createRemoteComponentRenderer(createHtmlHostWrapper('ul'))], + ['html-ol', createRemoteComponentRenderer(createHtmlHostWrapper('ol'))], + ['html-li', createRemoteComponentRenderer(createHtmlHostWrapper('li'))], + ['html-form', createRemoteComponentRenderer(createHtmlHostWrapper('form'))], + ['html-label', createRemoteComponentRenderer(createHtmlHostWrapper('label'))], + ['html-input', createRemoteComponentRenderer(createHtmlHostWrapper('input'))], + [ + 'html-textarea', + createRemoteComponentRenderer(createHtmlHostWrapper('textarea')), + ], + [ + 'html-select', + createRemoteComponentRenderer(createHtmlHostWrapper('select')), + ], + [ + 'html-option', + createRemoteComponentRenderer(createHtmlHostWrapper('option')), + ], + [ + 'html-button', + createRemoteComponentRenderer(createHtmlHostWrapper('button')), + ], + ['html-table', createRemoteComponentRenderer(createHtmlHostWrapper('table'))], + ['html-thead', createRemoteComponentRenderer(createHtmlHostWrapper('thead'))], + ['html-tbody', createRemoteComponentRenderer(createHtmlHostWrapper('tbody'))], + ['html-tfoot', createRemoteComponentRenderer(createHtmlHostWrapper('tfoot'))], + ['html-tr', createRemoteComponentRenderer(createHtmlHostWrapper('tr'))], + ['html-th', createRemoteComponentRenderer(createHtmlHostWrapper('th'))], + ['html-td', createRemoteComponentRenderer(createHtmlHostWrapper('td'))], + ['html-br', createRemoteComponentRenderer(createHtmlHostWrapper('br'))], + ['html-hr', createRemoteComponentRenderer(createHtmlHostWrapper('hr'))], + ['remote-style', createRemoteComponentRenderer(RemoteStyleRenderer)], ['remote-fragment', RemoteFragmentRenderer], ]); diff --git a/packages/twenty-sdk/src/front-component-renderer/host/generated/host-index.ts b/packages/twenty-sdk/src/front-component-renderer/host/generated/host-index.ts deleted file mode 100644 index b77a7d7ffc..0000000000 --- a/packages/twenty-sdk/src/front-component-renderer/host/generated/host-index.ts +++ /dev/null @@ -1,10 +0,0 @@ -/* - * _____ _ - *|_ _|_ _____ _ __ | |_ _ _ - * | | \ \ /\ / / _ \ '_ \| __| | | | Auto-generated file - * | | \ V V / __/ | | | |_| |_| | Any edits to this will be overridden - * |_| \_/\_/ \___|_| |_|\__|\__, | - * |___/ - */ - -export { componentRegistry } from './host-component-registry'; diff --git a/packages/twenty-sdk/src/front-component-renderer/host/utils/createHtmlHostWrapper.ts b/packages/twenty-sdk/src/front-component-renderer/host/utils/createHtmlHostWrapper.ts new file mode 100644 index 0000000000..245a892bde --- /dev/null +++ b/packages/twenty-sdk/src/front-component-renderer/host/utils/createHtmlHostWrapper.ts @@ -0,0 +1,152 @@ +import React from 'react'; + +import { EVENT_TO_REACT } from '@/sdk/front-component-api/constants/EventToReact'; +import { type SerializedEventData } from '@/sdk/front-component-api/constants/SerializedEventData'; + +const INTERNAL_PROPS = new Set(['element', 'receiver', 'components']); + +const EVENT_NAME_MAP: Record = Object.fromEntries( + Object.entries(EVENT_TO_REACT).map(([domEvent, reactProp]) => [ + `on${domEvent}`, + reactProp, + ]), +); + +const VOID_ELEMENTS = new Set([ + 'input', + 'br', + 'hr', + 'img', + 'area', + 'base', + 'col', + 'embed', + 'link', + 'meta', + 'source', + 'track', + 'wbr', +]); + +const parseCssString = ( + styleString: string | undefined, +): React.CSSProperties | undefined => { + if (!styleString || typeof styleString !== 'string') { + return styleString as React.CSSProperties | undefined; + } + + const style: Record = {}; + const declarations = styleString.split(';').filter(Boolean); + + for (const declaration of declarations) { + const colonIndex = declaration.indexOf(':'); + if (colonIndex === -1) continue; + + const property = declaration.slice(0, colonIndex).trim(); + const value = declaration.slice(colonIndex + 1).trim(); + + const camelProperty = property.replace(/-([a-z])/g, (_, letter: string) => + letter.toUpperCase(), + ); + style[camelProperty] = value; + } + + return style; +}; + +const serializeEvent = (event: unknown): SerializedEventData => { + if (!event || typeof event !== 'object') { + return { type: 'unknown' }; + } + + const domEvent = event as Record; + const serialized: SerializedEventData = { + type: typeof domEvent.type === 'string' ? domEvent.type : 'unknown', + }; + + if ('altKey' in domEvent) serialized.altKey = domEvent.altKey as boolean; + if ('ctrlKey' in domEvent) serialized.ctrlKey = domEvent.ctrlKey as boolean; + if ('metaKey' in domEvent) serialized.metaKey = domEvent.metaKey as boolean; + if ('shiftKey' in domEvent) + serialized.shiftKey = domEvent.shiftKey as boolean; + + if ('clientX' in domEvent) serialized.clientX = domEvent.clientX as number; + if ('clientY' in domEvent) serialized.clientY = domEvent.clientY as number; + if ('pageX' in domEvent) serialized.pageX = domEvent.pageX as number; + if ('pageY' in domEvent) serialized.pageY = domEvent.pageY as number; + if ('screenX' in domEvent) serialized.screenX = domEvent.screenX as number; + if ('screenY' in domEvent) serialized.screenY = domEvent.screenY as number; + if ('button' in domEvent) serialized.button = domEvent.button as number; + if ('buttons' in domEvent) serialized.buttons = domEvent.buttons as number; + + if ('key' in domEvent) serialized.key = domEvent.key as string; + if ('code' in domEvent) serialized.code = domEvent.code as string; + if ('repeat' in domEvent) serialized.repeat = domEvent.repeat as boolean; + + if ('deltaX' in domEvent) serialized.deltaX = domEvent.deltaX as number; + if ('deltaY' in domEvent) serialized.deltaY = domEvent.deltaY as number; + if ('deltaZ' in domEvent) serialized.deltaZ = domEvent.deltaZ as number; + if ('deltaMode' in domEvent) + serialized.deltaMode = domEvent.deltaMode as number; + + const target = domEvent.target as Record | undefined; + if (target && typeof target === 'object') { + if ('value' in target && typeof target.value === 'string') { + serialized.value = target.value; + } + if ('checked' in target && typeof target.checked === 'boolean') { + serialized.checked = target.checked; + } + if ('scrollTop' in target && typeof target.scrollTop === 'number') { + serialized.scrollTop = target.scrollTop; + } + if ('scrollLeft' in target && typeof target.scrollLeft === 'number') { + serialized.scrollLeft = target.scrollLeft; + } + } + + return serialized; +}; + +const wrapEventHandler = (handler: (detail: SerializedEventData) => void) => { + return (event: unknown) => { + handler(serializeEvent(event)); + }; +}; + +const filterProps = (props: T): T => { + const filtered: Record = {}; + + for (const [key, value] of Object.entries(props)) { + if (INTERNAL_PROPS.has(key) || value === undefined) continue; + + if (key === 'style') { + filtered.style = parseCssString(value as string | undefined); + } else { + const normalizedKey = EVENT_NAME_MAP[key.toLowerCase()] || key; + + if (normalizedKey.startsWith('on') && typeof value === 'function') { + filtered[normalizedKey] = wrapEventHandler( + value as (detail: SerializedEventData) => void, + ); + } else { + filtered[normalizedKey] = value; + } + } + } + + return filtered as T; +}; + +type WrapperProps = { children?: React.ReactNode } & Record; + +export const createHtmlHostWrapper = (htmlTag: string) => { + const isVoid = VOID_ELEMENTS.has(htmlTag); + + return ({ children, ...props }: WrapperProps) => + React.createElement( + htmlTag, + filterProps(props), + isVoid ? undefined : children, + ); +}; diff --git a/packages/twenty-sdk/src/front-component-renderer/polyfills/MockCSSStyleSheet.ts b/packages/twenty-sdk/src/front-component-renderer/polyfills/MockCSSStyleSheet.ts new file mode 100644 index 0000000000..bf76cfb165 --- /dev/null +++ b/packages/twenty-sdk/src/front-component-renderer/polyfills/MockCSSStyleSheet.ts @@ -0,0 +1,41 @@ +type OnChangeCallback = (cssText: string) => void; + +class MockCSSRule { + cssText: string; + + constructor(cssText: string) { + this.cssText = cssText; + } +} + +class MockCSSRuleList extends Array { + item(index: number): MockCSSRule | null { + return this[index] ?? null; + } +} + +export class MockCSSStyleSheet { + cssRules = new MockCSSRuleList(); + private onChange: OnChangeCallback; + + constructor(onChange: OnChangeCallback) { + this.onChange = onChange; + } + + insertRule(rule: string, index?: number): number { + const insertAt = index ?? this.cssRules.length; + this.cssRules.splice(insertAt, 0, new MockCSSRule(rule)); + this.notify(); + return insertAt; + } + + deleteRule(index: number): void { + this.cssRules.splice(index, 1); + this.notify(); + } + + private notify(): void { + const cssText = this.cssRules.map((rule) => rule.cssText).join('\n'); + this.onChange(cssText); + } +} diff --git a/packages/twenty-sdk/src/front-component-renderer/polyfills/installStyleBridge.ts b/packages/twenty-sdk/src/front-component-renderer/polyfills/installStyleBridge.ts new file mode 100644 index 0000000000..64d7ba1cae --- /dev/null +++ b/packages/twenty-sdk/src/front-component-renderer/polyfills/installStyleBridge.ts @@ -0,0 +1,252 @@ +import { type RemoteRootElement } from '@remote-dom/core/elements'; + +import { type RemoteStyleProperties } from '@/front-component-renderer/remote/generated/remote-elements'; +import { MockCSSStyleSheet } from './MockCSSStyleSheet'; + +export const installStyleBridge = (remoteRoot: RemoteRootElement): void => { + const styleElementMap = new WeakMap< + Element, + Element & RemoteStyleProperties + >(); + const styleObserverMap = new WeakMap void }>(); + + const trackStyleElement = (styleElement: Element): void => { + if (styleElementMap.has(styleElement)) { + return; + } + + const remoteStyleElement = document.createElement( + 'remote-style', + ) as Element & RemoteStyleProperties; + + styleElementMap.set(styleElement, remoteStyleElement); + + const attributeNames = styleElement.getAttributeNames?.() ?? []; + const dataAttributes = attributeNames + .filter((attributeName: string) => attributeName.startsWith('data-')) + .map( + (attributeName: string) => + `${attributeName}=${styleElement.getAttribute(attributeName) ?? ''}`, + ) + .join(';'); + + if (dataAttributes.length > 0) { + remoteStyleElement.styleKey = dataAttributes; + } + + const syncCssFromStyleElement = () => { + remoteStyleElement.cssText = styleElement.textContent ?? ''; + }; + + const mockSheet = new MockCSSStyleSheet((cssText: string) => { + remoteStyleElement.cssText = cssText; + }); + + try { + Object.defineProperty(styleElement, 'sheet', { + get: () => mockSheet, + configurable: true, + }); + } catch { + void 0; + } + + const prototypeChain = Object.getPrototypeOf(styleElement); + const textContentDescriptor = + Object.getOwnPropertyDescriptor(styleElement, 'textContent') ?? + Object.getOwnPropertyDescriptor(prototypeChain, 'textContent') ?? + Object.getOwnPropertyDescriptor( + Object.getPrototypeOf(prototypeChain), + 'textContent', + ); + + if (textContentDescriptor?.set) { + const originalTextContentSet = textContentDescriptor.set; + try { + Object.defineProperty(styleElement, 'textContent', { + get: textContentDescriptor.get, + set(value: string) { + originalTextContentSet.call(this, value); + remoteStyleElement.cssText = value ?? ''; + }, + configurable: true, + }); + } catch { + void 0; + } + } + + const originalAppendChild = styleElement.appendChild.bind(styleElement); + try { + ( + styleElement as Element & { appendChild: typeof originalAppendChild } + ).appendChild = (child: Node) => { + const result = originalAppendChild(child); + syncCssFromStyleElement(); + return result; + }; + } catch { + void 0; + } + + const originalInsertBeforeOnStyle = + styleElement.insertBefore.bind(styleElement); + try { + ( + styleElement as Element & { + insertBefore: typeof originalInsertBeforeOnStyle; + } + ).insertBefore = (child: T, ref: Node | null): T => { + const result = originalInsertBeforeOnStyle(child, ref); + syncCssFromStyleElement(); + return result; + }; + } catch { + void 0; + } + + const originalRemoveChildOnStyle = + styleElement.removeChild.bind(styleElement); + try { + ( + styleElement as Element & { + removeChild: typeof originalRemoveChildOnStyle; + } + ).removeChild = (child: T): T => { + const result = originalRemoveChildOnStyle(child); + syncCssFromStyleElement(); + return result; + }; + } catch { + void 0; + } + + if (typeof MutationObserver === 'function') { + try { + const styleObserver = new MutationObserver(() => { + syncCssFromStyleElement(); + }); + + if (typeof styleObserver.observe === 'function') { + styleObserver.observe(styleElement, { + subtree: true, + childList: true, + characterData: true, + }); + styleObserverMap.set(styleElement, styleObserver); + } + } catch { + void 0; + } + } + + const existingContent = styleElement.textContent; + if (existingContent) { + remoteStyleElement.cssText = existingContent; + } + + remoteRoot.appendChild(remoteStyleElement); + }; + + const untrackStyleElement = (styleElement: Element): void => { + const styleObserver = styleObserverMap.get(styleElement); + if (styleObserver) { + styleObserver.disconnect(); + styleObserverMap.delete(styleElement); + } + + const remoteStyleElement = styleElementMap.get(styleElement); + + if (remoteStyleElement && remoteStyleElement.parentNode) { + remoteStyleElement.parentNode.removeChild(remoteStyleElement); + styleElementMap.delete(styleElement); + } + }; + + const headElement = document.head; + const originalAppendChild = headElement.appendChild.bind(headElement); + const originalInsertBefore = headElement.insertBefore.bind(headElement); + const originalAppend = headElement.append.bind(headElement); + const originalPrepend = headElement.prepend.bind(headElement); + const originalReplaceChild = headElement.replaceChild.bind(headElement); + const originalRemoveChild = headElement.removeChild.bind(headElement); + + headElement.appendChild = (child: T): T => { + const result = originalAppendChild(child); + if (isStyleElement(child)) { + trackStyleElement(child as unknown as Element); + } + return result; + }; + + headElement.insertBefore = ( + child: T, + ref: Node | null, + ): T => { + const result = originalInsertBefore(child, ref); + if (isStyleElement(child)) { + trackStyleElement(child as unknown as Element); + } + return result; + }; + + headElement.append = (...nodes: (Node | string)[]) => { + originalAppend(...nodes); + for (const node of nodes) { + if (isStyleElement(node)) { + trackStyleElement(node as Element); + } + } + }; + + headElement.prepend = (...nodes: (Node | string)[]) => { + originalPrepend(...nodes); + for (const node of nodes) { + if (isStyleElement(node)) { + trackStyleElement(node as Element); + } + } + }; + + headElement.replaceChild = ( + newChild: Node, + oldChild: T, + ): T => { + const result = originalReplaceChild(newChild, oldChild); + if (isStyleElement(oldChild)) { + untrackStyleElement(oldChild as unknown as Element); + } + if (isStyleElement(newChild)) { + trackStyleElement(newChild as unknown as Element); + } + return result; + }; + + headElement.removeChild = (child: T): T => { + const result = originalRemoveChild(child); + if (isStyleElement(child)) { + untrackStyleElement(child as unknown as Element); + } + return result; + }; + + const existingStyleElements = Array.from( + headElement.querySelectorAll('style'), + ); + for (const styleElement of existingStyleElements) { + trackStyleElement(styleElement); + } + + Object.defineProperty(document, 'styleSheets', { + get: () => [], + configurable: true, + }); +}; + +const isStyleElement = (node: Node | string): node is Element => { + return ( + typeof node !== 'string' && + 'localName' in node && + node.localName === 'style' + ); +}; diff --git a/packages/twenty-sdk/src/front-component-renderer/remote/components/FrontComponentWorkerEffect.tsx b/packages/twenty-sdk/src/front-component-renderer/remote/components/FrontComponentWorkerEffect.tsx index 3b2b202e9b..2f2e2c00d3 100644 --- a/packages/twenty-sdk/src/front-component-renderer/remote/components/FrontComponentWorkerEffect.tsx +++ b/packages/twenty-sdk/src/front-component-renderer/remote/components/FrontComponentWorkerEffect.tsx @@ -49,7 +49,6 @@ export const FrontComponentWorkerEffect = ({ setError(workerError); }; - // Expose host functions to the worker via stable refs to avoid recreating threads const stableFrontComponentHostCommunicationApi: FrontComponentHostCommunicationApi = { navigate: (...args) => diff --git a/packages/twenty-sdk/src/front-component-renderer/remote/generated/remote-components.ts b/packages/twenty-sdk/src/front-component-renderer/remote/generated/remote-components.ts index e535cb0b7d..04b7d2c258 100644 --- a/packages/twenty-sdk/src/front-component-renderer/remote/generated/remote-components.ts +++ b/packages/twenty-sdk/src/front-component-renderer/remote/generated/remote-components.ts @@ -1,12 +1,3 @@ -/* - * _____ _ - *|_ _|_ _____ _ __ | |_ _ _ - * | | \ \ /\ / / _ \ '_ \| __| | | | Auto-generated file - * | | \ V V / __/ | | | |_| |_| | Any edits to this will be overridden - * |_| \_/\_/ \___|_| |_|\__|\__, | - * |___/ - */ - import { createRemoteComponent } from '@remote-dom/react'; import { HtmlDivElement, @@ -52,6 +43,7 @@ import { HtmlTdElement, HtmlBrElement, HtmlHrElement, + RemoteStyleElement, } from './remote-elements'; export const HtmlDiv = createRemoteComponent('html-div', HtmlDivElement, { @@ -1126,3 +1118,7 @@ export const HtmlHr = createRemoteComponent('html-hr', HtmlHrElement, { onDrag: { event: 'drag' }, }, }); +export const RemoteStyle = createRemoteComponent( + 'remote-style', + RemoteStyleElement, +); diff --git a/packages/twenty-sdk/src/front-component-renderer/remote/generated/remote-elements.ts b/packages/twenty-sdk/src/front-component-renderer/remote/generated/remote-elements.ts index 51f38b985e..bac1e29f98 100644 --- a/packages/twenty-sdk/src/front-component-renderer/remote/generated/remote-elements.ts +++ b/packages/twenty-sdk/src/front-component-renderer/remote/generated/remote-elements.ts @@ -1,12 +1,3 @@ -/* - * _____ _ - *|_ _|_ _____ _ __ | |_ _ _ - * | | \ \ /\ / / _ \ '_ \| __| | | | Auto-generated file - * | | \ V V / __/ | | | |_| |_| | Any edits to this will be overridden - * |_| \_/\_/ \___|_| |_|\__|\__, | - * |___/ - */ - import { createRemoteElement, RemoteRootElement, @@ -609,6 +600,23 @@ export const HtmlHrElement = createRemoteElement< properties: HTML_COMMON_PROPERTIES_CONFIG, events: [...HTML_COMMON_EVENTS_ARRAY], }); + +export type RemoteStyleProperties = { + cssText?: string; + styleKey?: string; +}; + +export const RemoteStyleElement = createRemoteElement< + RemoteStyleProperties, + Record, + Record, + Record +>({ + properties: { + cssText: { type: String }, + styleKey: { type: String }, + }, +}); customElements.define('html-div', HtmlDivElement); customElements.define('html-span', HtmlSpanElement); customElements.define('html-section', HtmlSectionElement); @@ -652,6 +660,7 @@ customElements.define('html-th', HtmlThElement); customElements.define('html-td', HtmlTdElement); customElements.define('html-br', HtmlBrElement); customElements.define('html-hr', HtmlHrElement); +customElements.define('remote-style', RemoteStyleElement); customElements.define('remote-root', RemoteRootElement); customElements.define('remote-fragment', RemoteFragmentElement); export { RemoteRootElement, RemoteFragmentElement }; @@ -700,6 +709,7 @@ declare global { 'html-td': InstanceType; 'html-br': InstanceType; 'html-hr': InstanceType; + 'remote-style': InstanceType; 'remote-root': InstanceType; 'remote-fragment': InstanceType; } diff --git a/packages/twenty-sdk/src/front-component-renderer/remote/generated/remote-index.ts b/packages/twenty-sdk/src/front-component-renderer/remote/generated/remote-index.ts deleted file mode 100644 index b6d417c6da..0000000000 --- a/packages/twenty-sdk/src/front-component-renderer/remote/generated/remote-index.ts +++ /dev/null @@ -1,11 +0,0 @@ -/* - * _____ _ - *|_ _|_ _____ _ __ | |_ _ _ - * | | \ \ /\ / / _ \ '_ \| __| | | | Auto-generated file - * | | \ V V / __/ | | | |_| |_| | Any edits to this will be overridden - * |_| \_/\_/ \___|_| |_|\__|\__, | - * |___/ - */ - -export * from './remote-elements'; -export * from './remote-components'; diff --git a/packages/twenty-sdk/src/front-component-renderer/remote/mock/mock-front-component.ts b/packages/twenty-sdk/src/front-component-renderer/remote/mock/mock-front-component.ts deleted file mode 100644 index 5e2a957bb0..0000000000 --- a/packages/twenty-sdk/src/front-component-renderer/remote/mock/mock-front-component.ts +++ /dev/null @@ -1,51 +0,0 @@ -/* eslint-disable */ -//@ts-nocheck -import React, { useEffect, useState } from 'react'; - -import { - HtmlButton, - HtmlDiv, - HtmlH3, - HtmlP, -} from '../generated/remote-components'; - -const FrontComponent = () => { - const [clickCount, setClickCount] = useState(0); - const [currentTime, setCurrentTime] = useState( - new Date().toLocaleTimeString(), - ); - - useEffect(() => { - const interval = setInterval(() => { - setCurrentTime(new Date().toLocaleTimeString()); - }, 1000); - return () => clearInterval(interval); - }, []); - - return React.createElement( - HtmlDiv, - null, - React.createElement(HtmlH3, null, 'Remote DOM front component'), - React.createElement( - HtmlP, - null, - 'Rendered in a web worker and mirrored on the host.', - ), - React.createElement( - HtmlButton, - { onClick: () => setClickCount(clickCount + 1) }, - 'Click me', - ), - React.createElement( - HtmlP, - null, - 'Clicked ', - clickCount, - ' time', - clickCount === 1 ? '' : 's', - ), - React.createElement(HtmlP, null, 'Current time: ', currentTime), - ); -}; - -export default React.createElement(FrontComponent); diff --git a/packages/twenty-sdk/src/front-component-renderer/remote/utils/installStylePropertyOnRemoteElements.ts b/packages/twenty-sdk/src/front-component-renderer/remote/utils/installStylePropertyOnRemoteElements.ts new file mode 100644 index 0000000000..68b1cb06b5 --- /dev/null +++ b/packages/twenty-sdk/src/front-component-renderer/remote/utils/installStylePropertyOnRemoteElements.ts @@ -0,0 +1,213 @@ +import { ALLOWED_HTML_ELEMENTS } from '@/sdk/front-component-api/constants/AllowedHtmlElements'; + +const camelToKebab = (property: string): string => + property.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`); + +const UNITLESS_CSS_PROPERTIES = new Set([ + 'animationIterationCount', + 'borderImageOutset', + 'borderImageSlice', + 'borderImageWidth', + 'boxFlex', + 'boxFlexGroup', + 'boxOrdinalGroup', + 'columnCount', + 'columns', + 'flex', + 'flexGrow', + 'flexPositive', + 'flexShrink', + 'flexNegative', + 'flexOrder', + 'gridArea', + 'gridRow', + 'gridRowEnd', + 'gridRowSpan', + 'gridRowStart', + 'gridColumn', + 'gridColumnEnd', + 'gridColumnSpan', + 'gridColumnStart', + 'fontWeight', + 'lineClamp', + 'lineHeight', + 'opacity', + 'order', + 'orphans', + 'tabSize', + 'widows', + 'zIndex', + 'zoom', + 'fillOpacity', + 'floodOpacity', + 'stopOpacity', + 'strokeDasharray', + 'strokeDashoffset', + 'strokeMiterlimit', + 'strokeOpacity', + 'strokeWidth', +]); + +type FlushFn = (cssText: string) => void; + +const createStyleProxy = (flush: FlushFn): Record => { + const styleStore: Record = {}; + + const flushToRemoteProperty = (): void => { + const cssText = Object.entries(styleStore) + .map(([key, value]) => `${key}:${value}`) + .join(';'); + + flush(cssText); + }; + + return new Proxy(styleStore, { + get: (target, property) => { + if (property === 'cssText') { + return Object.entries(target) + .map(([key, value]) => `${key}:${value}`) + .join(';'); + } + + if (property === 'setProperty') { + return (name: string, value: string | null) => { + if (value === null || value === '') { + delete target[name]; + } else { + target[name] = String(value); + } + + flushToRemoteProperty(); + }; + } + + if (property === 'removeProperty') { + return (name: string): string => { + const oldValue = target[name] ?? ''; + + delete target[name]; + flushToRemoteProperty(); + + return oldValue; + }; + } + + if (property === 'getPropertyValue') { + return (name: string): string => target[name] ?? ''; + } + + if (typeof property === 'string') { + const kebabKey = camelToKebab(property); + + return target[kebabKey] ?? ''; + } + + return undefined; + }, + set: (target, property, value) => { + if (property === 'cssText') { + for (const key of Object.keys(target)) { + delete target[key]; + } + + String(value) + .split(';') + .forEach((pair) => { + const colonIndex = pair.indexOf(':'); + + if (colonIndex > 0) { + const key = pair.slice(0, colonIndex).trim(); + const val = pair.slice(colonIndex + 1).trim(); + + if (key && val) { + target[key] = val; + } + } + }); + + flushToRemoteProperty(); + + return true; + } + + if (typeof property === 'string') { + const kebabKey = camelToKebab(property); + + if (value === null || value === undefined || value === '') { + delete target[kebabKey]; + } else { + let stringValue = String(value); + + if ( + typeof value === 'number' && + value !== 0 && + !UNITLESS_CSS_PROPERTIES.has(property) + ) { + stringValue = `${value}px`; + } + + target[kebabKey] = stringValue; + } + + flushToRemoteProperty(); + + return true; + } + + return true; + }, + }); +}; + +type RemoteElementLike = Element & { + updateRemoteProperty: (name: string, value: unknown) => void; +}; + +export const installStylePropertyOnRemoteElements = (): void => { + for (const elementConfig of ALLOWED_HTML_ELEMENTS) { + const elementConstructor = customElements.get(elementConfig.tag); + + if (!elementConstructor) { + continue; + } + + const styleProxies = new WeakMap>(); + + Object.defineProperty(elementConstructor.prototype, 'style', { + get(this: RemoteElementLike) { + let proxy = styleProxies.get(this); + + if (!proxy) { + const element = this; + + const flush: FlushFn = (cssText: string) => { + element.updateRemoteProperty('style', cssText || undefined); + }; + + proxy = createStyleProxy(flush); + styleProxies.set(this, proxy); + } + + return proxy; + }, + set(this: RemoteElementLike, value: unknown) { + let proxy = styleProxies.get(this); + + if (!proxy) { + const element = this; + const flush: FlushFn = (cssText: string) => { + element.updateRemoteProperty('style', cssText || undefined); + }; + + proxy = createStyleProxy(flush); + styleProxies.set(this, proxy); + } + + if (typeof value === 'string') { + (proxy as Record).cssText = value; + } + }, + configurable: true, + }); + } +}; diff --git a/packages/twenty-sdk/src/front-component-renderer/remote/utils/patchRemoteElementSetAttribute.ts b/packages/twenty-sdk/src/front-component-renderer/remote/utils/patchRemoteElementSetAttribute.ts new file mode 100644 index 0000000000..d5b2d35c76 --- /dev/null +++ b/packages/twenty-sdk/src/front-component-renderer/remote/utils/patchRemoteElementSetAttribute.ts @@ -0,0 +1,43 @@ +import { ALLOWED_HTML_ELEMENTS } from '@/sdk/front-component-api/constants/AllowedHtmlElements'; + +const ATTRIBUTE_TO_PROPERTY_MAP: Record = { + className: 'className', + class: 'className', + + htmlFor: 'htmlFor', + for: 'htmlFor', + + tabIndex: 'tabIndex', + tabindex: 'tabIndex', +}; + +export const patchRemoteElementSetAttribute = (): void => { + for (const elementConfig of ALLOWED_HTML_ELEMENTS) { + const elementConstructor = customElements.get(elementConfig.tag); + + if (!elementConstructor) { + continue; + } + + const originalSetAttribute = elementConstructor.prototype.setAttribute as ( + name: string, + value: string, + ) => void; + + elementConstructor.prototype.setAttribute = function ( + this: Element & Record, + name: string, + value: string, + ) { + const propertyName = ATTRIBUTE_TO_PROPERTY_MAP[name]; + + if (propertyName) { + this[propertyName] = value; + + return; + } + + originalSetAttribute.call(this, name, value); + }; + } +}; diff --git a/packages/twenty-sdk/src/front-component-renderer/remote/worker/remote-worker.ts b/packages/twenty-sdk/src/front-component-renderer/remote/worker/remote-worker.ts index dc7e3ad55e..1785846ea0 100644 --- a/packages/twenty-sdk/src/front-component-renderer/remote/worker/remote-worker.ts +++ b/packages/twenty-sdk/src/front-component-renderer/remote/worker/remote-worker.ts @@ -9,14 +9,13 @@ import { type RemoteConnection, type RemoteRootElement, } from '@remote-dom/core/elements'; -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 { isDefined } from 'twenty-shared/utils'; -import * as TwentySdk from '@/sdk'; +import { installStyleBridge } from '@/front-component-renderer/polyfills/installStyleBridge'; +import { installStylePropertyOnRemoteElements } from '@/front-component-renderer/remote/utils/installStylePropertyOnRemoteElements'; +import { patchRemoteElementSetAttribute } from '@/front-component-renderer/remote/utils/patchRemoteElementSetAttribute'; +import { HTML_TAG_TO_CUSTOM_ELEMENT_TAG } from '@/sdk/front-component-api/constants/HtmlTagToRemoteComponent'; import { setFrontComponentExecutionContext } from '@/sdk/front-component-api/context/frontComponentContext'; import { setNavigate } from '@/sdk/front-component-api/functions/navigate'; @@ -24,20 +23,14 @@ import { type FrontComponentExecutionContext } from '../../types/FrontComponentE import { type FrontComponentHostCommunicationApi } from '../../types/FrontComponentHostCommunicationApi'; import { type HostToWorkerRenderContext } from '../../types/HostToWorkerRenderContext'; import { type WorkerExports } from '../../types/WorkerExports'; -import * as RemoteComponents from '../generated/remote-components'; import { exposeGlobals } from '../utils/exposeGlobals'; import { setWorkerEnv } from './utils/setWorkerEnv'; +installStylePropertyOnRemoteElements(); +patchRemoteElementSetAttribute(); + exposeGlobals({ - React, - RemoteComponents, - jsx, - jsxs, - TwentySdk, - TwentyShared: { - utils: TwentySharedUtils, - types: TwentySharedTypes, - }, + __HTML_TAG_TO_CUSTOM_ELEMENT_TAG__: HTML_TAG_TO_CUSTOM_ELEMENT_TAG, }); const render: WorkerExports['render'] = async ( @@ -46,8 +39,11 @@ const render: WorkerExports['render'] = async ( ) => { const batchedConnection = new BatchingRemoteConnection(connection); const root = document.createElement('remote-root') as RemoteRootElement; + const renderContainer = document.createElement('remote-fragment'); root.connect(batchedConnection); + root.append(renderContainer); document.body.append(root); + installStyleBridge(root); if ( isDefined(renderContext.applicationAccessToken) && @@ -83,14 +79,7 @@ const render: WorkerExports['render'] = async ( /* @vite-ignore */ const componentModule = await import(importUrl); - const reactRoot = createRoot(root); - reactRoot.render(componentModule.default); - } catch (importError) { - console.error( - '[FrontComponentWorker] Failed to load or render component:', - importError, - ); - throw importError; + componentModule.default(renderContainer); } finally { URL.revokeObjectURL(importUrl); } diff --git a/packages/twenty-sdk/src/front-component-renderer/types/FrontComponentExecutionContext.ts b/packages/twenty-sdk/src/front-component-renderer/types/FrontComponentExecutionContext.ts index fc16248356..732a36147a 100644 --- a/packages/twenty-sdk/src/front-component-renderer/types/FrontComponentExecutionContext.ts +++ b/packages/twenty-sdk/src/front-component-renderer/types/FrontComponentExecutionContext.ts @@ -1,4 +1,3 @@ -// Serializable execution context that can be passed via postMessage (no functions) export type FrontComponentExecutionContext = { userId: string | null; }; diff --git a/packages/twenty-sdk/src/sdk/front-component-api/constants/HtmlTagToRemoteComponent.ts b/packages/twenty-sdk/src/sdk/front-component-api/constants/HtmlTagToRemoteComponent.ts index e33dba8048..88e56a773d 100644 --- a/packages/twenty-sdk/src/sdk/front-component-api/constants/HtmlTagToRemoteComponent.ts +++ b/packages/twenty-sdk/src/sdk/front-component-api/constants/HtmlTagToRemoteComponent.ts @@ -1,9 +1,28 @@ import { ALLOWED_HTML_ELEMENTS } from './AllowedHtmlElements'; -export const HTML_TAG_TO_REMOTE_COMPONENT: Record = - Object.fromEntries( +const UTILITY_TAG_MAPPINGS: Record = { + 'remote-style': 'RemoteStyle', +}; + +export const HTML_TAG_TO_REMOTE_COMPONENT: Record = { + ...Object.fromEntries( ALLOWED_HTML_ELEMENTS.map((element) => [ element.tag.startsWith('html-') ? element.tag.slice(5) : element.tag, element.name, ]), - ); + ), + ...UTILITY_TAG_MAPPINGS, +}; + +// Maps standard HTML tag names to their custom element equivalents +// used by the remote DOM polyfill (e.g. "div" → "html-div"). +// Consumed by the jsx-runtime wrapper so React creates the correct +// custom elements instead of standard HTML tags. +export const HTML_TAG_TO_CUSTOM_ELEMENT_TAG: Record = { + ...Object.fromEntries( + ALLOWED_HTML_ELEMENTS.map((element) => [ + element.tag.startsWith('html-') ? element.tag.slice(5) : element.tag, + element.tag, + ]), + ), +}; 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 index f43002a42a..0b1117830d 100644 --- a/packages/twenty-sdk/src/sdk/front-component-api/context/frontComponentContext.ts +++ b/packages/twenty-sdk/src/sdk/front-component-api/context/frontComponentContext.ts @@ -2,16 +2,28 @@ import { type FrontComponentExecutionContext } from '../types/FrontComponentExec type Listener = () => void; -let executionContext: FrontComponentExecutionContext | undefined; +// State is stored on globalThis so the worker's SDK instance and each +// front component's bundled SDK copy share the same backing store. +const CONTEXT_KEY = '__twentySdkExecutionContext__'; +const LISTENERS_KEY = '__twentySdkContextListeners__'; -const listeners = new Set(); +const getListeners = (): Set => { + if (!(globalThis as Record)[LISTENERS_KEY]) { + (globalThis as Record)[LISTENERS_KEY] = + new Set(); + } + + return (globalThis as Record)[ + LISTENERS_KEY + ] as Set; +}; export const setFrontComponentExecutionContext = ( context: FrontComponentExecutionContext, ): void => { - executionContext = context; + (globalThis as Record)[CONTEXT_KEY] = context; - for (const listener of listeners) { + for (const listener of getListeners()) { listener(); } }; @@ -19,17 +31,19 @@ export const setFrontComponentExecutionContext = ( export const getFrontComponentExecutionContext = (): | FrontComponentExecutionContext | undefined => { - return executionContext; + return (globalThis as Record)[CONTEXT_KEY] as + | FrontComponentExecutionContext + | undefined; }; export const subscribeToFrontComponentExecutionContext = ( listener: Listener, ): void => { - listeners.add(listener); + getListeners().add(listener); }; export const unsubscribeFromFrontComponentExecutionContext = ( listener: Listener, ): void => { - listeners.delete(listener); + getListeners().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 index 4e5994a0c8..bbc3890eda 100644 --- a/packages/twenty-sdk/src/sdk/front-component-api/functions/navigate.ts +++ b/packages/twenty-sdk/src/sdk/front-component-api/functions/navigate.ts @@ -8,10 +8,12 @@ type NavigateFunction = ( options?: NavigateOptions, ) => Promise; -let navigateFunction: NavigateFunction | undefined; +// State is stored on globalThis so the worker's SDK instance and each +// front component's bundled SDK copy share the same backing store. +const NAVIGATE_KEY = '__twentySdkNavigateFunction__'; export const setNavigate = (fn: NavigateFunction): void => { - navigateFunction = fn; + (globalThis as Record)[NAVIGATE_KEY] = fn; }; export const navigate: NavigateFunction = ( @@ -20,8 +22,13 @@ export const navigate: NavigateFunction = ( queryParams?: Record, options?: NavigateOptions, ): Promise => { + const navigateFunction = (globalThis as Record)[ + NAVIGATE_KEY + ] as NavigateFunction | undefined; + if (!isDefined(navigateFunction)) { throw new Error('navigateFunction is not set'); } + return navigateFunction(to, params, queryParams, options); }; diff --git a/packages/twenty-sdk/src/sdk/index.ts b/packages/twenty-sdk/src/sdk/index.ts index 6c7c6f5105..fa5ebf62e1 100644 --- a/packages/twenty-sdk/src/sdk/index.ts +++ b/packages/twenty-sdk/src/sdk/index.ts @@ -1,9 +1,9 @@ export type { ApplicationConfig } from './application/application-config'; export { defineApplication } from './application/define-application'; export type { - ValidationResult, DefinableEntity, DefineEntity, + ValidationResult, } from './common/types/define-entity.type'; export type { SyncableEntityOptions } from './common/types/syncable-entity-options.type'; export { createValidationResult } from './common/utils/create-validation-result'; @@ -24,24 +24,24 @@ export { OnDeleteAction } from './fields/on-delete-action'; export { RelationType } from './fields/relation-type'; export { validateFields } from './fields/validate-fields'; export type { - FrontComponentType, FrontComponentConfig, + FrontComponentType, } from './front-component-config'; export { defineLogicFunction } from './logic-functions/define-logic-function'; export type { - LogicFunctionHandler, LogicFunctionConfig, + LogicFunctionHandler, } from './logic-functions/logic-function-config'; export type { CronPayload } from './logic-functions/triggers/cron-payload-type'; export type { DatabaseEventPayload, + ObjectRecordBaseEvent, ObjectRecordCreateEvent, - ObjectRecordUpdateEvent, - ObjectRecordEvent, ObjectRecordDeleteEvent, ObjectRecordDestroyEvent, - ObjectRecordBaseEvent, + ObjectRecordEvent, ObjectRecordRestoreEvent, + ObjectRecordUpdateEvent, ObjectRecordUpsertEvent, } from './logic-functions/triggers/database-event-payload-type'; export type { RoutePayload } from './logic-functions/triggers/route-payload-type'; @@ -51,15 +51,23 @@ export { defineRole } from './roles/define-role'; 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 { + navigate, + useFrontComponentExecutionContext, + useUserId, +} from './front-component-api'; export type { FrontComponentExecutionContext } from './front-component-api'; // Front Component Common exports +export { + ALLOWED_HTML_ELEMENTS, + COMMON_HTML_EVENTS, + EVENT_TO_REACT, + HTML_COMMON_PROPERTIES, + HTML_TAG_TO_REMOTE_COMPONENT, +} from './front-component-api'; export type { AllowedHtmlElement } from './front-component-api'; -export { ALLOWED_HTML_ELEMENTS } from './front-component-api'; -export { COMMON_HTML_EVENTS } from './front-component-api'; -export { EVENT_TO_REACT } from './front-component-api'; -export { HTML_COMMON_PROPERTIES } from './front-component-api'; -export { HTML_TAG_TO_REMOTE_COMPONENT } from './front-component-api'; + +// Style bridge utilities for CSS-in-JS libraries in remote components +export { installStyleBridge } from '../front-component-renderer/polyfills/installStyleBridge'; +export { exposeGlobals } from '../front-component-renderer/remote/utils/exposeGlobals'; diff --git a/packages/twenty-sdk/src/ui/index.ts b/packages/twenty-sdk/src/ui/index.ts index d35fd14e73..3e05bbfb24 100644 --- a/packages/twenty-sdk/src/ui/index.ts +++ b/packages/twenty-sdk/src/ui/index.ts @@ -9,3 +9,7 @@ export * from 'twenty-ui/layout'; export * from 'twenty-ui/navigation'; export * from 'twenty-ui/theme'; export * from 'twenty-ui/utilities'; + +// Re-export Emotion's ThemeProvider so front components can wrap +// their content with the Twenty UI theme without a direct @emotion/react dependency +export { ThemeProvider } from '@emotion/react'; diff --git a/packages/twenty-sdk/tsconfig.json b/packages/twenty-sdk/tsconfig.json index e5fda73da3..db12e9ed30 100644 --- a/packages/twenty-sdk/tsconfig.json +++ b/packages/twenty-sdk/tsconfig.json @@ -28,12 +28,14 @@ "vite.config.ts", "vite.config.node.ts", "vite.config.browser.ts", + "vite.config.sdk.ts", "jest.config.mjs" ], "exclude": [ "src/front-component-renderer/remote/mock/**/*", "src/front-component-renderer/host/generated/host-component-registry.ts", "src/front-component-renderer/remote/generated/remote-components.ts", - "src/front-component-renderer/remote/generated/remote-elements.ts" + "src/front-component-renderer/remote/generated/remote-elements.ts", + "src/front-component-renderer/__stories__/**/*" ] } diff --git a/packages/twenty-sdk/tsconfig.lib.json b/packages/twenty-sdk/tsconfig.lib.json index a7e9544857..6d1c7f33ee 100644 --- a/packages/twenty-sdk/tsconfig.lib.json +++ b/packages/twenty-sdk/tsconfig.lib.json @@ -19,6 +19,9 @@ "**/*.test.ts", "**/*.spec.ts", "**/*.e2e-spec.ts", - "**/__tests__/**" + "**/__tests__/**", + "**/__stories__/**", + "**/*.stories.ts", + "**/*.stories.tsx" ] } diff --git a/packages/twenty-sdk/vite.config.browser.ts b/packages/twenty-sdk/vite.config.browser.ts index 879656da84..b57d0fa7c5 100644 --- a/packages/twenty-sdk/vite.config.browser.ts +++ b/packages/twenty-sdk/vite.config.browser.ts @@ -22,7 +22,6 @@ const entryFileNames = (chunk: any, extension: 'cjs' | 'mjs') => { return `${chunk.name}.${extension}`; } - // Remove 'src/' prefix and '/index.ts' suffix to get the module path const modulePath = entry.replace('src/', '').replace('/index.ts', ''); return `${modulePath}/index.${extension}`; diff --git a/packages/twenty-sdk/vite.config.sdk.ts b/packages/twenty-sdk/vite.config.sdk.ts new file mode 100644 index 0000000000..106b42fc40 --- /dev/null +++ b/packages/twenty-sdk/vite.config.sdk.ts @@ -0,0 +1,53 @@ +import * as path from 'path'; +import { type UserConfig, defineConfig } from 'vite'; +import tsconfigPaths from 'vite-tsconfig-paths'; + +const isExternal = (id: string): boolean => { + if (id.startsWith('.') || id.startsWith('/') || id.startsWith('\0')) { + return false; + } + + if (id.startsWith('src/') || id.startsWith('@/')) { + return false; + } + + return true; +}; + +export default defineConfig((): UserConfig => { + return { + root: __dirname, + cacheDir: '../../node_modules/.vite/packages/twenty-sdk-sdk', + resolve: { + alias: { + '@/': path.resolve(__dirname, 'src') + '/', + }, + }, + plugins: [ + tsconfigPaths({ + root: __dirname, + }), + ], + build: { + minify: 'esbuild', + sourcemap: true, + outDir: './dist/sdk', + emptyOutDir: false, + lib: { + entry: { + index: 'src/sdk/index.ts', + }, + formats: ['es'], + }, + rollupOptions: { + external: isExternal, + output: { + preserveModules: true, + preserveModulesRoot: 'src/sdk', + entryFileNames: '[name].js', + }, + }, + }, + logLevel: 'warn', + }; +}); diff --git a/packages/twenty-shared/package.json b/packages/twenty-shared/package.json index 6f7f57e754..f77079ee62 100644 --- a/packages/twenty-shared/package.json +++ b/packages/twenty-shared/package.json @@ -1,6 +1,7 @@ { "name": "twenty-shared", "private": true, + "sideEffects": false, "main": "dist/index.cjs", "module": "dist/index.mjs", "types": "dist/index.d.ts", diff --git a/packages/twenty-shared/project.json b/packages/twenty-shared/project.json index 1d8f9ca565..2c9860e7c5 100644 --- a/packages/twenty-shared/project.json +++ b/packages/twenty-shared/project.json @@ -52,6 +52,24 @@ "parallel": false } }, + "build:individual": { + "executor": "nx:run-commands", + "cache": true, + "dependsOn": [ + "build" + ], + "inputs": [ + "production", + "^production" + ], + "outputs": [ + "{projectRoot}/dist/individual" + ], + "options": { + "cwd": "{projectRoot}", + "command": "npx vite build -c vite.config.individual.ts" + } + }, "generateBarrels": { "executor": "nx:run-commands", "cache": true, diff --git a/packages/twenty-shared/src/individual-entry.ts b/packages/twenty-shared/src/individual-entry.ts new file mode 100644 index 0000000000..d2217191fe --- /dev/null +++ b/packages/twenty-shared/src/individual-entry.ts @@ -0,0 +1,11 @@ +export * from './ai'; +export * from './application'; +export * from './constants'; +export * from './database-events'; +export * from './metadata'; +export * from './testing'; +export * from './translations'; +export * from './types'; +export * from './utils'; +export * from './workflow'; +export * from './workspace'; diff --git a/packages/twenty-shared/tsconfig.json b/packages/twenty-shared/tsconfig.json index 0436467082..ede1d80d54 100644 --- a/packages/twenty-shared/tsconfig.json +++ b/packages/twenty-shared/tsconfig.json @@ -18,6 +18,10 @@ "src/**/*.ts", "scripts/**/*.ts", "**/__mocks__/**/*", - "jest.config.mjs" + "jest.config.mjs", + "vite.config.*.ts" + ], + "exclude": [ + "src/individual-entry.ts" ] } diff --git a/packages/twenty-shared/tsconfig.lib.json b/packages/twenty-shared/tsconfig.lib.json index 930c23d66b..7f6446f81a 100644 --- a/packages/twenty-shared/tsconfig.lib.json +++ b/packages/twenty-shared/tsconfig.lib.json @@ -11,6 +11,7 @@ "exclude": [ "**/*.spec.ts", "**/*.test.ts", - "**/__mocks__/**/*" + "**/__mocks__/**/*", + "src/individual-entry.ts" ] } diff --git a/packages/twenty-shared/vite.config.individual.ts b/packages/twenty-shared/vite.config.individual.ts new file mode 100644 index 0000000000..c1c3d5413d --- /dev/null +++ b/packages/twenty-shared/vite.config.individual.ts @@ -0,0 +1,66 @@ +// @ts-ignore +import path from 'path'; +import { type UserConfig, defineConfig } from 'vite'; +import tsconfigPaths from 'vite-tsconfig-paths'; +// @ts-ignore +import packageJson from './package.json'; + +const submodules = Object.keys((packageJson as any).exports || {}) + .filter((key) => key !== '.' && !key.startsWith('./src/')) + .map((key) => key.replace(/^\.\//, '')); + +const entries: Record = { + 'individual-entry': 'src/individual-entry.ts', +}; + +for (const submodule of submodules) { + entries[`${submodule}/index`] = `src/${submodule}/index.ts`; +} + +const isExternal = (id: string): boolean => { + if (id.startsWith('.') || id.startsWith('/') || id.startsWith('\0')) { + return false; + } + + if (id.startsWith('src/') || id.startsWith('@/')) { + return false; + } + + return true; +}; + +export default defineConfig((): UserConfig => { + return { + root: __dirname, + cacheDir: '../../node_modules/.vite/packages/twenty-shared-individual', + resolve: { + alias: { + '@/': path.resolve(__dirname, 'src') + '/', + }, + }, + plugins: [ + tsconfigPaths({ + root: __dirname, + }), + ], + build: { + minify: 'esbuild', + sourcemap: true, + outDir: './dist/individual', + emptyOutDir: true, + lib: { + entry: entries, + formats: ['es'], + }, + rollupOptions: { + external: isExternal, + output: { + preserveModules: true, + preserveModulesRoot: 'src', + entryFileNames: '[name].js', + }, + }, + }, + logLevel: 'warn', + }; +}); diff --git a/packages/twenty-shared/vite.config.ts b/packages/twenty-shared/vite.config.ts index 3b4ae28e94..b99eeb3fc3 100644 --- a/packages/twenty-shared/vite.config.ts +++ b/packages/twenty-shared/vite.config.ts @@ -49,6 +49,7 @@ export default defineConfig(() => { }), ], build: { + emptyOutDir: false, outDir: 'dist', lib: { entry: entries, name: 'twenty-shared' }, rollupOptions: { diff --git a/packages/twenty-ui/package.json b/packages/twenty-ui/package.json index 35aa06ea8f..c33e4cbcc2 100644 --- a/packages/twenty-ui/package.json +++ b/packages/twenty-ui/package.json @@ -5,6 +5,9 @@ "module": "dist/index.mjs", "style": "./dist/style.css", "type": "module", + "sideEffects": [ + "**/*.css" + ], "devDependencies": { "@babel/preset-env": "^7.26.9", "@babel/preset-react": "^7.26.3", @@ -64,9 +67,6 @@ "theme", "utilities" ], - "sideEffects": [ - "**/*.css" - ], "exports": { ".": { "types": "./dist/index.d.ts", diff --git a/packages/twenty-ui/project.json b/packages/twenty-ui/project.json index adb758f9b6..3751d98d5a 100644 --- a/packages/twenty-ui/project.json +++ b/packages/twenty-ui/project.json @@ -55,6 +55,24 @@ "command": "tsx {projectRoot}/scripts/generateBarrels.ts" } }, + "build:individual": { + "executor": "nx:run-commands", + "cache": true, + "dependsOn": [ + "build" + ], + "inputs": [ + "production", + "^production" + ], + "outputs": [ + "{projectRoot}/dist/individual" + ], + "options": { + "cwd": "{projectRoot}", + "command": "npx vite build -c vite.config.individual.ts" + } + }, "clean": { "executor": "nx:run-commands", "options": { diff --git a/packages/twenty-ui/src/display/status/components/Status.tsx b/packages/twenty-ui/src/display/status/components/Status.tsx index 66d41f8627..29fea6579b 100644 --- a/packages/twenty-ui/src/display/status/components/Status.tsx +++ b/packages/twenty-ui/src/display/status/components/Status.tsx @@ -1,9 +1,13 @@ import styled from '@emotion/styled'; -import { type ThemeColor } from '@ui/theme'; -import { themeColorSchema } from '@ui/theme/utils/themeColorSchema'; +import { MAIN_COLOR_NAMES, type ThemeColor } from '@ui/theme'; import { Loader } from '@ui/feedback/loader/components/Loader'; +const parseThemeColor = (color: string): ThemeColor => + (MAIN_COLOR_NAMES as string[]).includes(color) + ? (color as ThemeColor) + : 'gray'; + const StyledStatus = styled.h3<{ color: ThemeColor; weight: 'regular' | 'medium'; @@ -62,7 +66,7 @@ export const Status = ({ }: StatusProps) => ( { +export const ThemeProvider = ({ theme, children }: ThemeProviderProps) => { return ( {children} ); }; - -export default ThemeProvider; diff --git a/packages/twenty-ui/vite.config.individual.ts b/packages/twenty-ui/vite.config.individual.ts new file mode 100644 index 0000000000..9d05bf65b7 --- /dev/null +++ b/packages/twenty-ui/vite.config.individual.ts @@ -0,0 +1,85 @@ +import react from '@vitejs/plugin-react-swc'; +import wyw from '@wyw-in-js/vite'; +import * as path from 'path'; +import { defineConfig } from 'vite'; +import svgr from 'vite-plugin-svgr'; +import tsconfigPaths from 'vite-tsconfig-paths'; + +import packageJson from './package.json'; + +const depNames = Object.keys(packageJson.dependencies || {}); + +const isExternal = (id: string): boolean => + depNames.some((dep) => id === dep || id.startsWith(dep + '/')); + +export default defineConfig(() => { + return { + resolve: { + alias: { + '@ui/': path.resolve(__dirname, 'src') + '/', + '@assets/': path.resolve(__dirname, 'src/assets') + '/', + }, + }, + css: { + modules: { + localsConvention: 'camelCaseOnly', + }, + }, + root: __dirname, + cacheDir: '../../node_modules/.vite/packages/twenty-ui-individual', + assetsInclude: ['src/**/*.svg'], + plugins: [ + react({ + jsxImportSource: '@emotion/react', + plugins: [['@swc/plugin-emotion', {}]], + }), + tsconfigPaths({ + root: __dirname, + projects: ['tsconfig.json'], + }), + svgr(), + wyw({ + include: [ + '**/OverflowingTextWithTooltip.tsx', + '**/Tag.tsx', + '**/Avatar.tsx', + '**/Chip.tsx', + '**/LinkChip.tsx', + '**/Avatar.tsx', + '**/AvatarChipLeftComponent.tsx', + '**/ContactLink.tsx', + '**/RoundedLink.tsx', + ], + babelOptions: { + presets: ['@babel/preset-typescript', '@babel/preset-react'], + }, + }), + ], + build: { + cssCodeSplit: false, + minify: 'esbuild', + sourcemap: true, + outDir: './dist/individual', + emptyOutDir: true, + commonjsOptions: { + transformMixedEsModules: true, + interopDefault: true, + defaultIsModuleExports: true, + requireReturnsDefault: 'auto', + }, + lib: { + entry: 'src/individual-entry.ts', + formats: ['es'], + }, + rollupOptions: { + external: isExternal, + output: { + preserveModules: true, + preserveModulesRoot: 'src', + entryFileNames: '[name].js', + }, + }, + }, + logLevel: 'warn', + }; +}); diff --git a/packages/twenty-ui/vite.config.ts b/packages/twenty-ui/vite.config.ts index 55f68f793d..b849a478ed 100644 --- a/packages/twenty-ui/vite.config.ts +++ b/packages/twenty-ui/vite.config.ts @@ -101,12 +101,11 @@ export default defineConfig(({ command }) => { }, }), ], - // Configuration for building your library. - // See: https://vitejs.dev/guide/build.html#library-mode build: { cssCodeSplit: false, minify: 'esbuild', sourcemap: false, + emptyOutDir: false, outDir: './dist', reportCompressedSize: true, commonjsOptions: { @@ -120,7 +119,6 @@ export default defineConfig(({ command }) => { name: 'twenty-ui', }, rollupOptions: { - // External packages that should not be bundled into your library. external: Object.keys(packageJson.dependencies || {}), output: [ { diff --git a/yarn.lock b/yarn.lock index 79db5e5f4f..6943b3da4c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -51048,6 +51048,13 @@ __metadata: languageName: node linkType: hard +"preact@npm:^10.28.3": + version: 10.28.3 + resolution: "preact@npm:10.28.3" + checksum: 10c0/e8854fae8d8d40c918538f7a90888dd4f8acd388b8ba8e0c612ed0eb1a7d2cc6c15a7dc0a7b7c7bb5c361d2df63aa283e501cf1d47f8eb1413979cee611a38ef + languageName: node + linkType: hard + "prebuild-install@npm:^7.1.1": version: 7.1.2 resolution: "prebuild-install@npm:7.1.2" @@ -58363,6 +58370,7 @@ __metadata: jsonc-parser: "npm:^3.2.0" lodash.camelcase: "npm:^4.3.0" playwright: "npm:^1.56.1" + preact: "npm:^10.28.3" react: "npm:^18.2.0" react-dom: "npm:^18.2.0" storybook: "npm:^10.1.11"