Skip to content

fix: make the standalone transform parse TypeScript and JSX - #3

Draft
nitedani wants to merge 2 commits into
mainfrom
fix/standalone-transform-typescript
Draft

fix: make the standalone transform parse TypeScript and JSX#3
nitedani wants to merge 2 commits into
mainfrom
fix/standalone-transform-typescript

Conversation

@nitedani

@nitedani nitedani commented Aug 6, 2026

Copy link
Copy Markdown
Owner

The css prop stops working wherever @vitejs/plugin-react doesn't run

@vitejs/plugin-react@6 gates itself to client environments:

applyToEnvironmentHook: (env) => env.config.consumer === "client"

The published plugin (1.3.1) delivers its Compiled pass through that plugin's api.reactBabel hook, so as of plugin-react 6 the pass silently stops running in any server-side environment. Under React Server Components that leaves css={{ … }} on server components as an ordinary runtime prop, and it reaches the DOM as css="[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:

  1. The standalone transform that replaces the api.reactBabel hook — this was already in the working tree, not written by me.
  2. The fix that makes it run. It called Babel with no parser plugins, so every .tsx file failed on interface, import type and 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 .babelrc can't fight the Compiled pass. A leftover debug console.log, which printed every filename and its style rules on each build, is removed.

Measured

Vike + @vitejs/plugin-rsc application, production build:

before after
css="[object Object]" in rendered HTML 46 0
Compiled class names emitted none _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.

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 nitedani left a comment

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.

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"

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.

"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.

};

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.

}
}

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.

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.

});

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.

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.

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.

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.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant