fix: make the standalone transform parse TypeScript and JSX - #3
Conversation
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, ...).
nitedani
left a comment
There was a problem hiding this comment.
Per-hunk notes on why each change is needed.
| "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" |
There was a problem hiding this comment.
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.
| "lib" | ||
| ], | ||
| "dependencies": { | ||
| "@babel/core": "^7.26.10", |
There was a problem hiding this comment.
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.
| }; | ||
|
|
||
| const virtualCssFiles = new Map(); | ||
| const defaultIncludeRE = /\.[tj]sx?$/; |
There was a problem hiding this comment.
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.
| } | ||
| } | ||
|
|
||
| plugins = [ |
There was a problem hiding this comment.
@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.
| 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/')) { |
There was a problem hiding this comment.
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.
| }); | ||
|
|
||
| describe('transform filter', () => { | ||
| it('transforms plain JavaScript files', async () => { |
There was a problem hiding this comment.
.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.
| assertCompiled(result); | ||
| }); | ||
|
|
||
| it('transforms plain TypeScript files', async () => { |
There was a problem hiding this comment.
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.
| assertCompiled(result); | ||
| }); | ||
|
|
||
| it('strips the query before filtering', async () => { |
There was a problem hiding this comment.
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.
| assert.match(result.code, /className=\{ax/); | ||
| }); | ||
|
|
||
| it('does not transform dependencies', async () => { |
There was a problem hiding this comment.
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.
| assert.equal(result, undefined); | ||
| }); | ||
|
|
||
| it('does not transform plain JavaScript without a Compiled import', async () => { |
There was a problem hiding this comment.
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.
The
cssprop stops working wherever@vitejs/plugin-reactdoesn't run@vitejs/plugin-react@6gates itself to client environments:The published plugin (1.3.1) delivers its Compiled pass through that plugin's
api.reactBabelhook, so as of plugin-react 6 the pass silently stops running in any server-side environment. Under React Server Components that leavescss={{ … }}on server components as an ordinary runtime prop, and it reaches the DOM ascss="[object Object]".It works on
@vitejs/plugin-react@5, which had no such gate — which is why this looks like a regression that appears on upgrade rather than a bug in this plugin.What's in this PR
Two things, kept together because the second is unusable without the first:
api.reactBabelhook — this was already in the working tree, not written by me..tsxfile failed oninterface,import typeand type annotations.The transform now parses TypeScript and JSX and leaves both for Vite's own transform to strip — Babel only needs to understand them, not lower them. Babel config resolution is pinned off (
configFile: false,babelrc: false) so a project.babelrccan't fight the Compiled pass. A leftover debugconsole.log, which printed every filename and its style rules on each build, is removed.Measured
Vike +
@vitejs/plugin-rscapplication, production build:css="[object Object]"in rendered HTML_19pkidpf,_ca0q1k92,_k48p8n31, …Example build passes all five stages; the compiled preview serves and the classes are present in the served HTML.
Worth deciding separately
Since the transform no longer rides on plugin-react, it runs in every environment by default. That's what fixes RSC, but you may want an explicit environment filter rather than relying on the
/\.[jt]sx$/guard alone — a client-only project now runs Babel in the SSR environment too, where it previously didn't.