Support CSS imports in front-components via runtime style injection (#22150)

Front-components compile to a remote-dom worker, so a CSS import like
`import 'twenty-ui/style.css'` can't load a stylesheet and was breaking
the build at the manifest step.

This makes the build inline an imported CSS file as a runtime `<style>`
injection that flows through the existing style bridge into the host.
Because the CSS is bundled alongside that same build's hashed class
names, an app's styling matches its own twenty-ui version regardless of
which version the host ships — no server, manifest, or host changes
needed.

The manifest extractor keeps the no-op CSS loader (it executes the
bundle in Node, where `document` is undefined); the inject plugin runs
only in the real build and the dev watcher.
This commit is contained in:
Raphaël Bosi
2026-06-25 13:31:00 +02:00
committed by GitHub
parent 2af5370749
commit 19bbd59b53
8 changed files with 87 additions and 1 deletions
+1
View File
@@ -0,0 +1 @@
declare module '*.css';
@@ -1,5 +1,7 @@
import { defineFrontComponent } from 'twenty-sdk/define';
import 'twenty-ui/style.css';
export const MyComponent = () => {
return (
<div style={{ padding: '10px' }}>
@@ -15,7 +15,8 @@
},
"dependencies": {
"twenty-sdk": "latest",
"twenty-client-sdk": "latest"
"twenty-client-sdk": "latest",
"twenty-ui": "latest"
},
"devDependencies": {
"typescript": "^5.9.3",
@@ -0,0 +1,33 @@
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'path';
import * as esbuild from 'esbuild';
import { MINIMAL_APP_PATH } from '@/cli/__tests__/apps/fixture-paths';
import { getBaseFrontComponentBuildOptions } from '@/cli/utilities/build/common/front-component-build/utils/get-base-front-component-build-options';
describe('front-component build CSS injection', () => {
it('inlines an imported twenty-ui/style.css as a runtime document.head style injection', async () => {
const outputDir = await mkdtemp(join(tmpdir(), 'css-injection-'));
try {
await esbuild.build({
...getBaseFrontComponentBuildOptions(),
entryPoints: [join(MINIMAL_APP_PATH, 'my.front-component.tsx')],
outdir: outputDir,
});
const output = await readFile(
join(outputDir, 'my.front-component.mjs'),
'utf-8',
);
expect(output).toContain('document.createElement("style")');
expect(output).toContain('document.head.appendChild');
expect(output).toContain('box-sizing');
} finally {
await rm(outputDir, { recursive: true, force: true });
}
}, 30000);
});
@@ -0,0 +1,24 @@
import { readFile } from 'node:fs/promises';
import type * as esbuild from 'esbuild';
const buildStyleInjectionModule = (cssText: string): string =>
`if (typeof document !== 'undefined') {
const styleElement = document.createElement('style');
document.head.appendChild(styleElement);
styleElement.textContent = ${JSON.stringify(cssText)};
}`;
export const cssInjectionPlugin: esbuild.Plugin = {
name: 'css-injection',
setup: (build) => {
build.onLoad({ filter: /\.css$/ }, async ({ path }) => {
const cssText = await readFile(path, 'utf8');
return {
contents: buildStyleInjectionModule(cssText),
loader: 'js',
};
});
},
};
@@ -1,5 +1,6 @@
import type * as esbuild from 'esbuild';
import { cssInjectionPlugin } from '../css-injection-plugin';
import { createJsxRuntimeRemoteWrapperPlugin } from '../jsx-runtime-remote-wrapper-plugin';
import { jsxTransformToRemoteDomWorkerFormatPlugin } from '../jsx-transform-to-remote-dom-worker-format-plugin';
import { createPreactAliasPlugin } from '../preact-alias-plugin';
@@ -17,5 +18,6 @@ export const getFrontComponentBuildPlugins = (
),
...(options?.usePreact ? [createPreactAliasPlugin()] : []),
jsxTransformToRemoteDomWorkerFormatPlugin,
cssInjectionPlugin,
stripCommentsPlugin,
];
@@ -0,0 +1,22 @@
import { join } from 'path';
import { MINIMAL_APP_PATH } from '@/cli/__tests__/apps/fixture-paths';
import { extractManifestFromFile } from '@/cli/utilities/build/manifest/manifest-extract-config-from-file';
import { type FrontComponentConfig } from '@/sdk/define/front-component/front-component-config';
describe('extractManifestFromFile - front component importing twenty-ui', () => {
it('extracts the config from a front component that side-effect imports twenty-ui/style.css', async () => {
const filePath = join(MINIMAL_APP_PATH, 'my.front-component.tsx');
const result = await extractManifestFromFile<FrontComponentConfig>({
appPath: MINIMAL_APP_PATH,
filePath,
});
expect(result.errors).toEqual([]);
expect(result.config.universalIdentifier).toBe(
'e1e2e3e4-e5e6-4000-8000-000000000020',
);
expect(result.config.name).toBe('my-component');
}, 30000);
});
@@ -83,6 +83,7 @@ const loadModule = async ({
target: 'node18',
jsx: 'automatic',
tsconfig: hasTsconfig ? tsconfigPath : undefined,
loader: { '.css': 'empty' },
alias: {
...(reactPath && { react: reactPath }),
...(reactDomPath && { 'react-dom': reactDomPath }),