Skip to content

fix(rsc): resolve export * from in use client modules#1373

Closed
james-elicx wants to merge 4 commits into
mainfrom
fix/issue-1352-use-client-export-all
Closed

fix(rsc): resolve export * from in use client modules#1373
james-elicx wants to merge 4 commits into
mainfrom
fix/issue-1352-use-client-export-all

Conversation

@james-elicx

@james-elicx james-elicx commented May 20, 2026

Copy link
Copy Markdown
Member

Summary

@vitejs/plugin-rsc's rsc:use-client transform throws unsupported ExportAllDeclaration whenever a "use client" module contains export * from '...' (or export * as Ns from '...'). This breaks any app that follows Next.js's test/e2e/app-dir/rsc-basic pattern of putting export * re-exports inside client boundaries.

Adds a new pre-pass Vite plugin, vinext:use-client-export-all, that runs in the RSC environment before rsc:use-client and rewrites:

  • export * from '...'export { a, b, c, ... } from '...' (named exports enumerated by resolving and parsing the target module; nested export * chains are recursed; default is intentionally not forwarded, matching ESM)
  • export * as Ns from '...'import * as Ns from '...'; export { Ns }; (a single named export the upstream transform can wrap as a client-reference proxy)
  • External / unresolvable targets are left alone — the upstream error then surfaces with its original wording, and users can switch to explicit named re-exports.

The plugin is registered before the auto-injected @vitejs/plugin-rsc in vinext's plugin array, at the default enforce position so it runs after Vite's built-in TS/JSX transform (parseAst doesn't accept TS/JSX).

Design notes

  • Reference: Next.js's flight loader handles export * differently — it sets the module's assumedSourceType back to module so webpack handles the re-export natively (see next-flight-loader/index.ts). We can't do the same in Rolldown/Vite, so we pre-expand the declaration instead. The visible behavior matches: each re-exported named export becomes its own client-reference proxy.
  • Test fixture mirrors .nextjs-ref/test/e2e/app-dir/rsc-basic/components/export-all/* (chained export *, renamed re-export, and a namespace re-export case).
  • The upstream transform location: node_modules/@vitejs/plugin-rsc/dist/transforms/index.js:338if (!options.ignoreExportAllDeclaration && node.type === "ExportAllDeclaration") throw new Error("unsupported ExportAllDeclaration");. The plugin exposes no opt-out, so a pre-pass is the only workaround without patching node_modules.

Upstream tracking

Test plan

  • pnpm test tests/use-client-export-all-build.test.ts — both new cases pass; both fail on main with the expected error
  • pnpm test tests/app-router.test.ts tests/build-optimization.test.ts tests/standalone-build.test.ts — 402 existing tests still pass
  • pnpm run check — clean (format, lint, types)
  • CI green

Closes #1352

`@vitejs/plugin-rsc`'s `rsc:use-client` transform throws
"unsupported ExportAllDeclaration" when a `"use client"` module contains
a `export * from '...'` or `export * as Ns from '...'` declaration. This
broke any app that pattern-matched Next.js's `test/e2e/app-dir/rsc-basic`
fixture, which puts `export *` re-exports inside client boundaries.

Add a pre-pass Vite plugin (`vinext:use-client-export-all`) that, in the
RSC environment, rewrites `export * from '...'` to an explicit
`export { a, b, c } from '...'` form by resolving the target module via
the Rollup plugin context and enumerating its named exports (recursing
through chained `export *` re-exports). `export * as Ns from '...'` is
rewritten to `import * as Ns from '...'; export { Ns };`. The default
export is intentionally not forwarded, matching standard ESM semantics.

The plugin is registered before the auto-injected `@vitejs/plugin-rsc`
in vinext's plugin array, at the default enforce position so it runs
after Vite's built-in TS/JSX transform.

Add `tests/use-client-export-all-build.test.ts` with two production
build fixtures (one for `export *` and one for `export * as Ns`),
both ported from
`.nextjs-ref/test/e2e/app-dir/rsc-basic/components/export-all/`.

Closes #1352
@pkg-pr-new

pkg-pr-new Bot commented May 20, 2026

Copy link
Copy Markdown

Open in StackBlitz

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

commit: 53343b3

@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

Comment thread packages/vinext/src/plugins/use-client-export-all.ts Fixed
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review

Replace the multi-alternation backtracking regex used by `quickHasUseClient`
with a single-pass manual scanner. The previous pattern (a `(?:\/\*...\*\/|//...)*`
group followed by a string literal) could exhibit catastrophic backtracking
on inputs starting with `/*` and many `*//*` repetitions — flagged by CodeQL
as a high-severity ReDoS (js/redos) on the PR diff.
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk

@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

Clean, well-structured workaround for the upstream @vitejs/plugin-rsc limitation. The approach is sound — a pre-pass transform at the same enforce level but earlier in the array is the right strategy, and the recursive export collection with cycle detection is correct. Two issues to address below.

Blocking

HAS_USE_CLIENT_RE has catastrophic backtracking (confirmed)

CodeQL flagged this correctly. The regex on line 34:

const HAS_USE_CLIENT_RE = /^\s*(?:\/\*[\s\S]*?\*\/\s*|\/\/[^\n]*\n\s*)*['"]use client['"]/;

I confirmed this hangs on inputs like "/*" + "*//*".repeat(50) + "x" (>10s, effectively infinite). The [\s\S]*? inside /* ... */ can match */ characters, creating ambiguity with the outer repetition (...)*. This is a real concern since every .js/.ts file in the project runs through quickHasUseClient in the RSC environment.

This repo already has a battle-tested O(n) manual parser for exactly this: hasReactDirective() in index.ts:443. That function handles BOM, hashbang, line comments, block comments, and directive prologues with zero backtracking risk, and it already checks for "use client".

Suggested fix: Extract hasReactDirective() to a shared utility and reuse it here. It does exactly what quickHasUseClient needs. Alternatively, if you want to keep a regex, the simplest fix is a two-step approach: code.includes("use client") && <regex> where the regex doesn't need to handle comments at all (just check the directive is at the start of a line), but honestly the existing function is the cleanest path.

Non-blocking observations

1. collectNamedExports misses destructured exports

collectNamedExports only handles d.id.type === "Identifier" for VariableDeclaration declarators (line 95). Destructured exports like export const { Provider, Consumer } = createContext() or export const [first, second] = tuple produce ObjectPattern / ArrayPattern nodes, which are silently skipped. The generated export { ... } from will be missing those names.

In practice this is low-risk — destructured re-exports in "use client" barrel files are rare, and the fallback behavior (missing names → upstream error → user switches to explicit re-exports) is reasonable. But worth noting for completeness. If you want to handle it, recursing into the pattern's identifiers is straightforward.

2. this.load() returns transformed code — this is correct

I verified that this.load({ id }) returns fully transformed code (post-oxc TS/JSX transform), so parseAst in collectNamedExports receiving standard ESM is safe. The comment on line 213-216 about parseAst not accepting TS/JSX is accurate for the top-level findExportAllDeclarations call (which operates on the code parameter passed to transform), and the recursive collectNamedExports path gets pre-transformed code from this.load(). Both paths are correctly handled.

3. Plugin ordering is correct

Confirmed: createUseClientExportAllPlugin() is registered at line 2277 in the plugins array, while rscPluginPromise (containing rsc:use-client) is pushed at line 4185. Both are at default enforce. Transform hooks fire in array order at the same enforce level, so this plugin's rewrite runs before the upstream transform sees the module.

4. Test coverage is good

The two test cases (chained export * with nested re-exports, and export * as Namespace) cover the main code paths well. The fixture structure mirrors the Next.js rsc-basic/components/export-all pattern as claimed.

Overall this is a well-motivated and well-documented workaround. Fix the regex and it's ready to merge.

namespaceName: string | null;
};

const HAS_USE_CLIENT_RE = /^\s*(?:\/\*[\s\S]*?\*\/\s*|\/\/[^\n]*\n\s*)*['"]use client['"]/;

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.

Confirmed: this regex causes catastrophic backtracking. The pattern [\s\S]*? inside /* ... */ can match */ itself, creating exponential ambiguity with the outer (...)* repetition.

Repro:

const HAS_USE_CLIENT_RE = /^\s*(?:\/\*[\s\S]*?\*\/\s*|\/\/[^\n]*\n\s*)*['\"'"]use client['\"']/;
// Hangs for >10s:
HAS_USE_CLIENT_RE.test("/*" + "*//*".repeat(50) + "x");

hasReactDirective() in index.ts:443 already does this correctly — manual O(n) comment-skipping parser that handles BOM, hashbang, line comments, block comments, and directive prologues. It already checks for "use client". Extract it to a shared module and reuse it here.

@ask-bonk

ask-bonk Bot commented May 20, 2026

Copy link
Copy Markdown
Contributor

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

Review Summary for PR #1373

Verdict: Request Changes — one blocking issue, the rest looks good.

Blocking: Catastrophic regex backtracking (line 34)

The HAS_USE_CLIENT_RE regex has confirmed catastrophic backtracking. Input like "/*" + "*//*".repeat(50) + "x" causes >10s hang. The [\s\S]*? inside /* ... */ can match */ itself, creating exponential ambiguity with the outer (...)* group. Every .js/.ts file in the RSC environment passes through this check.

Fix: Reuse the existing hasReactDirective() function from index.ts:443, which is a battle-tested O(n) manual parser that already handles this exact use case (BOM, hashbang, comments, "use client" detection).

Non-blocking observations

  1. collectNamedExports misses destructured exportsObjectPattern/ArrayPattern in export const { a, b } = ... are silently skipped. Low practical risk but worth noting.
  2. this.load() correctness — Verified that loaded modules are post-transform, so parseAst receives valid ESM. Both code paths are safe.
  3. Plugin ordering — Confirmed correct. The plugin is at array position 2277, rsc:use-client is pushed at 4185, both at default enforce.
  4. Test coverage — Good. Covers chained export * and export * as Namespace cases, mirrors the Next.js rsc-basic/components/export-all fixture.

github run

`collectNamedExports` previously only recognised `Identifier` declarator ids,
silently dropping destructured exports like `export const { Provider, Consumer } = ...`
or `export const [a, b] = ...`. The rewritten `export { ... } from` clause
would omit those names and consumers would see `undefined` at runtime.

Walk binding patterns (ObjectPattern / ArrayPattern / RestElement /
AssignmentPattern) and collect every bound identifier. Add a regression test
that builds a `"use client"` barrel re-exporting destructured bindings.
@james-elicx

Copy link
Copy Markdown
Member Author

Thanks @ask-bonk — addressed your review:

  • Blocking (ReDoS in HAS_USE_CLIENT_RE): fixed in 36776a9. Replaced the backtracking regex with a single-pass manual scanner (quickHasUseClient) that visits each character at most once. CodeQL re-ran on that commit and is now green.
  • Non-blocking Virtual module imports break esbuild dependency optimization when vinext is installed from npm #1 (destructured exports): fixed in 53343b3. collectNamedExports now walks ObjectPattern / ArrayPattern / RestElement / AssignmentPattern declarator ids, and there's a new regression test that builds a "use client" barrel re-exporting export const { First, Second } = ... and export const [Third] = ....

On the hasReactDirective() reuse suggestion: agreed the duplication isn't ideal, but the two functions have intentionally different semantics — hasReactDirective matches "use client" | "use server" and walks past prior directives like "use strict", while quickHasUseClient is a "use client"-only fast filter. Extracting a shared helper would mean either widening the plugin's behaviour (and re-running on "use server" files that the RSC transform handles fine) or parameterising the helper. Happy to do the refactor in a follow-up if you'd rather; the immediate ReDoS concern is resolved.

/bigbonk

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

Follow-up Review

Both issues from the initial review are resolved. Approving.

Blocking (ReDoS) — fixed in 36776a9

The HAS_USE_CLIENT_RE regex is gone, replaced by quickHasUseClient() — a manual single-pass scanner that visits each character at most once. The logic is correct:

  1. Skips whitespace (space, tab, LF, CR, FF, VT)
  2. Skips // line comments to end-of-line
  3. Skips /* block comments */ by scanning for */ without backtracking
  4. At the first non-whitespace/non-comment character, checks for a quote followed by use client followed by the matching quote

O(n) worst case, no backtracking possible. CodeQL confirms it's clean.

On the hasReactDirective() reuse question: the explanation is reasonable. The two functions have different semantics (hasReactDirective walks past "use strict" and checks for both "use client" and "use server"; quickHasUseClient is a "use client"-only fast filter that doesn't walk past prior directives). Merging them would either widen behavior or over-parameterize. Fine to leave as a follow-up if it ever becomes worth it.

Non-blocking #1 (destructured exports) — fixed in 53343b3

collectPatternNames correctly walks all ESTree binding pattern types:

  • Identifier → adds the name directly
  • ObjectPattern → iterates properties, handling RestElement (walks .argument) and regular properties (walks .value, which is the binding pattern for the property)
  • ArrayPattern → iterates elements, recursing into each
  • RestElement → recurses into .argument
  • AssignmentPattern → recurses into .left (the binding target, ignoring the default value)

This covers the full ESTree BindingPattern grammar. The regression test exercises both export const { First, Second } = ... (ObjectPattern) and export const [Third] = ... (ArrayPattern) through a "use client" barrel file with export * from "./inner".

CI

CodeQL, Check, and most E2E/deploy jobs are green. Vitest unit/integration and a couple E2E jobs are still running but the relevant test file (use-client-export-all-build.test.ts) is included in the integration shard, and Check already passed (format + lint + types).

@ask-bonk

ask-bonk Bot commented May 20, 2026

Copy link
Copy Markdown
Contributor

Approved PR #1373. Both issues from the initial review are resolved:

  1. ReDoS fix (36776a9): The backtracking regex is replaced with a correct O(n) manual scanner. CodeQL is green.
  2. Destructured exports fix (53343b3): collectPatternNames correctly walks the full ESTree BindingPattern grammar (Identifier, ObjectPattern, ArrayPattern, RestElement, AssignmentPattern), with a regression test covering both export const { ... } and export const [ ... ] through a "use client" barrel.

The hasReactDirective() reuse point is fine to leave — the two functions have intentionally different semantics and merging would add complexity without clear benefit.

github run

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

Both fixes verified, LGTM.

ReDoS (36776a9): quickHasUseClient is a clean O(n) manual scanner — no regex, no backtracking, each character visited at most once. CodeQL green.

Destructured exports (53343b3): collectPatternNames correctly walks the full ESTree BindingPattern grammar (Identifier, ObjectPattern, ArrayPattern, RestElement, AssignmentPattern). Regression test covers both export const { First, Second } = ... and export const [Third] = ... through a "use client" barrel.

On the hasReactDirective reuse question: agree with keeping them separate. The two have different semantics (hasReactDirective walks past "use strict", checks both "use client" and "use server"; quickHasUseClient is a "use client"-only fast filter). Merging would over-parameterize for no real benefit. Not worth a follow-up unless the pattern proliferates further.

CI: Check, CodeQL, Vitest (unit), most E2E and deploys all green. Three integration/E2E jobs still pending — nothing suggests they'll fail given the existing passes.

@ask-bonk

ask-bonk Bot commented May 20, 2026

Copy link
Copy Markdown
Contributor

Review posted. Both fixes are solid:

  • ReDoS fix replaces the vulnerable regex with a provably O(n) manual scanner — correct and clean.
  • Destructured exports fix adds proper ESTree pattern walking with good regression test coverage.
  • hasReactDirective reuse: agreed that keeping them separate is the right call given the different semantics. Not worth a follow-up unless the pattern repeats.

CI is nearly all green, PR is approved and ready to merge once the remaining 3 pending jobs finish.

github run

@james-elicx
james-elicx marked this pull request as ready for review May 20, 2026 20:20
@james-elicx
james-elicx deleted the fix/issue-1352-use-client-export-all branch May 21, 2026 20:42
james-elicx added a commit that referenced this pull request Jun 1, 2026
Ports the build fixture from #1373 to verify the `@vitejs/plugin-rsc`
0.5.27 bump resolves the upstream "unsupported ExportAllDeclaration"
error. Covers bare `export *`, nested/renamed re-exports,
`export * as Ns from`, and destructured re-exported bindings.

The version bump fixes this natively, so the vinext-side workaround
plugin from #1373 is no longer needed.

Refs #1352
james-elicx added a commit that referenced this pull request Jun 1, 2026
* chore(deps): bump `@vitejs/plugin-rsc` to 0.5.27

* test(rsc): cover `export * from` in 'use client' modules (#1352)

Ports the build fixture from #1373 to verify the `@vitejs/plugin-rsc`
0.5.27 bump resolves the upstream "unsupported ExportAllDeclaration"
error. Covers bare `export *`, nested/renamed re-exports,
`export * as Ns from`, and destructured re-exported bindings.

The version bump fixes this natively, so the vinext-side workaround
plugin from #1373 is no longer needed.

Refs #1352
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.

Build failure: export * from in "use client" files not supported by RSC plugin

2 participants