Skip to content

fix(router): forward @rolldown/plugin-transform-imports to route discovery (vite-plugin-react#1519) - #1204

Merged
ivogt merged 1 commit into
mainfrom
fix/discovery-transform-imports
Oct 19, 2026
Merged

ivogt merged 1 commit into
mainfrom
fix/discovery-transform-imports

Conversation

@ivogt

@ivogt ivogt commented Oct 19, 2026

Copy link
Copy Markdown
Collaborator

devcorner: dc-t104 · session dc-s168

An app that follows plugin-rsc's new barrel-package recipe (vitejs/vite-plugin-react#1519) now builds under rango. Rango's route discovery now applies @rolldown/plugin-transform-imports's import rewrite too. Before, vite build failed, and in Cloudflare-preset dev every request answered 500.

Problem

Take a server component that imports one export from a sideEffects: false barrel package, where another module in that barrel imports client-only. plugin-rsc fails it with 'client-only' cannot be imported in server build, because the unused module is still in the module graph. vitejs/vite-plugin-react#1519 (open, docs and e2e only) documents the fix: rewrite barrel imports to subpath imports with @rolldown/plugin-transform-imports.

That works in plain plugin-rsc, but not under rango. Rango discovers routes in a temp Vite server that receives only user plugins with resolveId or load (selectForwardableResolvePlugins, src/vite/utils/forward-user-plugins.ts). This plugin has only a transform hook, so it was dropped. Discovery then loaded the whole barrel and failed:

Error: [rango] Build-time router discovery failed:
Error: 'client-only' cannot be imported in server build ('rsc' environment):

Before / after

The same app config and server component:

// vite.config.ts
import transformImports from "@rolldown/plugin-transform-imports";
import { rango } from "@rangojs/router/vite";

export default defineConfig({
  plugins: [
    transformImports({
      "some-ui-library": {
        transform: "some-ui-library/{{kebabCase member}}",
        skipDefaultConversion: true,
      },
    }),
    rango(),
  ],
});
// a server component
import { Button } from "some-ui-library"; // the barrel also exports Calendar, which imports client-only
  • Before: vite build exits 1 with [rango] Build-time router discovery failed. In Cloudflare-preset dev, every request (/, not only the barrel route) answers 500 with [rango] Failed to create temp runner: 'client-only' cannot be imported….
  • After: vite build succeeds, and the RSC bundle contains some-ui-library/button only. Cloudflare-preset dev renders the page.

End-to-end consumer example

This is the shape the e2e apps now use: a UI kit whose barrel mixes server-safe and client-only components.

// vite.config.ts (Cloudflare preset)
import { cloudflare } from "@cloudflare/vite-plugin";
import react from "@vitejs/plugin-react";
import transformImports from "@rolldown/plugin-transform-imports";
import { rango } from "@rangojs/router/vite";
import { defineConfig } from "vite";

export default defineConfig({
  plugins: [
    transformImports({
      "@acme/ui": {
        // Button -> @acme/ui/button; match the package's `exports` map
        transform: [
          ["[A-Z].*", "@acme/ui/{{kebabCase member}}"],
          ["*", "@acme/ui"],
        ],
        skipDefaultConversion: true,
      },
    }),
    react(),
    rango({ preset: "cloudflare" }),
    cloudflare({
      viteEnvironment: { name: "rsc", childEnvironments: ["ssr"] },
    }),
  ],
});
// src/pages/pricing.tsx (server component)
import { Button, Badge } from "@acme/ui";

export function PricingPage() {
  return (
    <main>
      <Badge>Pro</Badge>
      <Button>Upgrade</Button>
    </main>
  );
}

Semantics

Case Before After
Node preset, vite build fails in discovery builds
Node preset, dev works (discovery uses the main dev server) unchanged
Cloudflare preset, vite build fails in discovery builds
Cloudflare preset, dev 500 on every request renders
Barrel with a client-only module, without transform-imports fails (plugin-rsc's import validation) unchanged, same as plain plugin-rsc
Other transform-only user plugins (e.g. @tailwindcss/vite, @mdx-js/rollup) not forwarded to discovery unchanged (MDX follow-up: dc-t105)

Code change

  • packages/rangojs-router/src/vite/utils/forward-user-plugins.ts: adds IMPORT_REWRITE_PLUGINS, a named allowlist, currently "rolldown-plugin-transform-imports". Plugins on it are forwarded with their transform hook, including its filter. Its JSDoc states the rule for adding a plugin: its transform changes the code or imports discovery evaluates, and reads only state built at construction or in config/configResolved. The predicate hasResolutionHooks becomes isForwardable.

    const IMPORT_REWRITE_PLUGINS: ReadonlySet<string> = new Set([
      "rolldown-plugin-transform-imports",
    ]);
    
    function isForwardable(p: Plugin): boolean {
      return Boolean(
        (p as any).resolveId ||
        (p as any).load ||
        IMPORT_REWRITE_PLUGINS.has(p.name),
      );
    }
    // stripToResolutionHooks:
    if (IMPORT_REWRITE_PLUGINS.has(p.name))
      (stripped as any).transform = (p as any).transform;
  • Why a list by name, not something more general. The /simplify altitude review weighed the alternatives:

    • Forwarding every transform breaks @tailwindcss/vite: it caches per-environment state keyed by environment name, which the temp server shares.
    • Forwarding only transforms that declare a filter still catches Tailwind and misses MDX.
    • A user-facing option is new public API, and plugin runtime names don't match package names.
    • Loading the user's config file in the temp server runs a second rango() and cloudflare() lifecycle.
  • createTempRscServer (router-discovery.ts) is the only Vite server rango creates, so this one change covers the build, Cloudflare dev and dev prerender.

  • Comment-only edits in router-discovery.ts and discovery/state.ts.

  • Complexity: 12 non-comment source lines added and 3 removed, in 1 file. No new modules, state, wire fields or public API, and no client bytes. The new Set.has check runs once per user plugin, at configResolved.

Test plan

  • Unit: src/vite/__tests__/forward-user-plugins.test.ts, "forwards the barrel import rewriter with its transform". It pins that the plugin is forwarded with its exact transform hook object, filter included. The existing "skips plugins without resolution hooks" test still pins that other transform-only plugins are dropped.
  • e2e, router test-app: e2e/barrel-imports.test.ts, with barrel imports (dev) and barrel imports (production). Each loads /barrel-imports (a server component importing Button from rango-e2e-barrel-lib), then expects a 200, no page errors, and the button after hydration.
  • e2e, cloudflare-basic: e2e/barrel-imports.test.ts, dev and (production), the same contract.
  • Red before green, measured on this branch with only forward-user-plugins.ts reverted:
    • unit: 1 failed | 17 passed
    • test-app vite build: exit 1, [rango] Build-time router discovery failed: 'client-only' cannot be imported in server build ('rsc' environment)
    • cloudflare-basic vite build: exit 1, the same error. Its Playwright webServer fails to start for the same reason.
    • cloudflare-basic dev: GET /barrel-imports -> 500 and GET / -> 500, with [rango] Failed to create temp runner: 'client-only' cannot be imported…
    • The test-app dev case is not a red test: Node-preset dev never failed. The test-app's production describe and cloudflare-basic's dev and production describes are the ones that pin the fix.
  • Green:
    • router e2e, barrel-imports client-package-resolution native-tsconfig-paths prerender.test (dev + production): 94 passed
    • cloudflare-basic e2e, barrel-imports resolve-plugin-parity client-package-resolution prerender.test (dev + production): 25 passed
    • test:hmr-routes-local (route-types HMR, which CI skips): 21 passed
  • pnpm run typecheck, pnpm run test:unit:all (router 9145 unit + 1037 RSC passed, every consumer app passed), pnpm run lint, oxfmt check on the changed files. Every check:* script passes (suspense-contract, no-external-store, doc-links, docs-api, skills, skills-catalog, route-gen, e2e-bucketing, e2e-parity, e2e-console-baseline). The full e2e suites run on CI.

Notes

  • Upstream state: #1519 and #1511 are both open and unreleased. #1519 is docs and an e2e test only, so this needs no plugin-rsc bump. #1511 moves rsc:validate-imports to generateBundle, and the maintainer proposes #1519 in its place. #1511 alone would not help rango, because discovery runs in dev mode. Research: devcorner doc dc-d154.
  • A list by name is brittle to an upstream rename of the plugin's runtime name. Then only the e2e builds would catch it, not the unit test, whose fixture copies the string. Pinning the real name at unit level would need @rolldown/plugin-transform-imports as a router devDependency, so I left it to the e2e builds.
  • Order in the temp server: forwarded plugins run after rsc() and rango's transforms, while in the app's config the rewriter comes first. The rewrite only has to happen before imports are followed, so the order is fine here.
  • Follow-up (dc-t105): @mdx-js/rollup hits the same gap. apps/docs/vite.config.ts wraps it in a load hook for this reason (mdxLoad). unplugin-auto-import may also be affected, but that is inferred, not verified.
  • New test dependencies: @rolldown/plugin-transform-imports@^0.1.2 (devDependency) and a local rango-e2e-barrel-lib package, in both e2e apps. The barrel package is duplicated in both apps, following the rango-e2e-deep-context-lib precedent. /barrel-imports is nameless, so no *.gen.ts changes.

Practices

  • Lint: oxlint --deny-warnings reports 0, and oxfmt check passes on the changed files. A repo-wide oxfmt --check . also walks stale .claude/worktrees/* checkouts and fails on those, not on this diff.

  • Dead code: hasResolutionHooks is renamed to isForwardable; its only caller was in the same file. Nothing is left unused. In the test package, calendar.js is kept on purpose: it is the client-only module the barrel must not load. index.d.ts declares only Button, since TypeScript only sees the barrel import.

  • Tests: as listed above. I added the unit test and both e2e suites; the full e2e suites run on CI.

  • Code review: I ran /simplify with 4 Opus agents and applied:

    • the predicate fold
    • the allowlist rule in its JSDoc
    • a toEqual unit assertion
    • a trimmed test package (no per-subpath .d.ts files, no ./calendar export)
    • a string transform in the test configs
    • shorter comments

    I skipped the shared e2e scenario for the two near-identical specs, following the existing precedent. /code-review (medium) found 10 items and I fixed the 3 stale descriptions (discovery/state.ts, the router-discovery.ts capture comment, skills/react-compiler/SKILL.md). Left, with reasons in Notes:

    • the brittleness of matching by name
    • a user-facing option (new public API)
    • plugin order in the temp server
    • the duplicated fixture
    • a unit test that would run the transform inside a temp server (the e2e builds cover it)
    • requiring transform on a matching plugin (harmless as it is)
  • Docs:

    • docs/internal/feature-file-map.md (the Vite plugin row)
    • docs/react-compiler.md and skills/react-compiler/SKILL.md: which plugins discovery forwards
    • a CHANGELOG Unreleased entry

    No consumer guide recommends the barrel recipe yet. That can come with dc-t105.

…overy

plugin-rsc's README recommends @rolldown/plugin-transform-imports when a
server component imports from a barrel package whose other module imports
client-only (vitejs/vite-plugin-react#1519). Rango's discovery temp server
forwarded only resolveId/load user plugins, so the rewrite never ran there:
`vite build` failed with "[rango] Build-time router discovery failed:
'client-only' cannot be imported in server build", and Cloudflare-preset dev
answered 500 on every request.

Forward that plugin by name (IMPORT_REWRITE_PLUGINS) with its transform hook.
Other transform-only plugins are still dropped; the list's JSDoc states the
rule for adding one.

Tests: a forward-user-plugins unit test, and a barrel-imports e2e (dev and
production) in the router test-app and cloudflare-basic, backed by a
rango-e2e-barrel-lib test package.
@ivogt
ivogt force-pushed the fix/discovery-transform-imports branch from b9b7f8c to 45b9f8a Compare October 19, 2026 08:22
@ivogt
ivogt marked this pull request as ready for review October 19, 2026 08:22
@pkg-pr-new

pkg-pr-new Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@rangojs/router@1204

commit: 45b9f8a

@ivogt
ivogt merged commit 0f36986 into main Oct 19, 2026
59 checks passed
ivogt added a commit that referenced this pull request Oct 19, 2026
@ivogt
ivogt deleted the fix/discovery-transform-imports branch October 19, 2026 16:11
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