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:
@@ -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",
|
||||
|
||||
+33
@@ -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);
|
||||
});
|
||||
+24
@@ -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',
|
||||
};
|
||||
});
|
||||
},
|
||||
};
|
||||
+2
@@ -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,
|
||||
];
|
||||
|
||||
+22
@@ -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);
|
||||
});
|
||||
+1
@@ -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 }),
|
||||
|
||||
Reference in New Issue
Block a user