From c5d352fda2f437274377a48f0a3c350b8eaa8204 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Horv=C3=A1th=20D=C3=A1niel?= Date: Thu, 6 Aug 2026 02:13:04 +0200 Subject: [PATCH 1/2] fix: make the standalone transform parse TypeScript and JSX Contains two things, kept together because the second is unusable without the first: 1. The in-progress standalone transform that replaces the plugin-react `api.reactBabel` hook (already present in the working tree, not mine). 2. The fix that makes it actually run. The transform called Babel with no parser plugins, so every .tsx file failed on `interface`, `import type` and type annotations. It now parses TypeScript and JSX and leaves both for Vite's own transform to strip -- Babel only has to understand them, not lower them. Babel config resolution is also pinned off so a project .babelrc cannot fight the Compiled pass, and a leftover debug console.log that printed every filename and its style rules on each build is removed. Why the reactBabel hook stopped working: @vitejs/plugin-react@6 added applyToEnvironmentHook: (env) => env.config.consumer === "client" so it no longer runs in server-side environments. Since the published plugin delivers its transform through that hook, the Compiled pass silently stops running wherever plugin-react does not. Under React Server Components that leaves `css={{ ... }}` on server components as an ordinary runtime prop, and it reaches the DOM as css="[object Object]". Measured on a Vike + @vitejs/plugin-rsc application, production build: 46 occurrences of css="[object Object]" before, 0 after, with Compiled class names emitted (_19pkidpf, _ca0q1k92, ...). --- vite-plugin-compiled-react/src/index.ts | 140 ++++++++++++++---------- 1 file changed, 81 insertions(+), 59 deletions(-) diff --git a/vite-plugin-compiled-react/src/index.ts b/vite-plugin-compiled-react/src/index.ts index 55510eb..749a201 100644 --- a/vite-plugin-compiled-react/src/index.ts +++ b/vite-plugin-compiled-react/src/index.ts @@ -1,4 +1,5 @@ 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'; @@ -52,6 +53,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', @@ -82,6 +85,61 @@ export const compiled = (options: CompiledPluginOptions = {}): Plugin => { moduleResolverPluginAlias[find] = replacement; } } + + plugins = [ + { + visitor: { + Program(root) { + if ( + /node_modules/.test(this.filename) || + /extractAssets/.test(this.filename) + ) { + return; + } + if (/\.[jt]sx$/.test(this.filename)) { + + 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)) { @@ -145,66 +203,30 @@ 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) { + if (!/\.[jt]sx$/.test(id)) { + return; + } - 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}`) - ) - ); - } - }, - }, - }, - }); - } - }, + const res = await babel.transformAsync(code, { + filename: id, + 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, + }); + + if (!res || !res.code) { + return; + } + + return { + code: res.code, + map: res.map, + }; }, }; }; From c6cff3f3c6cf55ab1ee7f0c0b7c6f9cf90ea36e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Horv=C3=A1th=20D=C3=A1niel?= Date: Thu, 6 Aug 2026 04:28:44 +0200 Subject: [PATCH 2/2] fix: preserve plugin-react transform boundaries --- pnpm-lock.yaml | 6 ++ vite-plugin-compiled-react/package.json | 5 +- vite-plugin-compiled-react/src/index.ts | 32 +++++---- .../test/filter.test.mjs | 67 +++++++++++++++++++ 4 files changed, 97 insertions(+), 13 deletions(-) create mode 100644 vite-plugin-compiled-react/test/filter.test.mjs diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a7574f7..dfe24e3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,9 @@ importers: vite-plugin-compiled-react: dependencies: + '@babel/core': + specifier: ^7.26.10 + version: 7.26.10 '@babel/types': specifier: ^7.27.0 version: 7.27.0 @@ -63,6 +66,9 @@ importers: '@compiled/react': specifier: ^0.18.3 version: 0.18.3(react@19.1.0) + '@types/babel__core': + specifier: 7.20.5 + version: 7.20.5 '@types/node': specifier: ^22.14.0 version: 22.14.0 diff --git a/vite-plugin-compiled-react/package.json b/vite-plugin-compiled-react/package.json index 8d47974..2f0fc5b 100644 --- a/vite-plugin-compiled-react/package.json +++ b/vite-plugin-compiled-react/package.json @@ -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" }, "exports": { "import": "./lib/index.js" @@ -23,6 +24,7 @@ "lib" ], "dependencies": { + "@babel/core": "^7.26.10", "@babel/types": "^7.27.0", "@compiled/babel-plugin": "^0.37.1", "@compiled/babel-plugin-strip-runtime": "^0.37.1", @@ -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", diff --git a/vite-plugin-compiled-react/src/index.ts b/vite-plugin-compiled-react/src/index.ts index 749a201..362868b 100644 --- a/vite-plugin-compiled-react/src/index.ts +++ b/vite-plugin-compiled-react/src/index.ts @@ -2,10 +2,9 @@ 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 = { /** @@ -38,8 +37,10 @@ export type CompiledPluginOptions = { }; const virtualCssFiles = new Map(); +const defaultIncludeRE = /\.[tj]sx?$/; export const compiled = (options: CompiledPluginOptions = {}): Plugin => { + const filter = createFilter(defaultIncludeRE); const hash = (code: string) => { return createHash('md5').update(code).digest('hex').substring(2, 9); }; @@ -90,16 +91,10 @@ export const compiled = (options: CompiledPluginOptions = {}): Plugin => { { visitor: { Program(root) { - if ( - /node_modules/.test(this.filename) || - /extractAssets/.test(this.filename) - ) { + if (/extractAssets/.test(this.filename)) { return; } - if (/\.[jt]sx$/.test(this.filename)) { - - root.unshiftContainer('body', importDeclaration); - } + root.unshiftContainer('body', importDeclaration); }, }, }, @@ -204,12 +199,25 @@ export const compiled = (options: CompiledPluginOptions = {}): Plugin => { } }, async transform(code, id) { - if (!/\.[jt]sx$/.test(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/')) { + return; + } + const [filepath] = id.split('?'); + if (!filepath || !filter(filepath)) { + return; + } + if ( + !filepath.endsWith('x') && + !code.includes("'@compiled/react'") && + !code.includes('"@compiled/react"') + ) { return; } - const res = await babel.transformAsync(code, { filename: id, + sourceFileName: filepath, sourceMaps: true, plugins, // Parse only: TypeScript and JSX are left for Vite's own transform. Babel must still diff --git a/vite-plugin-compiled-react/test/filter.test.mjs b/vite-plugin-compiled-react/test/filter.test.mjs new file mode 100644 index 0000000..73fe597 --- /dev/null +++ b/vite-plugin-compiled-react/test/filter.test.mjs @@ -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 () => { + 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 () => { + 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 () => { + const result = await transform( + "export const Page = () =>
;", + '/project/src/style.tsx?direct' + ); + + assertCompiled(result); + assert.match(result.code, /className=\{ax/); + }); + + it('does not transform dependencies', async () => { + 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 () => { + 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/); +}