Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion vite-plugin-compiled-react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
},
"scripts": {
"build": "rimraf lib && tsc -b && cat src/types.d.ts >> lib/index.d.ts",
"dev": "tsc -w"
"dev": "tsc -w",
"test": "pnpm build && node --test test/*.test.mjs"

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These regressions live at the built-plugin boundary, so the test command builds lib before importing it. Otherwise node:test could exercise stale output and certify source code that consumers never load.

},
"exports": {
"import": "./lib/index.js"
Expand All @@ -23,6 +24,7 @@
"lib"
],
"dependencies": {
"@babel/core": "^7.26.10",

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The standalone transform calls Babel directly; the old reactBabel callback received Babel through plugin-react. Declaring @babel/core as a runtime dependency avoids relying on a transitive copy that can disappear or resolve incompatibly for consumers.

"@babel/types": "^7.27.0",
"@compiled/babel-plugin": "^0.37.1",
"@compiled/babel-plugin-strip-runtime": "^0.37.1",
Expand All @@ -35,6 +37,7 @@
},
"devDependencies": {
"@compiled/react": "^0.18.3",
"@types/babel__core": "7.20.5",
"@types/node": "^22.14.0",
"rimraf": "^6.0.1",
"typescript": "^5.8.3",
Expand Down
152 changes: 91 additions & 61 deletions vite-plugin-compiled-react/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import t from '@babel/types';
import babel from '@babel/core';
import compiledPlugin from '@compiled/babel-plugin';
import compiledStripRuntimePlugin from '@compiled/babel-plugin-strip-runtime';
import type { ReactBabelOptions } from '@vitejs/plugin-react';
import moduleResolverPlugin from 'babel-plugin-module-resolver';
import { createHash } from 'crypto';
import { EnvironmentModuleNode, type Plugin } from 'vite';
import { createFilter, EnvironmentModuleNode, type Plugin } from 'vite';

export type CompiledPluginOptions = {
/**
Expand Down Expand Up @@ -37,8 +37,10 @@ export type CompiledPluginOptions = {
};

const virtualCssFiles = new Map();
const defaultIncludeRE = /\.[tj]sx?$/;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The published plugin inherited plugin-react's .[tj]sx? default. Keeping the same expression is required so the standalone hook does not silently drop .js and .ts; createFilter preserves Vite's standard filter behavior.


export const compiled = (options: CompiledPluginOptions = {}): Plugin => {
const filter = createFilter(defaultIncludeRE);
const hash = (code: string) => {
return createHash('md5').update(code).digest('hex').substring(2, 9);
};
Expand All @@ -52,6 +54,8 @@ export const compiled = (options: CompiledPluginOptions = {}): Plugin => {
let command = '';
let root: string;
const moduleResolverPluginAlias = {};
let plugins: babel.PluginItem[] = [];

return {
name: 'vite-plugin-compiled-react',
enforce: 'pre',
Expand Down Expand Up @@ -82,6 +86,55 @@ export const compiled = (options: CompiledPluginOptions = {}): Plugin => {
moduleResolverPluginAlias[find] = replacement;
}
}

plugins = [

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@vitejs/plugin-react 6 gates third-party reactBabel hooks through its environment-application path, so the RSC analysis build never ran Compiled and serialized CSS objects into HTML. Building the Babel chain from the resolved root and aliases lets this plugin own the transform in every environment that includes it.

{
visitor: {
Program(root) {
if (/extractAssets/.test(this.filename)) {
return;
}
root.unshiftContainer('body', importDeclaration);
},
},
},
[moduleResolverPlugin, { root, alias: moduleResolverPluginAlias }],
[compiledPlugin, { importReact: false, ...baseOptions }],
];

if (
options.extract &&
(options.extract === true ||
(command === 'serve' && options.extract.serve) ||
(command === 'build' && options.extract.build))
) {
plugins.push([
compiledStripRuntimePlugin,
{ compiledRequireExclude: true },
]);

plugins.push({
visitor: {
Program: {
exit(path, { file }) {
const styleRules = file.metadata.styleRules;
if (styleRules.length) {
const code = styleRules.join('\n');
const fileId = hash(code) + '.css';
virtualCssFiles.set(fileId, styleRules.join('\n'));
path.unshiftContainer(
'body',
t.importDeclaration(
[],
t.stringLiteral(`${virtualCssFileName}:${fileId}`)
)
);
}
},
},
},
});
}
},
resolveId(source, importer, options) {
if (source.startsWith(virtualCssFileName)) {
Expand Down Expand Up @@ -145,66 +198,43 @@ export const compiled = (options: CompiledPluginOptions = {}): Plugin => {
`;
}
},
api: {
reactBabel(babelConfig: ReactBabelOptions) {
babelConfig.plugins.push({
visitor: {
Program(root) {
if (
/node_modules/.test(this.filename) ||
/extractAssets/.test(this.filename)
) {
return;
}
if (/\.[jt]sx$/.test(this.filename)) {
root.unshiftContainer('body', importDeclaration);
}
},
},
});
async transform(code, id) {
// Keep the same default boundary as @vitejs/plugin-react: dependencies are excluded, the
// query is stripped before filtering, and plain .js/.ts files are eligible as well.
if (id.includes('/node_modules/')) {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The old callback inherited plugin-react's dependency exclusion and query stripping. Without both here, query-suffixed source IDs are skipped and Flow dependencies reach Babel, where opaque type Token fails parsing.

return;
}
const [filepath] = id.split('?');
if (!filepath || !filter(filepath)) {
return;
}
if (
!filepath.endsWith('x') &&

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generated .js from React can call jsx() without using Compiled; running the Compiled Babel plugin on it throws Importing jsx from a library other than Compiled. Explicit Compiled imports still opt plain .js and .ts in, while JSX extensions remain eligible for the css prop transform.

!code.includes("'@compiled/react'") &&
!code.includes('"@compiled/react"')
) {
return;
}
const res = await babel.transformAsync(code, {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Calling Babel here replaces the reactBabel callback that plugin-react 6 no longer invokes for the RSC analysis environment. Parse-only JSX and TypeScript support is necessary because Vite has not stripped annotations yet, while final lowering remains Vite's job.

filename: id,
sourceFileName: filepath,
sourceMaps: true,
plugins,
// Parse only: TypeScript and JSX are left for Vite's own transform. Babel must still
// understand them, otherwise annotations and `interface` are syntax errors here.
parserOpts: { plugins: ['jsx', 'typescript'] },
configFile: false,
babelrc: false,
});

babelConfig.plugins.push([
moduleResolverPlugin,
{ root, alias: moduleResolverPluginAlias },
]);
babelConfig.plugins.push([
compiledPlugin,
{ importReact: false, ...baseOptions },
]);
if (
options.extract &&
(options.extract === true ||
(command === 'serve' && options.extract.serve) ||
(command === 'build' && options.extract.build))
) {
babelConfig.plugins.push([
compiledStripRuntimePlugin,
{ compiledRequireExclude: true },
]);

babelConfig.plugins.push({
visitor: {
Program: {
exit(path, { file }) {
const styleRules = file.metadata.styleRules;
if (styleRules.length) {
const code = styleRules.join('\n');
const fileId = hash(code) + '.css';
virtualCssFiles.set(fileId, styleRules.join('\n'));
path.unshiftContainer(
'body',
t.importDeclaration(
[],
t.stringLiteral(`${virtualCssFileName}:${fileId}`)
)
);
}
},
},
},
});
}
},
if (!res || !res.code) {
return;
}

return {
code: res.code,
map: res.map,
};
},
};
};
67 changes: 67 additions & 0 deletions vite-plugin-compiled-react/test/filter.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import assert from 'node:assert/strict';
import { before, describe, it } from 'node:test';

let compiled;

before(async () => {
({ compiled } = await import('../lib/index.js'));
});

describe('transform filter', () => {
it('transforms plain JavaScript files', async () => {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.js was included by plugin-react's effective filter but omitted by the first standalone regex. This fixture goes red if that original regression returns.

const result = await transform(
"import { css } from '@compiled/react'; export const value = css({ color: 'red' });",
'/project/src/style.js'
);

assertCompiled(result);
});

it('transforms plain TypeScript files', async () => {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Plain .ts must pass both the filename filter and Babel's TypeScript parser before Compiled can see the call. The annotation makes the test fail if parser support is removed rather than checking only the filename boundary.

const result = await transform(
"import { css } from '@compiled/react'; export const value: string = css({ color: 'red' });",
'/project/src/style.ts'
);

assertCompiled(result);
});

it('strips the query before filtering', async () => {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Vite appends queries to transformed IDs; filtering the full ID made valid TSX bypass Compiled. The className={ax...} assertion proves the visitor ran, not merely that Babel returned code.

const result = await transform(
"export const Page = () => <div css={{ color: 'red' }} />;",
'/project/src/style.tsx?direct'
);

assertCompiled(result);
assert.match(result.code, /className=\{ax/);
});

it('does not transform dependencies', async () => {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Plugin-react excluded dependencies before invoking Babel. The Flow-only opaque type makes this fixture mutation-sensitive: removing the node_modules guard fails with a Babel syntax error instead of quietly broadening the transform.

const result = await transform(
"// @flow\nopaque type Token = string; export const token: Token = 'dependency';",
'/project/node_modules/flow-dependency/index.jsx'
);

assert.equal(result, undefined);
});

it('does not transform plain JavaScript without a Compiled import', async () => {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The real extension build contains generated .js that imports React's jsx helper but has no Compiled usage. This fixture protects the relevance guard that prevents Compiled from rejecting that helper as a foreign JSX factory.

const result = await transform(
"import { jsx } from 'react/jsx-runtime'; export const Page = () => jsx('div', {});",
'/project/generated/runtime.js'
);

assert.equal(result, undefined);
});
});

async function transform(code, id) {
const plugin = compiled();
plugin.configResolved({ root: '/project', resolve: { alias: [] } });
return plugin.transform.call({}, code, id);
}

function assertCompiled(result) {
assert.ok(result);
assert.match(result.code, /generated by @compiled\/babel-plugin/);
}