Skip to content

fix(rsc): allow JSX in .js client modules inside node_modules - #1295

Merged
james-elicx merged 1 commit into
mainfrom
fix/deploy-suite-jsx-in-node-modules
May 18, 2026
Merged

james-elicx merged 1 commit into
mainfrom
fix/deploy-suite-jsx-in-node-modules

Conversation

@james-elicx

Copy link
Copy Markdown
Member

Summary

Many third-party Next.js client libraries ship plain .js files containing 'use client' (or 'use server') plus JSX. Next.js's SWC pipeline compiles JSX in .js transparently, but vinext's vinext:jsx-in-js plugin (added in #1199) explicitly skipped node_modules, so @vitejs/plugin-rsc's rsc:use-client analysis pass would feed the raw source straight to rolldown/oxc with lang: 'js' and crash the production build.

Original error

[plugin rsc:use-client] /tmp/.../node_modules/.pnpm/@monorepo+adapter-next@.../client/index.js
RolldownError: Parse failed with 1 error:
Unexpected JSX expression
4:   return <p>hello world</p>
            ^

Affected Next.js fixtures

These are part of category A1 in the deploy-suite failure analysis.

Root cause

packages/vinext/src/index.ts configures vinext:jsx-in-js with:

if (cleanId.includes("/node_modules/")) return;

so any third-party .js file containing JSX bypasses the JSX transform. @vitejs/plugin-rsc then parses that file with rolldown/oxc using lang: 'js' (extension-inferred), which has no JSX support and throws RolldownError: Unexpected JSX expression.

Fix

Extend vinext:jsx-in-js so it also transforms .js/.mjs files inside node_modules when they begin with a React directive ('use client' or 'use server'). A small hasReactDirective() helper walks the directive prologue (skipping BOM, hashbang, line/block comments, and earlier directives like 'use strict') so we only opt into the transform when there is a real directive — code.includes('use client') alone would match incidental occurrences in template literals or comments.

Scoping the change this way:

  1. Solves the failure (the only files rsc:use-client parses are directive-bearing modules).
  2. Keeps build performance unchanged for the vast majority of node_modules .js files, which exit the plugin after a cheap String#includes check.
  3. Avoids forcing lang: 'jsx' on arbitrary library code that may use syntax the JSX-enabled OXC parser does not accept.

Validation

vp check                                       # format / lint / type — clean
vp test run tests/jsx-in-js-node-modules.test.ts
vp test run tests/jsx-in-js.test.ts tests/jsx-in-js-node-modules.test.ts \
            tests/intercepting-routes-build.test.ts tests/build-optimization.test.ts
vp test run tests/nextjs-compat/ tests/font-google-build.test.ts \
            tests/build-time-classification-integration.test.ts \
            tests/tsconfig-path-alias-build.test.ts                # 281 passing, 2 skipped, 0 regressions
vp run vinext#build                                                # clean

tests/jsx-in-js-node-modules.test.ts reproduces the exact error from the deploy-suite log on main (fixture mirrors @monorepo/adapter-next byte-for-byte) and passes with the fix applied.

Many third-party Next.js client libraries ship plain `.js` files containing
`'use client'` (or `'use server'`) plus JSX. Next.js's SWC pipeline compiles
JSX in `.js` transparently, but vinext's `vinext:jsx-in-js` plugin
previously skipped node_modules entirely, so `@vitejs/plugin-rsc`'s
`rsc:use-client` analysis pass would feed the raw source to rolldown/oxc
with `lang: 'js'` and fail:

    [plugin rsc:use-client] node_modules/.../client/index.js
    RolldownError: Parse failed with 1 error:
    Unexpected JSX expression
    4:   return <p>hello world</p>

This affected the Next.js deploy-suite test fixtures:
  - test/e2e/app-dir/next-dist-client-esm-import
  - test/e2e/app-dir/client-module-with-package-type

Fix: extend `vinext:jsx-in-js` to also handle `.js`/`.mjs` files inside
`node_modules` *when* they begin with a React directive. We mirror
`@vitejs/plugin-rsc`'s detection by parsing the directive prologue
(skipping BOM, hashbang, comments, and earlier directives like
`'use strict'`), which keeps the transform cost essentially zero for the
vast majority of node_modules files.

Validation:
  - vp check (format / lint / type) — clean
  - vp test run tests/jsx-in-js.test.ts tests/jsx-in-js-node-modules.test.ts
    tests/intercepting-routes-build.test.ts tests/build-optimization.test.ts
  - vp test run tests/nextjs-compat/ tests/font-google-build.test.ts
    tests/build-time-classification-integration.test.ts
    tests/tsconfig-path-alias-build.test.ts (281 passing, 0 regressions)
  - vp run vinext#build (clean)
  - New tests/jsx-in-js-node-modules.test.ts reproduces the reported error
    on main and passes with the fix.
@pkg-pr-new

pkg-pr-new Bot commented May 18, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/vinext@1295

commit: da4b62e

@github-actions

Copy link
Copy Markdown
Contributor
Example Preview Production Original
app-router-cloudflare preview production
pages-router-cloudflare preview production
app-router-playground preview production original
realworld-api-rest preview production
nextra-docs-template preview production
benchmarks preview production
hackernews preview production original
web preview production

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review: fix(rsc): allow JSX in .js client modules inside node_modules

Clean fix for a real production failure. The approach — scoping the JSX transform to directive-bearing node_modules .js files only — is the right design tradeoff between correctness and build performance. CI is all green.

What's good

  • Two-tier gating is smart. The cheap code.includes("use client") bail-out avoids any work for the vast majority of node_modules .js files. The hasReactDirective() parser only runs when the substring is present, which correctly avoids false positives from incidental occurrences in comments or template literals.

  • Directive prologue parser is correct for all practical inputs. I traced every code path: BOM stripping, hashbang, line/block comments, non-matching directives like "use strict" before the React directive, and backtick/escaped-quote rejection. All behave correctly. The \r\n handling is technically sloppy (post-directive skip on line 476 doesn't consume \r), but the outer /\s/ whitespace loop on line 448 catches it. Since npm packages universally use \n, this is fine.

  • Integration test reproduces the exact failure mode from the deploy-suite log and mirrors the Next.js fixture (@monorepo/adapter-next) byte-for-byte. Good.

  • Comment quality is excellent. The block comment explaining the plugin (lines 867–882) and the JSDoc on hasReactDirective explain the "why" clearly.

Suggestion: extract hasReactDirective and add unit tests

The function has ~45 lines of non-trivial parsing logic (BOM, hashbang, comments, directive scanning) with zero direct unit tests. The integration test exercises exactly one happy path ('use client' + JSX in .js). Consider extracting it to a utility module and adding a focused test file covering:

  1. "use server" directive (half the target directives are untested)
  2. "use strict" preceding "use client" (the directive-scanning loop)
  3. "use client" in a comment or template literal (false positive prevention — the whole point of having hasReactDirective instead of just includes)
  4. .mjs file (the regex matches both .js and .mjs but only .js is tested)
  5. BOM and hashbang prefixes
  6. Double-quoted "use client" (the integration test only uses single quotes)

This would be fast to run (pure function, no Vite build) and would make the integration test a complementary E2E validation rather than the sole coverage.

Not blocking — the function is correct and the integration test validates the production failure is fixed. But given how many edge cases the parser handles, direct tests would catch future regressions much faster than a 90s build test.

// Skip block comments.
if (code[i] === "/" && code[i + 1] === "*") {
const end = code.indexOf("*/", i + 2);
if (end === -1) return false;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: the post-directive skip on line 475 consumes ;, space, and tab but not \r. For a file like "use strict"\r\n"use client", the \r is left dangling, and we rely on the outer /\s/ whitespace loop (line 448) to clean it up on the next iteration. This works correctly, but if you ever refactor this into a standalone module, consider normalizing the newline consumption here to:

while (i < len && (code[i] === ';' || /\s/.test(code[i]!))) i++;

so the function is self-contained and doesn't depend on the outer loop's whitespace handling.

@ask-bonk

ask-bonk Bot commented May 18, 2026

Copy link
Copy Markdown
Contributor

Review posted successfully. Here's a summary of what I reviewed:

PR #1295: fix(rsc): allow JSX in .js client modules inside node_modules — Approved

The PR fixes a production build failure where third-party Next.js libraries shipping .js files with 'use client' + JSX (like @monorepo/adapter-next) would crash with RolldownError: Unexpected JSX expression because the vinext:jsx-in-js plugin was unconditionally skipping node_modules.

Key findings from the review:

  1. The fix is correct and well-scoped. The two-tier gating (cheap includes check, then precise hasReactDirective parser) avoids unnecessary work on the vast majority of node_modules files while correctly transforming directive-bearing ones.

  2. The hasReactDirective parser handles all practical edge cases correctly — BOM, hashbang, line/block comments, "use strict" before the React directive, backtick rejection, escaped quotes. There's a minor \r handling gap in the post-directive skip, but it's covered by the outer whitespace loop. No infinite loop risk.

  3. One suggestion (non-blocking): Extract hasReactDirective and add direct unit tests. The function has ~45 lines of parsing logic tested only through a single integration test path ('use client' in .js). Missing test coverage for "use server", "use strict" + "use client", false positive prevention (directive in comments/template literals), .mjs files, BOM, hashbang, and double-quoted directives. Left an inline comment on the \r\n handling.

  4. All CI checks pass — Check, Vitest (unit + 3 integration shards), all E2E projects, deploys, and smoke tests.

github run

@james-elicx
james-elicx marked this pull request as ready for review May 18, 2026 13:41
@james-elicx
james-elicx merged commit c483253 into main May 18, 2026
37 checks passed
@james-elicx
james-elicx deleted the fix/deploy-suite-jsx-in-node-modules branch May 18, 2026 13:41
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