From 98cf913e7f3b46aa9d83b8c86a44380099498bbd Mon Sep 17 00:00:00 2001 From: Alex Date: Sun, 14 Jun 2026 17:24:53 +0200 Subject: [PATCH 01/11] feat(vite-plugin): RSC dev support via child worker environments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dev plugin hardcoded a single worker environment named "ssr". RSC apps (@vitejs/plugin-rsc: React Router RSC, Waku) run the worker in the "rsc" environment (react-server condition) and load "ssr" from it at runtime, so a single-env assumption can't host them — dev crashed with "registerMissingImport is not supported in dev rsc" and, once past that, failed SSR with a duplicate-React null dispatcher. Add a viteEnvironment { name, childEnvironments } option (mirroring the official @cloudflare/vite-plugin) and generalize the single "ssr" env to an entry env plus its children: - each worker env gets the workerd resolve conditions + dependency pre-bundling (noDiscovery: false), built per-env so each carries its own optimizeDeps.entries (a shared entries seeded from the rsc main left the ssr child with no scan root, causing a mid-session re-optimization that re-hashed and duplicated React) - dev connects a module runner for the entry env and each child, and awaits each env's depsOptimizer.init() before its runner imports - the worker entry environment name is the configured entry (was "ssr") Default (no viteEnvironment) is unchanged: entry "ssr", no children. --- .../cloudflare-rolldown-plugin/src/options.ts | 33 ++++ .../src/plugins/options.ts | 177 ++++++++++++------ .../cloudflare-vite-plugin/src/dev-plugin.ts | 51 +++-- .../cloudflare-vite-plugin/src/dev-server.ts | 5 +- 4 files changed, 184 insertions(+), 82 deletions(-) diff --git a/packages/cloudflare-rolldown-plugin/src/options.ts b/packages/cloudflare-rolldown-plugin/src/options.ts index 9ad597fb..8da89bc5 100644 --- a/packages/cloudflare-rolldown-plugin/src/options.ts +++ b/packages/cloudflare-rolldown-plugin/src/options.ts @@ -28,4 +28,37 @@ export interface CloudflarePluginOptions { * ``` */ exports?: Array; + /** + * Which Vite environment hosts the Worker, and any child environments it + * loads at runtime. Defaults to the single `ssr` environment. + * + * `@vitejs/plugin-rsc` apps run the Worker in the `rsc` environment (resolved + * with the `react-server` condition) and load the `ssr` environment from it, + * so they set `{ name: "rsc", childEnvironments: ["ssr"] }`. Every named + * environment is given the Worker treatment (workerd resolve conditions, + * dependency pre-bundling) and a module runner in dev. Mirrors the + * official `@cloudflare/vite-plugin` option of the same name. + * + * @default { name: "ssr", childEnvironments: [] } + */ + viteEnvironment?: { + name?: string; + childEnvironments?: Array; + }; +} + +/** + * Resolves the Worker's entry Vite environment and any child environments it + * loads at runtime. The entry environment owns the Worker's input and build + * output; children (e.g. an RSC app's `ssr` environment) still need the Worker + * treatment and a dev module runner so the entry can load modules from them. + */ +export function workerEnvironments(options: CloudflarePluginOptions): { + entry: string; + children: Array; + all: Array; +} { + const entry = options.viteEnvironment?.name ?? "ssr"; + const children = options.viteEnvironment?.childEnvironments ?? []; + return { entry, children, all: [entry, ...children] }; } diff --git a/packages/cloudflare-rolldown-plugin/src/plugins/options.ts b/packages/cloudflare-rolldown-plugin/src/plugins/options.ts index 61862ebd..5a88bf61 100644 --- a/packages/cloudflare-rolldown-plugin/src/plugins/options.ts +++ b/packages/cloudflare-rolldown-plugin/src/plugins/options.ts @@ -1,7 +1,7 @@ import path from "node:path"; import type * as vite from "vite"; import { createPlugin } from "../factory.js"; -import type { CloudflarePluginOptions } from "../options.js"; +import { type CloudflarePluginOptions, workerEnvironments } from "../options.js"; import { hasNodejsCompat } from "../utils.js"; import { WORKER_ENTRY_PREFIX } from "./virtual-modules.js"; @@ -55,10 +55,11 @@ export const optionsPlugin = createPlugin<"options", OptionsApi>("options", (plu async config(userConfig) { const vite = await import("vite"); const isRolldown = "rolldownVersion" in this.meta; + const { entry: entryEnv, children: childEnvs } = workerEnvironments(pluginOptions); input = normalizeInput( pluginOptions.main ?? - userConfig.environments?.ssr?.build?.rolldownOptions?.input ?? - userConfig.environments?.ssr?.build?.rollupOptions?.input ?? + userConfig.environments?.[entryEnv]?.build?.rolldownOptions?.input ?? + userConfig.environments?.[entryEnv]?.build?.rollupOptions?.input ?? {}, ); const rollupOptions: vite.Rollup.RollupOptions = { @@ -70,8 +71,115 @@ export const optionsPlugin = createPlugin<"options", OptionsApi>("options", (plu process.env.NODE_ENV || userConfig.mode || "production", ); const appType = userConfig.appType ?? (Object.keys(input).length === 0 ? "spa" : "custom"); + + // The dep-prebundle scanner for each Worker environment must be rooted + // at THAT environment's own entry, so its whole module graph (e.g. an + // RSC app's `ssr` env pulling react-dom/server + react) is bundled in + // the initial optimize pass. Sharing one `entries` (or one config + // object) across envs leaves children with no scan root, so their deps + // are discovered lazily and re-optimized mid-session — which re-hashes + // and duplicates singletons like React (a null hooks dispatcher). + const resolveEnvironmentEntries = (name: string): Array | undefined => { + const rawInput = + name === entryEnv + ? (pluginOptions.main ?? + userConfig.environments?.[name]?.build?.rolldownOptions?.input ?? + userConfig.environments?.[name]?.build?.rollupOptions?.input) + : (userConfig.environments?.[name]?.build?.rolldownOptions?.input ?? + userConfig.environments?.[name]?.build?.rollupOptions?.input ?? + pluginOptions.main); + if (!rawInput) return undefined; + const values = + typeof rawInput === "string" + ? [rawInput] + : Array.isArray(rawInput) + ? rawInput + : Object.values(rawInput); + return values.length > 0 ? values.map((value) => vite.normalizePath(value)) : undefined; + }; + + // The Worker treatment (workerd resolve conditions + dependency + // pre-bundling) applies to the entry environment AND every child + // environment it loads at runtime — without `noDiscovery: false` the + // child/entry envs get Vite's throwing deps optimizer, which breaks + // RSC dev (`registerMissingImport is not supported in dev `). A + // fresh config object is built per environment so each can carry its + // own `optimizeDeps.entries` without aliasing the others. + const makeWorkerEnvironment = ( + name: string, + { isEntry }: { isEntry: boolean }, + ): vite.EnvironmentOptions => ({ + resolve: { + noExternal: true, + conditions: [...DEFAULT_RESOLVE_CONDITION_NAMES, "development|production"], + }, + optimizeDeps: { + noDiscovery: false, + ignoreOutdatedRequests: true, + entries: resolveEnvironmentEntries(name), + ...(isRolldown + ? { + rolldownOptions: { + platform: "neutral", + resolve: { + conditionNames: [ + ...DEFAULT_RESOLVE_CONDITION_NAMES, + "development|production", + ], + mainFields: DEFAULT_RESOLVE_MAIN_FIELDS, + extensions: DEFAULT_RESOLVE_EXTENSIONS, + }, + transform: { + target: TARGET, + define, + }, + }, + } + : { + esbuildOptions: { + platform: "neutral", + conditions: [...DEFAULT_RESOLVE_CONDITION_NAMES, "development|production"], + resolveExtensions: DEFAULT_RESOLVE_EXTENSIONS, + mainFields: DEFAULT_RESOLVE_MAIN_FIELDS, + target: TARGET, + define, + }, + }), + }, + keepProcessEnv: true, + // The entry environment owns the Worker's build input and server + // output directory; children (e.g. `ssr`) keep their own build config + // from the framework plugin (`@vitejs/plugin-rsc`). + ...(isEntry + ? { + build: { + ssr: true, + target: TARGET, + emitAssets: true, + copyPublicDir: false, + outDir: getOutputDirectory(userConfig, "server"), + ...(isRolldown + ? { + rolldownOptions: { + ...rollupOptions, + platform: "neutral", + resolve: { + mainFields: DEFAULT_RESOLVE_MAIN_FIELDS, + extensions: DEFAULT_RESOLVE_EXTENSIONS, + }, + }, + } + : { rollupOptions }), + }, + } + : {}), + }); + return { appType, + // Legacy top-level `ssr` namespace: a baseline for any SSR-kind env + // that doesn't override. Each Worker env sets its own `resolve` + // below; this stays as a harmless default. ssr: { noExternal: true, resolve: { @@ -94,65 +202,10 @@ export const optionsPlugin = createPlugin<"options", OptionsApi>("options", (plu outDir: getOutputDirectory(userConfig, "client"), }, }, - ssr: { - resolve: { - noExternal: true, - conditions: [...DEFAULT_RESOLVE_CONDITION_NAMES, "development|production"], - }, - build: { - ssr: true, - target: TARGET, - emitAssets: true, - copyPublicDir: false, - outDir: getOutputDirectory(userConfig, "server"), - ...(isRolldown - ? { - rolldownOptions: { - ...rollupOptions, - platform: "neutral", - resolve: { - mainFields: DEFAULT_RESOLVE_MAIN_FIELDS, - extensions: DEFAULT_RESOLVE_EXTENSIONS, - }, - }, - } - : { rollupOptions }), - }, - optimizeDeps: { - noDiscovery: false, - ignoreOutdatedRequests: true, - entries: pluginOptions.main ? vite.normalizePath(pluginOptions.main) : undefined, - ...(isRolldown - ? { - rolldownOptions: { - platform: "neutral", - resolve: { - conditionNames: [ - ...DEFAULT_RESOLVE_CONDITION_NAMES, - "development|production", - ], - mainFields: DEFAULT_RESOLVE_MAIN_FIELDS, - extensions: DEFAULT_RESOLVE_EXTENSIONS, - }, - transform: { - target: TARGET, - define, - }, - }, - } - : { - esbuildOptions: { - platform: "neutral", - conditions: [...DEFAULT_RESOLVE_CONDITION_NAMES, "development|production"], - resolveExtensions: DEFAULT_RESOLVE_EXTENSIONS, - mainFields: DEFAULT_RESOLVE_MAIN_FIELDS, - target: TARGET, - define, - }, - }), - }, - keepProcessEnv: true, - }, + [entryEnv]: makeWorkerEnvironment(entryEnv, { isEntry: true }), + ...Object.fromEntries( + childEnvs.map((name) => [name, makeWorkerEnvironment(name, { isEntry: false })]), + ), }, }; }, diff --git a/packages/cloudflare-vite-plugin/src/dev-plugin.ts b/packages/cloudflare-vite-plugin/src/dev-plugin.ts index 0555ecc6..d44244dc 100644 --- a/packages/cloudflare-vite-plugin/src/dev-plugin.ts +++ b/packages/cloudflare-vite-plugin/src/dev-plugin.ts @@ -1,3 +1,4 @@ +import { workerEnvironments } from "@distilled.cloud/cloudflare-rolldown-plugin/options"; import type { OptionsApi } from "@distilled.cloud/cloudflare-rolldown-plugin/plugins"; import { resolvePluginApi } from "@distilled.cloud/cloudflare-rolldown-plugin/utils"; import type { RuntimeServices } from "@distilled.cloud/cloudflare-runtime"; @@ -28,26 +29,29 @@ export function dev(options: CloudflareVitePluginOptions): vite.Plugin { optionsApi = resolvePluginApi(plugins ?? [], "distilled-cloudflare:options"); }, config() { - return { - environments: { - ssr: { - dev: { - createEnvironment(name, config) { - const hasConfigureServer = config.plugins.some( - (plugin) => - plugin.name === "distilled-cloudflare:dev" && - plugin.configureServer !== undefined, - ); - if (!hasConfigureServer) { - return vite.createRunnableDevEnvironment(name, config); - } + const devEnvironmentConfig = { + dev: { + createEnvironment(name: string, config: vite.ResolvedConfig) { + const hasConfigureServer = config.plugins.some( + (plugin) => + plugin.name === "distilled-cloudflare:dev" && + plugin.configureServer !== undefined, + ); + if (!hasConfigureServer) { + return vite.createRunnableDevEnvironment(name, config); + } - return new DistilledDevEnvironment(name, config); - }, - }, + return new DistilledDevEnvironment(name, config); }, }, }; + // The entry environment and every child environment it loads at runtime + // run in workerd with a module runner. (Default: the single `ssr` env.) + return { + environments: Object.fromEntries( + workerEnvironments(options).all.map((name) => [name, devEnvironmentConfig]), + ), + }; }, async buildEnd() { if (!isServerRestarting) { @@ -89,9 +93,18 @@ export function dev(options: CloudflareVitePluginOptions): vite.Plugin { options.context ?? context!, ); const address = handle.address; - const ssrEnvironment = server.environments.ssr; - if (ssrEnvironment instanceof DistilledDevEnvironment) { - await ssrEnvironment.connect(address); + // Connect a module runner for the entry environment and each child it + // loads at runtime, so cross-environment imports (e.g. an RSC entry + // loading `ssr` modules) resolve to a live runner inside workerd. + // Settle each environment's dep optimizer BEFORE its runner starts + // importing: a re-optimization after first import re-hashes shared deps + // and duplicates singletons like React (a null hooks dispatcher). + for (const environmentName of workerEnvironments(options).all) { + const environment = server.environments[environmentName]; + if (environment instanceof DistilledDevEnvironment) { + await environment.depsOptimizer?.init(); + await environment.connect(address); + } } if (!input) { // If there is no input, we are in SPA mode, so we don't need to route requests to the server. diff --git a/packages/cloudflare-vite-plugin/src/dev-server.ts b/packages/cloudflare-vite-plugin/src/dev-server.ts index 41182d89..efee3e90 100644 --- a/packages/cloudflare-vite-plugin/src/dev-server.ts +++ b/packages/cloudflare-vite-plugin/src/dev-server.ts @@ -1,3 +1,4 @@ +import { workerEnvironments } from "@distilled.cloud/cloudflare-rolldown-plugin/options"; import type { BindingHooks, Module, RuntimeServices } from "@distilled.cloud/cloudflare-runtime"; import { layerRuntime, Runtime } from "@distilled.cloud/cloudflare-runtime"; import { @@ -103,7 +104,9 @@ const serve = Effect.fn(function* ( className: "ModuleRunnerDO", }), Json.local("__DISTILLED_ENVIRONMENT__", { - environmentName: "ssr", + // The Worker runs the entry environment (the `rsc` env for RSC apps, + // `ssr` otherwise); its module runner imports the entry from here. + environmentName: workerEnvironments(options).entry, entryId: entry.id, entryName: entry.name, }), From e9bac1d7e3a1d22b03f042cfff8b2414e493aa51 Mon Sep 17 00:00:00 2001 From: Alex Date: Sun, 14 Jun 2026 17:24:53 +0200 Subject: [PATCH 02/11] test(fixtures): add react-rsc and react-router-rsc dev fixtures Minimal @vitejs/plugin-rsc starter (react-rsc) and a React Router on RSC app (react-router-rsc), both wired through the child-environment model (viteEnvironment: { name: rsc, childEnvironments: [ssr] }) to exercise RSC dev. --- bun.lock | 96 +++++++++-- fixtures/react-router-rsc/README.md | 34 ++++ fixtures/react-router-rsc/app/paper.css | 150 ++++++++++++++++++ fixtures/react-router-rsc/app/root.tsx | 54 +++++++ fixtures/react-router-rsc/app/routes.ts | 21 +++ .../react-router-rsc/app/routes/about.tsx | 20 +++ .../react-router-rsc/app/routes/client.tsx | 22 +++ .../app/routes/home.actions.ts | 7 + .../app/routes/home.client.tsx | 12 ++ fixtures/react-router-rsc/app/routes/home.css | 3 + fixtures/react-router-rsc/app/routes/home.tsx | 42 +++++ .../app/routes/root.client.tsx | 44 +++++ .../app/routes/test-action-state/client.tsx | 19 +++ .../app/routes/test-action-state/server.tsx | 20 +++ fixtures/react-router-rsc/app/styles.css | 32 ++++ fixtures/react-router-rsc/package.json | 29 ++++ fixtures/react-router-rsc/public/favicon.ico | Bin 0 -> 15086 bytes .../react-router-vite/entry.browser.tsx | 56 +++++++ .../react-router-vite/entry.rsc.single.tsx | 9 ++ .../react-router-vite/entry.rsc.tsx | 36 +++++ .../react-router-vite/entry.ssr.tsx | 33 ++++ .../react-router-vite/entry.worker.tsx | 7 + .../react-router-vite/types.d.ts | 2 + fixtures/react-router-rsc/tsconfig.json | 15 ++ fixtures/react-router-rsc/vite.config.ts | 48 ++++++ fixtures/react-rsc/README.md | 40 +++++ fixtures/react-rsc/package.json | 27 ++++ fixtures/react-rsc/public/vite.svg | 1 + fixtures/react-rsc/src/action.tsx | 11 ++ fixtures/react-rsc/src/assets/react.svg | 1 + fixtures/react-rsc/src/client.tsx | 13 ++ .../react-rsc/src/framework/entry.browser.tsx | 138 ++++++++++++++++ .../react-rsc/src/framework/entry.rsc.tsx | 122 ++++++++++++++ .../react-rsc/src/framework/entry.ssr.tsx | 74 +++++++++ .../src/framework/error-boundary.tsx | 81 ++++++++++ fixtures/react-rsc/src/framework/request.tsx | 58 +++++++ fixtures/react-rsc/src/index.css | 112 +++++++++++++ fixtures/react-rsc/src/root.tsx | 71 +++++++++ fixtures/react-rsc/tsconfig.json | 17 ++ fixtures/react-rsc/vite.config.ts | 39 +++++ 40 files changed, 1602 insertions(+), 14 deletions(-) create mode 100644 fixtures/react-router-rsc/README.md create mode 100644 fixtures/react-router-rsc/app/paper.css create mode 100644 fixtures/react-router-rsc/app/root.tsx create mode 100644 fixtures/react-router-rsc/app/routes.ts create mode 100644 fixtures/react-router-rsc/app/routes/about.tsx create mode 100644 fixtures/react-router-rsc/app/routes/client.tsx create mode 100644 fixtures/react-router-rsc/app/routes/home.actions.ts create mode 100644 fixtures/react-router-rsc/app/routes/home.client.tsx create mode 100644 fixtures/react-router-rsc/app/routes/home.css create mode 100644 fixtures/react-router-rsc/app/routes/home.tsx create mode 100644 fixtures/react-router-rsc/app/routes/root.client.tsx create mode 100644 fixtures/react-router-rsc/app/routes/test-action-state/client.tsx create mode 100644 fixtures/react-router-rsc/app/routes/test-action-state/server.tsx create mode 100644 fixtures/react-router-rsc/app/styles.css create mode 100644 fixtures/react-router-rsc/package.json create mode 100644 fixtures/react-router-rsc/public/favicon.ico create mode 100644 fixtures/react-router-rsc/react-router-vite/entry.browser.tsx create mode 100644 fixtures/react-router-rsc/react-router-vite/entry.rsc.single.tsx create mode 100644 fixtures/react-router-rsc/react-router-vite/entry.rsc.tsx create mode 100644 fixtures/react-router-rsc/react-router-vite/entry.ssr.tsx create mode 100644 fixtures/react-router-rsc/react-router-vite/entry.worker.tsx create mode 100644 fixtures/react-router-rsc/react-router-vite/types.d.ts create mode 100644 fixtures/react-router-rsc/tsconfig.json create mode 100644 fixtures/react-router-rsc/vite.config.ts create mode 100644 fixtures/react-rsc/README.md create mode 100644 fixtures/react-rsc/package.json create mode 100644 fixtures/react-rsc/public/vite.svg create mode 100644 fixtures/react-rsc/src/action.tsx create mode 100644 fixtures/react-rsc/src/assets/react.svg create mode 100644 fixtures/react-rsc/src/client.tsx create mode 100644 fixtures/react-rsc/src/framework/entry.browser.tsx create mode 100644 fixtures/react-rsc/src/framework/entry.rsc.tsx create mode 100644 fixtures/react-rsc/src/framework/entry.ssr.tsx create mode 100644 fixtures/react-rsc/src/framework/error-boundary.tsx create mode 100644 fixtures/react-rsc/src/framework/request.tsx create mode 100644 fixtures/react-rsc/src/index.css create mode 100644 fixtures/react-rsc/src/root.tsx create mode 100644 fixtures/react-rsc/tsconfig.json create mode 100644 fixtures/react-rsc/vite.config.ts diff --git a/bun.lock b/bun.lock index 05fe7b54..e53b4526 100644 --- a/bun.lock +++ b/bun.lock @@ -15,6 +15,46 @@ "typescript": "catalog:", }, }, + "fixtures/react-router-rsc": { + "name": "@fixtures/react-router-rsc", + "dependencies": { + "react": "^19.2.7", + "react-dom": "^19.2.7", + "react-router": "7.16.0", + }, + "devDependencies": { + "@cloudflare/workers-types": "catalog:workers", + "@distilled.cloud/cloudflare-runtime": "workspace:*", + "@distilled.cloud/cloudflare-vite-plugin": "workspace:*", + "@tailwindcss/typography": "^0.5.19", + "@tailwindcss/vite": "^4.3.0", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "latest", + "@vitejs/plugin-rsc": "latest", + "tailwindcss": "^4.3.0", + "vite": "catalog:", + }, + }, + "fixtures/react-rsc": { + "name": "@fixtures/react-rsc", + "version": "0.0.0", + "dependencies": { + "react": "^19.2.7", + "react-dom": "^19.2.7", + }, + "devDependencies": { + "@cloudflare/workers-types": "catalog:workers", + "@distilled.cloud/cloudflare-runtime": "workspace:*", + "@distilled.cloud/cloudflare-vite-plugin": "workspace:*", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "latest", + "@vitejs/plugin-rsc": "latest", + "rsc-html-stream": "^0.0.7", + "vite": "catalog:", + }, + }, "fixtures/solid-ssr": { "name": "@fixtures/solid-ssr", "version": "0.0.0", @@ -427,6 +467,10 @@ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.7", "", { "os": "win32", "cpu": "x64" }, "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg=="], + "@fixtures/react-router-rsc": ["@fixtures/react-router-rsc@workspace:fixtures/react-router-rsc"], + + "@fixtures/react-rsc": ["@fixtures/react-rsc@workspace:fixtures/react-rsc"], + "@fixtures/solid-ssr": ["@fixtures/solid-ssr@workspace:fixtures/solid-ssr"], "@fixtures/solidstart": ["@fixtures/solidstart@workspace:fixtures/solidstart"], @@ -997,7 +1041,7 @@ "@types/node": ["@types/node@24.12.4", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA=="], - "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], + "@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="], "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], @@ -1011,7 +1055,9 @@ "@vercel/nft": ["@vercel/nft@1.5.0", "", { "dependencies": { "@mapbox/node-pre-gyp": "^2.0.0", "@rollup/pluginutils": "^5.1.3", "acorn": "^8.6.0", "acorn-import-attributes": "^1.9.5", "async-sema": "^3.1.1", "bindings": "^1.4.0", "estree-walker": "2.0.2", "glob": "^13.0.0", "graceful-fs": "^4.2.9", "node-gyp-build": "^4.2.2", "picomatch": "^4.0.2", "resolve-from": "^5.0.0" }, "bin": { "nft": "out/cli.js" } }, "sha512-IWTDeIoWhQ7ZtRO/JRKH+jhmeQvZYhtGPmzw/QGDY+wDCQqfm25P9yIdoAFagu4fWsK4IwZXDFIjrmp5rRm/sA=="], - "@vitejs/plugin-react": ["@vitejs/plugin-react@5.2.0", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw=="], + "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.2", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.0" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg=="], + + "@vitejs/plugin-rsc": ["@vitejs/plugin-rsc@0.5.27", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1", "es-module-lexer": "^2.1.0", "estree-walker": "^3.0.3", "magic-string": "^0.30.21", "srvx": "^0.11.15", "strip-literal": "^3.1.0", "turbo-stream": "^3.2.0", "vitefu": "^1.1.3" }, "peerDependencies": { "react": "*", "react-dom": "*", "react-server-dom-webpack": "*", "vite": "*" }, "optionalPeers": ["react-server-dom-webpack"] }, "sha512-s1fd5DUkPXk86DDHPM/kP93WrvI0MoA8klxdDZmD1fMSaA9xujfgunsm8ZoUH0FemR+63vNalFsIDR0AJH4ktg=="], "@vitest/expect": ["@vitest/expect@4.1.6", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.6", "@vitest/utils": "4.1.6", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg=="], @@ -1411,7 +1457,7 @@ "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], - "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + "js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="], "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], @@ -1655,12 +1701,14 @@ "rc9": ["rc9@3.0.1", "", { "dependencies": { "defu": "^6.1.6", "destr": "^2.0.5" } }, "sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ=="], - "react": ["react@19.2.6", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="], + "react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="], - "react-dom": ["react-dom@19.2.6", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.6" } }, "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g=="], + "react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="], "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], + "react-router": ["react-router@7.16.0", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-wArC8lVyJb3+jM9OpDyW6hLCizACWkvQR/sSGqSs+o5uEXEtGlqdZ4v8hENR3Jad6i+LRkK93q/+bQAcvl6V1A=="], + "readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="], "readdir-glob": ["readdir-glob@1.1.3", "", { "dependencies": { "minimatch": "^5.1.0" } }, "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA=="], @@ -1695,6 +1743,8 @@ "rou3": ["rou3@0.7.12", "", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="], + "rsc-html-stream": ["rsc-html-stream@0.0.7", "", {}, "sha512-v9+fuY7usTgvXdNl8JmfXCvSsQbq2YMd60kOeeMIqCJFZ69fViuIxztHei7v5mlMMa2h3SqS+v44Gu9i9xANZA=="], + "run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="], "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], @@ -1721,6 +1771,8 @@ "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], + "set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="], + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], @@ -1761,7 +1813,7 @@ "sql-escaper": ["sql-escaper@1.3.3", "", {}, "sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw=="], - "srvx": ["srvx@0.9.8", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-RZaxTKJEE/14HYn8COLuUOJAt0U55N9l1Xf6jj+T0GoA01EUH1Xz5JtSUOI+EHn+AEgPCVn7gk6jHJffrr06fQ=="], + "srvx": ["srvx@0.11.15", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-iXsux0UcOjdvs0LCMa2Ws3WwcDUozA3JN3BquNXkaFPP7TpRqgunKdEgoZ/uwb1J6xaYHfxtz9Twlh6yzwM6Tg=="], "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], @@ -1845,6 +1897,8 @@ "turbo": ["turbo@2.9.16", "", { "optionalDependencies": { "@turbo/darwin-64": "2.9.16", "@turbo/darwin-arm64": "2.9.16", "@turbo/linux-64": "2.9.16", "@turbo/linux-arm64": "2.9.16", "@turbo/windows-64": "2.9.16", "@turbo/windows-arm64": "2.9.16" }, "bin": { "turbo": "bin/turbo" } }, "sha512-NqgRQy6j6dPYcdSdv0q1g9QsZg7SWg87RERM8otw/1AtKU2yTFVClOM7cbwKzOonZr/Ek1blTBucw64L9H0Bwg=="], + "turbo-stream": ["turbo-stream@3.2.0", "", {}, "sha512-EK+bZ9UVrVh7JLslVFOV0GEMsociOqVOvEMTAd4ixMyffN5YNIEdLZWXUx5PJqDbTxSIBWw04HS9gCY4frYQDQ=="], + "type-fest": ["type-fest@5.6.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA=="], "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], @@ -1961,6 +2015,8 @@ "@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + "@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + "@babel/core/@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], "@babel/core/@babel/parser": ["@babel/parser@7.29.3", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA=="], @@ -2005,6 +2061,14 @@ "@fixtures/tanstack-start/@types/node": ["@types/node@22.19.19", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew=="], + "@fixtures/tanstack-start/@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], + + "@fixtures/tanstack-start/@vitejs/plugin-react": ["@vitejs/plugin-react@5.2.0", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw=="], + + "@fixtures/tanstack-start/react": ["react@19.2.6", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="], + + "@fixtures/tanstack-start/react-dom": ["react-dom@19.2.6", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.6" } }, "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g=="], + "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], "@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], @@ -2027,6 +2091,8 @@ "@solidjs/start/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + "@solidjs/start/srvx": ["srvx@0.9.8", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-RZaxTKJEE/14HYn8COLuUOJAt0U55N9l1Xf6jj+T0GoA01EUH1Xz5JtSUOI+EHn+AEgPCVn7gk6jHJffrr06fQ=="], + "@solidjs/start/vite": ["vite@7.3.3", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA=="], "@solidjs/vite-plugin-nitro-2/vite": ["vite@7.3.3", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA=="], @@ -2063,8 +2129,6 @@ "@tanstack/start-plugin-core/@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="], - "@tanstack/start-plugin-core/srvx": ["srvx@0.11.15", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-iXsux0UcOjdvs0LCMa2Ws3WwcDUozA3JN3BquNXkaFPP7TpRqgunKdEgoZ/uwb1J6xaYHfxtz9Twlh6yzwM6Tg=="], - "@tanstack/start-plugin-core/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], "@types/babel__core/@babel/parser": ["@babel/parser@7.29.3", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA=="], @@ -2073,8 +2137,6 @@ "@vercel/nft/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], - "@vitejs/plugin-react/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.3", "", {}, "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q=="], - "anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "archiver-utils/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], @@ -2085,9 +2147,9 @@ "babel-plugin-jsx-dom-expressions/@babel/helper-module-imports": ["@babel/helper-module-imports@7.18.6", "", { "dependencies": { "@babel/types": "^7.18.6" } }, "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA=="], - "h3-v2/rou3": ["rou3@0.8.1", "", {}, "sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA=="], + "h3/srvx": ["srvx@0.9.8", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-RZaxTKJEE/14HYn8COLuUOJAt0U55N9l1Xf6jj+T0GoA01EUH1Xz5JtSUOI+EHn+AEgPCVn7gk6jHJffrr06fQ=="], - "h3-v2/srvx": ["srvx@0.11.15", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-iXsux0UcOjdvs0LCMa2Ws3WwcDUozA3JN3BquNXkaFPP7TpRqgunKdEgoZ/uwb1J6xaYHfxtz9Twlh6yzwM6Tg=="], + "h3-v2/rou3": ["rou3@0.8.1", "", {}, "sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA=="], "lazystream/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], @@ -2137,8 +2199,6 @@ "strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "strip-literal/js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="], - "tar/yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], "unctx/unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="], @@ -2251,6 +2311,8 @@ "@fixtures/tanstack-start/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "@fixtures/tanstack-start/@vitejs/plugin-react/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.3", "", {}, "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q=="], + "@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], "@solidjs/start/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], @@ -2309,6 +2371,8 @@ "@tanstack/directive-functions-plugin/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + "@tanstack/directive-functions-plugin/@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + "@tanstack/directive-functions-plugin/@tanstack/router-utils/@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], "@tanstack/directive-functions-plugin/@tanstack/router-utils/@babel/parser": ["@babel/parser@7.29.3", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA=="], @@ -2317,8 +2381,12 @@ "@tanstack/server-functions-plugin/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + "@tanstack/server-functions-plugin/@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + "@tanstack/start-plugin-core/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + "@tanstack/start-plugin-core/@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + "archiver-utils/glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], "archiver-utils/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], diff --git a/fixtures/react-router-rsc/README.md b/fixtures/react-router-rsc/README.md new file mode 100644 index 00000000..27e7f9c7 --- /dev/null +++ b/fixtures/react-router-rsc/README.md @@ -0,0 +1,34 @@ +# rsc react-router + +https://vite-rsc-react-router.hiro18181.workers.dev + +> [!NOTE] +> React Router now provides [official RSC support](https://reactrouter.com/how-to/react-server-components) for Vite. The example might not be kept up to date with the latest version. Please refer to React Router's official documentation for the latest integrations. + +Vite RSC example based on demo made by React router team with Parcel: + +- https://github.com/jacob-ebey/parcel-plugin-react-router/ +- https://github.com/jacob-ebey/experimental-parcel-react-router-starter +- https://github.com/remix-run/react-router/tree/rsc/playground/rsc-vite + +See also [`rsc-movies`](https://github.com/hi-ogawa/rsc-movies/). + +[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/vitejs/vite-plugin-react/tree/main/packages/plugin-rsc/examples/react-router?file=src%2Froutes%2Froot.tsx) + +Or try it locally by: + +```sh +npx giget gh:vitejs/vite-plugin-react/packages/plugin-rsc/examples/react-router my-app +cd my-app +npm i +npm run dev +npm run build +npm run preview + +# run on @cloudflare/vite-plugin and deploy. +# a separate configuration is found in ./cf/vite.config.ts +npm run cf-dev +npm run cf-build +npm run cf-preview +npm run cf-release +``` diff --git a/fixtures/react-router-rsc/app/paper.css b/fixtures/react-router-rsc/app/paper.css new file mode 100644 index 00000000..761e5186 --- /dev/null +++ b/fixtures/react-router-rsc/app/paper.css @@ -0,0 +1,150 @@ +@theme { + --default-font-family: 'Patrick Hand SC', sans-serif; + --default-mono-font-family: 'Patrick Hand SC', sans-serif; + + --color-foreground: black; + --color-danger: rgb(167, 52, 45); + --color-secondary: rgb(11, 116, 213); + --color-success: rgb(134, 163, 97); + --color-warning: rgb(221, 205, 69); + --color-border: #cdcccb; + --color-border-active: rgba(0, 0, 0, 0.2); + + --color-paper-background: white; + --color-paper-border: #cdcccb; + --shadow-paper: -1px 5px 35px -9px rgba(0, 0, 0, 0.2); + + --shadow-btn: 15px 28px 25px -18px rgba(0, 0, 0, 0.2); + --shadow-btn-hover: 2px 8px 8px -5px rgba(0, 0, 0, 0.3); + --color-btn-border: black; + --btn-color-danger: var(--color-danger); + --btn-color-secondary: var(--color-secondary); + --btn-color-success: var(--color-success); + --btn-color-warning: var(--color-warning); +} + +@utility paper-border { + @apply border-2 border-border; + border-bottom-left-radius: 25px 115px; + border-bottom-right-radius: 155px 25px; + border-top-left-radius: 15px 225px; + border-top-right-radius: 25px 150px; +} + +@utility no-paper-border { + @apply border-0; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + border-top-left-radius: 0; + border-top-right-radius: 0; +} + +@utility paper-underline { + @apply border-b-3 border-[currentcolor]; + border-bottom-left-radius: 15px 3px; + border-bottom-right-radius: 15px 5px; + border-bottom-style: solid; +} + +@utility paper-underline-hover { + @apply paper-underline border-transparent; + @variant hover { + @apply border-[currentcolor]; + } +} + +@utility paper { + @apply border border-paper-border bg-paper-background p-8 shadow-paper; +} + +@utility breadcrumbs { + @apply flex flex-wrap gap-2; + & > * { + @apply inline-block after:text-lg after:content-[""] not-last:after:ml-2 not-last:after:text-foreground not-last:after:content-["/"]; + } + & > a { + @apply text-secondary; + } +} + +@utility btn { + @apply inline-block cursor-pointer bg-paper-background paper-border px-4 py-2 text-lg shadow-btn transition-[shadow_transition]; + + @variant active { + @apply border-border-active; + } + @variant hover { + @apply translate-y-1 shadow-btn-hover; + } + + &.btn-icon { + @apply aspect-square px-2 py-2; + & img, + & svg { + @apply h-7 w-7; + } + } +} + +@utility btn-* { + border-color: --value(--btn-color-*); + color: --value(--btn-color-*); +} + +@utility btn-sm { + @apply px-2 py-1 text-base; +} + +@utility btn-lg { + @apply px-6 py-3 text-2xl; +} + +@utility label { + @apply mb-1 block font-semibold; +} + +@utility input { + @apply paper-border px-3 py-2; + + @variant disabled { + @apply border-border-active; + } +} + +@utility checkbox { + @apply h-6 w-6 paper-border; + + @variant disabled { + @apply border-border-active; + } +} + +@utility select { + @apply paper-border px-3 py-2; + + @variant disabled { + @apply border-border-active; + } +} + +@layer base { + body { + @apply text-foreground; + } + + * { + @apply outline-secondary; + } +} + +@layer utilities { + .prose { + :where(u):not(:where([class~='not-prose'], [class~='not-prose'] *)) { + @apply paper-underline no-underline; + } + + :where(a):not(:where([class~='not-prose'], [class~='not-prose'] *)) { + @apply paper-underline-hover no-underline text-secondary; + } + } +} diff --git a/fixtures/react-router-rsc/app/root.tsx b/fixtures/react-router-rsc/app/root.tsx new file mode 100644 index 00000000..75b019e9 --- /dev/null +++ b/fixtures/react-router-rsc/app/root.tsx @@ -0,0 +1,54 @@ +import './styles.css' +import { Link, Outlet } from 'react-router' +import { TestClientState, TestHydrated } from './routes/client' +import { DumpError, GlobalNavigationLoadingBar } from './routes/root.client' + +export function Layout({ children }: { children: React.ReactNode }) { + console.log('[debug] root - Layout') + return ( + + + + + React Router Vite + + +
+ +
+ + {children} + + + ) +} + +export default function Component() { + console.log('[debug] root - Component') + return ( + <> + + + ) +} + +export function ErrorBoundary() { + return +} diff --git a/fixtures/react-router-rsc/app/routes.ts b/fixtures/react-router-rsc/app/routes.ts new file mode 100644 index 00000000..24914e7f --- /dev/null +++ b/fixtures/react-router-rsc/app/routes.ts @@ -0,0 +1,21 @@ +import type { unstable_RSCRouteConfigEntry } from 'react-router' + +export const routes: unstable_RSCRouteConfigEntry[] = [ + { + id: 'root', + path: '', + lazy: () => import('./root'), + children: [ + { + id: 'home', + index: true, + lazy: () => import('./routes/home'), + }, + { + id: 'about', + path: 'about', + lazy: () => import('./routes/about'), + }, + ], + }, +] diff --git a/fixtures/react-router-rsc/app/routes/about.tsx b/fixtures/react-router-rsc/app/routes/about.tsx new file mode 100644 index 00000000..a4a076ca --- /dev/null +++ b/fixtures/react-router-rsc/app/routes/about.tsx @@ -0,0 +1,20 @@ +'use client' + +import React from 'react' + +export function Component() { + const [count, setCount] = React.useState(0) + + return ( +
+
+

About

+

This is the about page.

+

[test-style-home]

+ +
+
+ ) +} diff --git a/fixtures/react-router-rsc/app/routes/client.tsx b/fixtures/react-router-rsc/app/routes/client.tsx new file mode 100644 index 00000000..679c9938 --- /dev/null +++ b/fixtures/react-router-rsc/app/routes/client.tsx @@ -0,0 +1,22 @@ +'use client' + +import React from 'react' + +export function TestHydrated() { + const hydrated = React.useSyncExternalStore( + React.useCallback(() => () => {}, []), + () => true, + () => false, + ) + return [hydrated: {hydrated ? 1 : 0}] +} + +export function TestClientState() { + return ( + + ) +} diff --git a/fixtures/react-router-rsc/app/routes/home.actions.ts b/fixtures/react-router-rsc/app/routes/home.actions.ts new file mode 100644 index 00000000..715166e5 --- /dev/null +++ b/fixtures/react-router-rsc/app/routes/home.actions.ts @@ -0,0 +1,7 @@ +'use server' + +export async function sayHello(defaultName: string, formData: FormData) { + await new Promise((resolve) => setTimeout(resolve, 500)) + const name = formData.get('name') || defaultName + console.log(`[debug] sayHello - ${name}`) +} diff --git a/fixtures/react-router-rsc/app/routes/home.client.tsx b/fixtures/react-router-rsc/app/routes/home.client.tsx new file mode 100644 index 00000000..6da32f10 --- /dev/null +++ b/fixtures/react-router-rsc/app/routes/home.client.tsx @@ -0,0 +1,12 @@ +'use client' + +import { useFormStatus } from 'react-dom' + +export function PendingButton() { + const status = useFormStatus() + return ( + + ) +} diff --git a/fixtures/react-router-rsc/app/routes/home.css b/fixtures/react-router-rsc/app/routes/home.css new file mode 100644 index 00000000..7204e2fd --- /dev/null +++ b/fixtures/react-router-rsc/app/routes/home.css @@ -0,0 +1,3 @@ +.test-style-home { + color: rgb(250, 150, 0); +} diff --git a/fixtures/react-router-rsc/app/routes/home.tsx b/fixtures/react-router-rsc/app/routes/home.tsx new file mode 100644 index 00000000..fdaf9db3 --- /dev/null +++ b/fixtures/react-router-rsc/app/routes/home.tsx @@ -0,0 +1,42 @@ +import { sayHello } from './home.actions.ts' +import { PendingButton } from './home.client.tsx' +import './home.css' +import { TestActionStateServer } from './test-action-state/server.tsx' + +const Component = () => { + return ( +
+
+

Home

+

This is the home page.

+ [test-style-home] +

Server Action

+
+
+ + +
+
+ +
+
+
+ +
+
+
+ ) +} + +export default Component diff --git a/fixtures/react-router-rsc/app/routes/root.client.tsx b/fixtures/react-router-rsc/app/routes/root.client.tsx new file mode 100644 index 00000000..be3a8e3d --- /dev/null +++ b/fixtures/react-router-rsc/app/routes/root.client.tsx @@ -0,0 +1,44 @@ +'use client' + +import { useNavigation, useRouteError } from 'react-router' + +export function GlobalNavigationLoadingBar() { + const navigation = useNavigation() + + if (navigation.state === 'idle') return null + + return ( +
+
+
+ ) +} + +export function DumpError() { + const error = useRouteError() + const message = + error instanceof Error ? ( +
+
+          {JSON.stringify(
+            {
+              ...error,
+              name: error.name,
+              message: error.message,
+            },
+            null,
+            2,
+          )}
+        
+ {error.stack &&
{error.stack}
} +
+ ) : ( +
Unknown Error
+ ) + return ( + <> +

Oooops

+
{message}
+ + ) +} diff --git a/fixtures/react-router-rsc/app/routes/test-action-state/client.tsx b/fixtures/react-router-rsc/app/routes/test-action-state/client.tsx new file mode 100644 index 00000000..520dab49 --- /dev/null +++ b/fixtures/react-router-rsc/app/routes/test-action-state/client.tsx @@ -0,0 +1,19 @@ +'use client' + +import React from 'react' + +export function TestActionStateClient(props: { + action: (prev: React.ReactNode) => Promise +}) { + const [state, formAction, isPending] = React.useActionState( + props.action, + null, + ) + + return ( +
+ + {isPending ? 'pending...' : state} +
+ ) +} diff --git a/fixtures/react-router-rsc/app/routes/test-action-state/server.tsx b/fixtures/react-router-rsc/app/routes/test-action-state/server.tsx new file mode 100644 index 00000000..128186e2 --- /dev/null +++ b/fixtures/react-router-rsc/app/routes/test-action-state/server.tsx @@ -0,0 +1,20 @@ +import { TestActionStateClient } from './client' + +// Test case based on +// https://github.com/remix-run/react-router/issues/13882 + +export function TestActionStateServer({ message }: { message: string }) { + return ( + { + 'use server' + await new Promise((resolve) => setTimeout(resolve, 200)) + return ( + + [(ok) ({message})] {prev} + + ) + }} + /> + ) +} diff --git a/fixtures/react-router-rsc/app/styles.css b/fixtures/react-router-rsc/app/styles.css new file mode 100644 index 00000000..e1a22e4e --- /dev/null +++ b/fixtures/react-router-rsc/app/styles.css @@ -0,0 +1,32 @@ +@import 'tailwindcss'; +@plugin "@tailwindcss/typography"; + +@import './paper.css'; + +@theme { + --animate-progress: progress 1s infinite linear; + + @keyframes progress { + 0% { + transform: translateX(0) scaleX(0); + } + 40% { + transform: translateX(0) scaleX(0.4); + } + 100% { + transform: translateX(100%) scaleX(0.5); + } + } +} + +@utility vt-name { + view-transition-name: var(--vt-name); +} + +@utility no-vt { + view-transition-name: none; +} + +@view-transition { + navigation: auto; +} diff --git a/fixtures/react-router-rsc/package.json b/fixtures/react-router-rsc/package.json new file mode 100644 index 00000000..653e9aa1 --- /dev/null +++ b/fixtures/react-router-rsc/package.json @@ -0,0 +1,29 @@ +{ + "name": "@fixtures/react-router-rsc", + "private": true, + "license": "MIT", + "type": "module", + "scripts": { + "dev": "vite dev --port 3200", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.2.7", + "react-dom": "^19.2.7", + "react-router": "7.16.0" + }, + "devDependencies": { + "@cloudflare/workers-types": "catalog:workers", + "@distilled.cloud/cloudflare-runtime": "workspace:*", + "@distilled.cloud/cloudflare-vite-plugin": "workspace:*", + "@tailwindcss/typography": "^0.5.19", + "@tailwindcss/vite": "^4.3.0", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "latest", + "@vitejs/plugin-rsc": "latest", + "tailwindcss": "^4.3.0", + "vite": "catalog:" + } +} diff --git a/fixtures/react-router-rsc/public/favicon.ico b/fixtures/react-router-rsc/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..5dbdfcddcb14182535f6d32d1c900681321b1aa3 GIT binary patch literal 15086 zcmeI33v3ic7{|AFEmuJ-;v>ep_G*NPi6KM`qNryCe1PIJ8siIN1WZ(7qVa)RVtmC% z)Ch?tN+afMKm;5@rvorJk zcXnoOc4q51HBQnQH_jn!cAg&XI1?PlX>Kl^k8qq0;zkha`kY$Fxt#=KNJAE9CMdpW zqr4#g8`nTw191(+H4xW8Tmyru2I^3=J1G3emPxkPXA=3{vvuvse_WWSshqaqls^-m zgB7q8&Vk*aYRe?sn$n53dGH#%3y%^vxv{pL*-h0Z4bmb_(k6{FL7HWIz(V*HT#IcS z-wE{)+0x1U!RUPt3gB97%p}@oHxF4|6S*+Yw=_tLtxZ~`S=z6J?O^AfU>7qOX`JNBbV&8+bO0%@fhQitKIJ^O^ zpgIa__qD_y07t@DFlBJ)8SP_#^j{6jpaXt{U%=dx!qu=4u7^21lWEYHPPY5U3TcoQ zX_7W+lvZi>TapNk_X>k-KO%MC9iZp>1E`N34gHKd9tK&){jq2~7OsJ>!G0FzxQFw6G zm&Vb(2#-T|rM|n3>uAsG_hnbvUKFf3#ay@u4uTzia~NY%XgCHfx4^To4BDU@)HlV? z@EN=g^ymETa1sQK{kRwyE4Ax8?wT&GvaG@ASO}{&a17&^v`y z!oPdiSiia^oov(Z)QhG2&|FgE{M9_4hJROGbnj>#$~ZF$-G^|zPj*QApltKe?;u;uKHJ~-V!=VLkg7Kgct)l7u39f@%VG8e3f$N-B zAu3a4%ZGf)r+jPAYCSLt73m_J3}p>}6Tx0j(wg4vvKhP!DzgiWANiE;Ppvp}P2W@m z-VbYn+NXFF?6ngef5CfY6ZwKnWvNV4z6s^~yMXw2i5mv}jC$6$46g?G|CPAu{W5qF zDobS=zb2ILX9D827g*NtGe5w;>frjanY{f)hrBP_2ehBt1?`~ypvg_Ot4x1V+43P@Ve8>qd)9NX_jWdLo`Zfy zoeam9)@Dpym{4m@+LNxXBPjPKA7{3a&H+~xQvr>C_A;7=JrfK~$M2pCh>|xLz>W6SCs4qC|#V`)# z)0C|?$o>jzh<|-cpf

K7osU{Xp5PG4-K+L2G=)c3f&}H&M3wo7TlO_UJjQ-Oq&_ zjAc9=nNIYz{c3zxOiS5UfcE1}8#iI4@uy;$Q7>}u`j+OU0N<*Ezx$k{x_27+{s2Eg z`^=rhtIzCm!_UcJ?Db~Lh-=_))PT3{Q0{Mwdq;0>ZL%l3+;B&4!&xm#%HYAK|;b456Iv&&f$VQHf` z>$*K9w8T+paVwc7fLfMlhQ4)*zL_SG{~v4QR;IuX-(oRtYAhWOlh`NLoX0k$RUYMi z2Y!bqpdN}wz8q`-%>&Le@q|jFw92ErW-hma-le?S z-@OZt2EEUm4wLsuEMkt4zlyy29_3S50JAcQHTtgTC{P~%-mvCTzrjXOc|{}N`Cz`W zSj7CrXfa7lcsU0J(0uSX6G`54t^7}+OLM0n(|g4waOQ}bd3%!XLh?NX9|8G_|06Ie zD5F1)w5I~!et7lA{G^;uf7aqT`KE&2qx9|~O;s6t!gb`+zVLJyT2T)l*8l(j literal 0 HcmV?d00001 diff --git a/fixtures/react-router-rsc/react-router-vite/entry.browser.tsx b/fixtures/react-router-rsc/react-router-vite/entry.browser.tsx new file mode 100644 index 00000000..7b30c863 --- /dev/null +++ b/fixtures/react-router-rsc/react-router-vite/entry.browser.tsx @@ -0,0 +1,56 @@ +import { + createFromReadableStream, + createTemporaryReferenceSet, + encodeReply, + setServerCallback, +} from '@vitejs/plugin-rsc/browser' +import { startTransition, StrictMode } from 'react' +import { hydrateRoot } from 'react-dom/client' +import { + type DataRouter, + type unstable_RSCPayload as RSCServerPayload, +} from 'react-router' +import { + unstable_createCallServer as createCallServer, + unstable_getRSCStream as getRSCStream, + unstable_RSCHydratedRouter as RSCHydratedRouter, +} from 'react-router/dom' + +// Create and set the callServer function to support post-hydration server actions. +setServerCallback( + createCallServer({ + createFromReadableStream, + createTemporaryReferenceSet, + encodeReply, + }), +) + +// Get and decode the initial server payload +createFromReadableStream(getRSCStream()).then((payload) => { + startTransition(async () => { + const formState = + payload.type === 'render' ? await payload.formState : undefined + + hydrateRoot( + document, + + + , + { + // @ts-expect-error - no types for this yet + formState, + }, + ) + }) +}) + +declare let __reactRouterDataRouter: DataRouter + +if (import.meta.hot) { + import.meta.hot.on('rsc:update', () => { + __reactRouterDataRouter.revalidate() + }) +} diff --git a/fixtures/react-router-rsc/react-router-vite/entry.rsc.single.tsx b/fixtures/react-router-rsc/react-router-vite/entry.rsc.single.tsx new file mode 100644 index 00000000..3df903d4 --- /dev/null +++ b/fixtures/react-router-rsc/react-router-vite/entry.rsc.single.tsx @@ -0,0 +1,9 @@ +import { fetchServer } from './entry.rsc' + +export default async function handler(request: Request) { + const ssr = await import.meta.viteRsc.loadModule< + typeof import('./entry.ssr') + >('ssr', 'index') + + return ssr.default(request, await fetchServer(request)) +} diff --git a/fixtures/react-router-rsc/react-router-vite/entry.rsc.tsx b/fixtures/react-router-rsc/react-router-vite/entry.rsc.tsx new file mode 100644 index 00000000..847211bf --- /dev/null +++ b/fixtures/react-router-rsc/react-router-vite/entry.rsc.tsx @@ -0,0 +1,36 @@ +import { + createTemporaryReferenceSet, + decodeAction, + decodeFormState, + decodeReply, + loadServerAction, + renderToReadableStream, +} from '@vitejs/plugin-rsc/rsc' +import { unstable_matchRSCServerRequest as matchRSCServerRequest } from 'react-router' +import { routes } from '../app/routes' + +export function fetchServer(request: Request) { + return matchRSCServerRequest({ + // Provide the React Server touchpoints. + createTemporaryReferenceSet, + decodeAction, + decodeFormState, + decodeReply, + loadServerAction, + // The incoming request. + request, + // The app routes. + routes, + // Encode the match with the React Server implementation. + generateResponse(match, options) { + return new Response(renderToReadableStream(match.payload, options), { + status: match.statusCode, + headers: match.headers, + }) + }, + }) +} + +if (import.meta.hot) { + import.meta.hot.accept() +} diff --git a/fixtures/react-router-rsc/react-router-vite/entry.ssr.tsx b/fixtures/react-router-rsc/react-router-vite/entry.ssr.tsx new file mode 100644 index 00000000..4e356c1b --- /dev/null +++ b/fixtures/react-router-rsc/react-router-vite/entry.ssr.tsx @@ -0,0 +1,33 @@ +import { createFromReadableStream } from '@vitejs/plugin-rsc/ssr' +import { renderToReadableStream as renderHTMLToReadableStream } from 'react-dom/server.edge' +import { + unstable_routeRSCServerRequest as routeRSCServerRequest, + unstable_RSCStaticRouter as RSCStaticRouter, +} from 'react-router' + +export default async function handler( + request: Request, + serverResponse: Response, +): Promise { + const bootstrapScriptContent = + await import.meta.viteRsc.loadBootstrapScriptContent('index') + + return await routeRSCServerRequest({ + request, + serverResponse, + createFromReadableStream, + async renderHTML(getPayload, options) { + const payload = getPayload() + + return await renderHTMLToReadableStream( + , + { + ...options, + bootstrapScriptContent, + signal: request.signal, + formState: await payload.formState, + }, + ) + }, + }) +} diff --git a/fixtures/react-router-rsc/react-router-vite/entry.worker.tsx b/fixtures/react-router-rsc/react-router-vite/entry.worker.tsx new file mode 100644 index 00000000..bd5075cc --- /dev/null +++ b/fixtures/react-router-rsc/react-router-vite/entry.worker.tsx @@ -0,0 +1,7 @@ +import handler from "./entry.rsc.single" + +// The distilled Cloudflare worker wrapper expects a `{ fetch }` default export; +// the RSC single-worker handler is a bare (request) => Response function. +export default { + fetch: (request: Request) => handler(request), +} diff --git a/fixtures/react-router-rsc/react-router-vite/types.d.ts b/fixtures/react-router-rsc/react-router-vite/types.d.ts new file mode 100644 index 00000000..bb5578e1 --- /dev/null +++ b/fixtures/react-router-rsc/react-router-vite/types.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/fixtures/react-router-rsc/tsconfig.json b/fixtures/react-router-rsc/tsconfig.json new file mode 100644 index 00000000..1950c5a2 --- /dev/null +++ b/fixtures/react-router-rsc/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "allowImportingTsExtensions": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "skipLibCheck": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "moduleResolution": "Bundler", + "module": "ESNext", + "target": "ESNext", + "lib": ["ESNext", "DOM"], + "jsx": "react-jsx" + } +} diff --git a/fixtures/react-router-rsc/vite.config.ts b/fixtures/react-router-rsc/vite.config.ts new file mode 100644 index 00000000..d22d28ae --- /dev/null +++ b/fixtures/react-router-rsc/vite.config.ts @@ -0,0 +1,48 @@ +import cloudflare from "@distilled.cloud/cloudflare-vite-plugin" +import tailwindcss from "@tailwindcss/vite" +import react from "@vitejs/plugin-react" +import rsc from "@vitejs/plugin-rsc" +import { defineConfig } from "vite" + +// React Router (hand-rolled on @vitejs/plugin-rsc) wired to the distilled +// Cloudflare vite plugin via the single-worker child-environment model — +// the same topology vermittelbar uses with the official plugin +// (`viteEnvironment: { name: "rsc", childEnvironments: ["ssr"] }`). The worker +// IS the `rsc` env; its handler loads `ssr` at runtime via +// `import.meta.viteRsc.loadModule("ssr", ...)`. +export default defineConfig({ + clearScreen: false, + build: { minify: false }, + plugins: [ + tailwindcss(), + { + // Workaround for https://github.com/tailwindlabs/tailwindcss/pull/19670 + name: "fix-tailwind-full-reload", + configResolved(config) { + const plugin = config.plugins.find( + (p) => p.name === "@tailwindcss/vite:generate:serve", + ) + delete plugin?.hotUpdate + }, + }, + react(), + rsc({ + serverHandler: false, + entries: { + client: "./react-router-vite/entry.browser.tsx", + ssr: "./react-router-vite/entry.ssr.tsx", + rsc: "./react-router-vite/entry.worker.tsx", + }, + }), + cloudflare({ + main: "./react-router-vite/entry.worker.tsx", + compatibilityDate: "2026-03-10", + compatibilityFlags: ["nodejs_compat"], + viteEnvironment: { name: "rsc", childEnvironments: ["ssr"] }, + worker: { name: "fixtures-react-router-rsc" }, + }), + ], + optimizeDeps: { + include: ["react-router", "react-router/internal/react-server-client"], + }, +}) diff --git a/fixtures/react-rsc/README.md b/fixtures/react-rsc/README.md new file mode 100644 index 00000000..cd111710 --- /dev/null +++ b/fixtures/react-rsc/README.md @@ -0,0 +1,40 @@ +# Vite + RSC + +This example shows how to set up a React application with [Server Component](https://react.dev/reference/rsc/server-components) features on Vite using [`@vitejs/plugin-rsc`](https://github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-rsc). + +[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/vitejs/vite-plugin-react/tree/main/packages/plugin-rsc/examples/starter) + +```sh +# run dev server +npm run dev + +# build for production and preview +npm run build +npm run preview +``` + +## API usage + +See [`@vitejs/plugin-rsc`](https://github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-rsc) for the documentation. + +- [`vite.config.ts`](./vite.config.ts) + - `@vitejs/plugin-rsc/plugin` +- [`./src/framework/entry.rsc.tsx`](./src/framework/entry.rsc.tsx) + - `@vitejs/plugin-rsc/rsc` + - `import.meta.viteRsc.loadModule` +- [`./src/framework/entry.ssr.tsx`](./src/framework/entry.ssr.tsx) + - `@vitejs/plugin-rsc/ssr` + - `import.meta.viteRsc.loadBootstrapScriptContent` + - `rsc-html-stream/server` +- [`./src/framework/entry.browser.tsx`](./src/framework/entry.browser.tsx) + - `@vitejs/plugin-rsc/browser` + - `rsc-html-stream/client` + +## Notes + +- [`./src/framework/entry.{browser,rsc,ssr}.tsx`](./src/framework) (with inline comments) provides an overview of how low level RSC (React flight) API can be used to build RSC framework. +- You can use [`vite-plugin-inspect`](https://github.com/antfu-collective/vite-plugin-inspect) to understand how `"use client"` and `"use server"` directives are transformed internally. + +## Deployment + +See [vite-plugin-rsc-deploy-example](https://github.com/hi-ogawa/vite-plugin-rsc-deploy-example) diff --git a/fixtures/react-rsc/package.json b/fixtures/react-rsc/package.json new file mode 100644 index 00000000..8a3c4259 --- /dev/null +++ b/fixtures/react-rsc/package.json @@ -0,0 +1,27 @@ +{ + "name": "@fixtures/react-rsc", + "version": "0.0.0", + "private": true, + "license": "MIT", + "type": "module", + "scripts": { + "dev": "vite dev --port 3100", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.2.7", + "react-dom": "^19.2.7" + }, + "devDependencies": { + "@cloudflare/workers-types": "catalog:workers", + "@distilled.cloud/cloudflare-runtime": "workspace:*", + "@distilled.cloud/cloudflare-vite-plugin": "workspace:*", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "latest", + "@vitejs/plugin-rsc": "latest", + "rsc-html-stream": "^0.0.7", + "vite": "catalog:" + } +} diff --git a/fixtures/react-rsc/public/vite.svg b/fixtures/react-rsc/public/vite.svg new file mode 100644 index 00000000..e7b8dfb1 --- /dev/null +++ b/fixtures/react-rsc/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/fixtures/react-rsc/src/action.tsx b/fixtures/react-rsc/src/action.tsx new file mode 100644 index 00000000..4fc55d65 --- /dev/null +++ b/fixtures/react-rsc/src/action.tsx @@ -0,0 +1,11 @@ +'use server' + +let serverCounter = 0 + +export async function getServerCounter() { + return serverCounter +} + +export async function updateServerCounter(change: number) { + serverCounter += change +} diff --git a/fixtures/react-rsc/src/assets/react.svg b/fixtures/react-rsc/src/assets/react.svg new file mode 100644 index 00000000..6c87de9b --- /dev/null +++ b/fixtures/react-rsc/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/fixtures/react-rsc/src/client.tsx b/fixtures/react-rsc/src/client.tsx new file mode 100644 index 00000000..29bb5d36 --- /dev/null +++ b/fixtures/react-rsc/src/client.tsx @@ -0,0 +1,13 @@ +'use client' + +import React from 'react' + +export function ClientCounter() { + const [count, setCount] = React.useState(0) + + return ( + + ) +} diff --git a/fixtures/react-rsc/src/framework/entry.browser.tsx b/fixtures/react-rsc/src/framework/entry.browser.tsx new file mode 100644 index 00000000..5b48ebdf --- /dev/null +++ b/fixtures/react-rsc/src/framework/entry.browser.tsx @@ -0,0 +1,138 @@ +import { + createFromReadableStream, + createFromFetch, + setServerCallback, + createTemporaryReferenceSet, + encodeReply, +} from '@vitejs/plugin-rsc/browser' +import React from 'react' +import { createRoot, hydrateRoot } from 'react-dom/client' +import { rscStream } from 'rsc-html-stream/client' +import type { RscPayload } from './entry.rsc' +import { GlobalErrorBoundary } from './error-boundary' +import { createRscRenderRequest } from './request' + +async function main() { + // stash `setPayload` function to trigger re-rendering + // from outside of `BrowserRoot` component (e.g. server function call, navigation, hmr) + let setPayload: (v: RscPayload) => void + + // deserialize RSC stream back to React VDOM for CSR + const initialPayload = await createFromReadableStream( + // initial RSC stream is injected in SSR stream as + rscStream, + ) + + // browser root component to (re-)render RSC payload as state + function BrowserRoot() { + const [payload, setPayload_] = React.useState(initialPayload) + + React.useEffect(() => { + setPayload = (v) => React.startTransition(() => setPayload_(v)) + }, [setPayload_]) + + // re-fetch/render on client side navigation + React.useEffect(() => { + return listenNavigation(() => fetchRscPayload()) + }, []) + + return payload.root + } + + // re-fetch RSC and trigger re-rendering + async function fetchRscPayload() { + const renderRequest = createRscRenderRequest(window.location.href) + const payload = await createFromFetch(fetch(renderRequest)) + setPayload(payload) + } + + // register a handler which will be internally called by React + // on server function request after hydration. + setServerCallback(async (id, args) => { + const temporaryReferences = createTemporaryReferenceSet() + const renderRequest = createRscRenderRequest(window.location.href, { + id, + body: await encodeReply(args, { temporaryReferences }), + }) + const payload = await createFromFetch(fetch(renderRequest), { + temporaryReferences, + }) + setPayload(payload) + const { ok, data } = payload.returnValue! + if (!ok) throw data + return data + }) + + // hydration + const browserRoot = ( + + + + + + ) + if ('__NO_HYDRATE' in globalThis) { + createRoot(document).render(browserRoot) + } else { + hydrateRoot(document, browserRoot, { + formState: initialPayload.formState, + }) + } + + // implement server HMR by triggering re-fetch/render of RSC upon server code change + if (import.meta.hot) { + import.meta.hot.on('rsc:update', () => { + fetchRscPayload() + }) + } +} + +// a little helper to setup events interception for client side navigation +function listenNavigation(onNavigation: () => void) { + window.addEventListener('popstate', onNavigation) + + const oldPushState = window.history.pushState + window.history.pushState = function (...args) { + const res = oldPushState.apply(this, args) + onNavigation() + return res + } + + const oldReplaceState = window.history.replaceState + window.history.replaceState = function (...args) { + const res = oldReplaceState.apply(this, args) + onNavigation() + return res + } + + function onClick(e: MouseEvent) { + let link = (e.target as Element).closest('a') + if ( + link && + link instanceof HTMLAnchorElement && + link.href && + (!link.target || link.target === '_self') && + link.origin === location.origin && + !link.hasAttribute('download') && + e.button === 0 && // left clicks only + !e.metaKey && // open in new tab (mac) + !e.ctrlKey && // open in new tab (windows) + !e.altKey && // download + !e.shiftKey && + !e.defaultPrevented + ) { + e.preventDefault() + history.pushState(null, '', link.href) + } + } + document.addEventListener('click', onClick) + + return () => { + document.removeEventListener('click', onClick) + window.removeEventListener('popstate', onNavigation) + window.history.pushState = oldPushState + window.history.replaceState = oldReplaceState + } +} + +main() diff --git a/fixtures/react-rsc/src/framework/entry.rsc.tsx b/fixtures/react-rsc/src/framework/entry.rsc.tsx new file mode 100644 index 00000000..c9cf5c4b --- /dev/null +++ b/fixtures/react-rsc/src/framework/entry.rsc.tsx @@ -0,0 +1,122 @@ +import { + renderToReadableStream, + createTemporaryReferenceSet, + decodeReply, + loadServerAction, + decodeAction, + decodeFormState, +} from '@vitejs/plugin-rsc/rsc' +import type { ReactFormState } from 'react-dom/client' +import { Root } from '../root.tsx' +import { parseRenderRequest } from './request.tsx' + +// The schema of payload which is serialized into RSC stream on rsc environment +// and deserialized on ssr/client environments. +export type RscPayload = { + // this demo renders/serializes/deserizlies entire root html element + // but this mechanism can be changed to render/fetch different parts of components + // based on your own route conventions. + root: React.ReactNode + // server action return value of non-progressive enhancement case + returnValue?: { ok: boolean; data: unknown } + // server action form state (e.g. useActionState) of progressive enhancement case + formState?: ReactFormState +} + +// the plugin by default assumes `rsc` entry having default export of request handler. +// however, how server entries are executed can be customized by registering own server handler. +export default { fetch: handler } + +async function handler(request: Request): Promise { + // differentiate RSC, SSR, action, etc. + const renderRequest = parseRenderRequest(request) + request = renderRequest.request + + // handle server function request + let returnValue: RscPayload['returnValue'] | undefined + let formState: ReactFormState | undefined + let temporaryReferences: unknown | undefined + let actionStatus: number | undefined + if (renderRequest.isAction === true) { + if (renderRequest.actionId) { + // action is called via `ReactClient.setServerCallback`. + const contentType = request.headers.get('content-type') + const body = contentType?.startsWith('multipart/form-data') + ? await request.formData() + : await request.text() + temporaryReferences = createTemporaryReferenceSet() + const args = await decodeReply(body, { temporaryReferences }) + const action = await loadServerAction(renderRequest.actionId) + try { + const data = await action.apply(null, args) + returnValue = { ok: true, data } + } catch (e) { + returnValue = { ok: false, data: e } + actionStatus = 500 + } + } else { + // otherwise server function is called via `

` + // before hydration (e.g. when javascript is disabled). + // aka progressive enhancement. + const formData = await request.formData() + const decodedAction = await decodeAction(formData) + try { + const result = await decodedAction() + formState = await decodeFormState(result, formData) + } catch (e) { + // there's no single general obvious way to surface this error, + // so explicitly return classic 500 response. + return new Response('Internal Server Error: server action failed', { + status: 500, + }) + } + } + } + + // serialization from React VDOM tree to RSC stream. + // we render RSC stream after handling server function request + // so that new render reflects updated state from server function call + // to achieve single round trip to mutate and fetch from server. + const rscPayload: RscPayload = { + root: , + formState, + returnValue, + } + const rscOptions = { temporaryReferences } + const rscStream = renderToReadableStream(rscPayload, rscOptions) + + // Respond RSC stream without HTML rendering as decided by `RenderRequest` + if (renderRequest.isRsc) { + return new Response(rscStream, { + status: actionStatus, + headers: { + 'content-type': 'text/x-component;charset=utf-8', + }, + }) + } + + // Delegate to SSR environment for html rendering. + // The plugin provides `loadModule` helper to allow loading SSR environment entry module + // in RSC environment. however this can be customized by implementing own runtime communication + // e.g. `@cloudflare/vite-plugin`'s service binding. + const ssrEntryModule = await import.meta.viteRsc.loadModule< + typeof import('./entry.ssr.tsx') + >('ssr', 'index') + const ssrResult = await ssrEntryModule.renderHTML(rscStream, { + formState, + // allow quick simulation of javascript disabled browser + debugNojs: renderRequest.url.searchParams.has('__nojs'), + }) + + // respond html + return new Response(ssrResult.stream, { + status: ssrResult.status, + headers: { + 'Content-type': 'text/html', + }, + }) +} + +if (import.meta.hot) { + import.meta.hot.accept() +} diff --git a/fixtures/react-rsc/src/framework/entry.ssr.tsx b/fixtures/react-rsc/src/framework/entry.ssr.tsx new file mode 100644 index 00000000..7fc5a956 --- /dev/null +++ b/fixtures/react-rsc/src/framework/entry.ssr.tsx @@ -0,0 +1,74 @@ +import { createFromReadableStream } from '@vitejs/plugin-rsc/ssr' +import React from 'react' +import type { ReactFormState } from 'react-dom/client' +import { renderToReadableStream } from 'react-dom/server.edge' +import { injectRSCPayload } from 'rsc-html-stream/server' +import type { RscPayload } from './entry.rsc' + +export async function renderHTML( + rscStream: ReadableStream, + options: { + formState?: ReactFormState + nonce?: string + debugNojs?: boolean + }, +): Promise<{ stream: ReadableStream; status?: number }> { + // duplicate one RSC stream into two. + // - one for SSR (ReactClient.createFromReadableStream below) + // - another for browser hydration payload by injecting . + const [rscStream1, rscStream2] = rscStream.tee() + + // deserialize RSC stream back to React VDOM + let payload: Promise | undefined + function SsrRoot() { + // deserialization needs to be kicked off inside ReactDOMServer context + // for ReactDomServer preinit/preloading to work + payload ??= createFromReadableStream(rscStream1) + return React.use(payload).root + } + + // render html (traditional SSR) + const bootstrapScriptContent = + await import.meta.viteRsc.loadBootstrapScriptContent('index') + let htmlStream: ReadableStream + let status: number | undefined + try { + htmlStream = await renderToReadableStream(, { + bootstrapScriptContent: options?.debugNojs + ? undefined + : bootstrapScriptContent, + nonce: options?.nonce, + formState: options?.formState, + }) + } catch (e) { + // fallback to render an empty shell and run pure CSR on browser, + // which can replay server component error and trigger error boundary. + status = 500 + htmlStream = await renderToReadableStream( + + + + + , + { + bootstrapScriptContent: + `self.__NO_HYDRATE=1;` + + (options?.debugNojs ? '' : bootstrapScriptContent), + nonce: options?.nonce, + }, + ) + } + + let responseStream: ReadableStream = htmlStream + if (!options?.debugNojs) { + // initial RSC stream is injected in HTML stream as + // using utility made by devongovett https://github.com/devongovett/rsc-html-stream + responseStream = responseStream.pipeThrough( + injectRSCPayload(rscStream2, { + nonce: options?.nonce, + }), + ) + } + + return { stream: responseStream, status } +} diff --git a/fixtures/react-rsc/src/framework/error-boundary.tsx b/fixtures/react-rsc/src/framework/error-boundary.tsx new file mode 100644 index 00000000..39d91651 --- /dev/null +++ b/fixtures/react-rsc/src/framework/error-boundary.tsx @@ -0,0 +1,81 @@ +'use client' + +import React from 'react' + +// Minimal ErrorBoundary example to handle errors globally on browser +export function GlobalErrorBoundary(props: { children?: React.ReactNode }) { + return ( + + {props.children} + + ) +} + +// https://github.com/vercel/next.js/blob/33f8428f7066bf8b2ec61f025427ceb2a54c4bdf/packages/next/src/client/components/error-boundary.tsx +// https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary +class ErrorBoundary extends React.Component<{ + children?: React.ReactNode + errorComponent: React.FC<{ + error: Error + reset: () => void + }> +}> { + state: { error?: Error } = {} + + static getDerivedStateFromError(error: Error) { + return { error } + } + + reset = () => { + this.setState({ error: null }) + } + + render() { + const error = this.state.error + if (error) { + return + } + return this.props.children + } +} + +// https://github.com/vercel/next.js/blob/677c9b372faef680d17e9ba224743f44e1107661/packages/next/src/build/webpack/loaders/next-app-loader.ts#L73 +// https://github.com/vercel/next.js/blob/677c9b372faef680d17e9ba224743f44e1107661/packages/next/src/client/components/error-boundary.tsx#L145 +function DefaultGlobalErrorPage(props: { error: Error; reset: () => void }) { + return ( + + + Unexpected Error + + +

Caught an unexpected error

+
+          Error:{' '}
+          {import.meta.env.DEV && 'message' in props.error
+            ? props.error.message
+            : '(Unknown)'}
+        
+ + + + ) +} diff --git a/fixtures/react-rsc/src/framework/request.tsx b/fixtures/react-rsc/src/framework/request.tsx new file mode 100644 index 00000000..4c7c666e --- /dev/null +++ b/fixtures/react-rsc/src/framework/request.tsx @@ -0,0 +1,58 @@ +// Framework conventions (arbitrary choices for this demo): +// - Use `_.rsc` URL suffix to differentiate RSC requests from SSR requests +// - Use `x-rsc-action` header to pass server action ID +const URL_POSTFIX = '_.rsc' +const HEADER_ACTION_ID = 'x-rsc-action' + +// Parsed request information used to route between RSC/SSR rendering and action handling. +// Created by parseRenderRequest() from incoming HTTP requests. +type RenderRequest = { + isRsc: boolean // true if request should return RSC payload (via _.rsc suffix) + isAction: boolean // true if this is a server action call (POST request) + actionId?: string // server action ID from x-rsc-action header + request: Request // normalized Request with _.rsc suffix removed from URL + url: URL // normalized URL with _.rsc suffix removed +} + +export function createRscRenderRequest( + urlString: string, + action?: { id: string; body: BodyInit }, +): Request { + const url = new URL(urlString) + url.pathname += URL_POSTFIX + const headers = new Headers() + if (action) { + headers.set(HEADER_ACTION_ID, action.id) + } + return new Request(url.toString(), { + method: action ? 'POST' : 'GET', + headers, + body: action?.body, + }) +} + +export function parseRenderRequest(request: Request): RenderRequest { + const url = new URL(request.url) + const isAction = request.method === 'POST' + if (url.pathname.endsWith(URL_POSTFIX)) { + url.pathname = url.pathname.slice(0, -URL_POSTFIX.length) + const actionId = request.headers.get(HEADER_ACTION_ID) || undefined + if (request.method === 'POST' && !actionId) { + throw new Error('Missing action id header for RSC action request') + } + return { + isRsc: true, + isAction, + actionId, + request: new Request(url, request), + url, + } + } else { + return { + isRsc: false, + isAction, + request, + url, + } + } +} diff --git a/fixtures/react-rsc/src/index.css b/fixtures/react-rsc/src/index.css new file mode 100644 index 00000000..f4d2128c --- /dev/null +++ b/fixtures/react-rsc/src/index.css @@ -0,0 +1,112 @@ +:root { + font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +a { + font-weight: 500; + color: #646cff; + text-decoration: inherit; +} +a:hover { + color: #535bf2; +} + +body { + margin: 0; + display: flex; + place-items: center; + min-width: 320px; + min-height: 100vh; +} + +h1 { + font-size: 3.2em; + line-height: 1.1; +} + +button { + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #1a1a1a; + cursor: pointer; + transition: border-color 0.25s; +} +button:hover { + border-color: #646cff; +} +button:focus, +button:focus-visible { + outline: 4px auto -webkit-focus-ring-color; +} + +@media (prefers-color-scheme: light) { + :root { + color: #213547; + background-color: #ffffff; + } + a:hover { + color: #747bff; + } + button { + background-color: #f9f9f9; + } +} + +#root { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; +} +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} +.logo.react:hover { + filter: drop-shadow(0 0 2em #61dafbaa); +} + +@keyframes logo-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: no-preference) { + a:nth-of-type(2) .logo { + animation: logo-spin infinite 20s linear; + } +} + +.card { + padding: 1rem; +} + +.read-the-docs { + color: #888; + text-align: left; +} diff --git a/fixtures/react-rsc/src/root.tsx b/fixtures/react-rsc/src/root.tsx new file mode 100644 index 00000000..bc4ab1b4 --- /dev/null +++ b/fixtures/react-rsc/src/root.tsx @@ -0,0 +1,71 @@ +import './index.css' // css import is automatically injected in exported server components +import viteLogo from '/vite.svg' +import { getServerCounter, updateServerCounter } from './action.tsx' +import reactLogo from './assets/react.svg' +import { ClientCounter } from './client.tsx' + +export function Root(props: { url: URL }) { + return ( + + + + + + Vite + RSC + + + + + + ) +} + +function App(props: { url: URL }) { + return ( +
+ +

Vite + RSC

+
+ +
+
+ + + +
+
Request URL: {props.url?.href}
+
    +
  • + Edit src/client.tsx to test client HMR. +
  • +
  • + Edit src/root.tsx to test server HMR. +
  • +
  • + Visit{' '} + + _.rsc + {' '} + to view RSC stream payload. +
  • +
  • + Visit{' '} + + ?__nojs + {' '} + to test server action without js enabled. +
  • +
+
+ ) +} diff --git a/fixtures/react-rsc/tsconfig.json b/fixtures/react-rsc/tsconfig.json new file mode 100644 index 00000000..b212cd7a --- /dev/null +++ b/fixtures/react-rsc/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "erasableSyntaxOnly": true, + "allowImportingTsExtensions": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "skipLibCheck": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "moduleResolution": "Bundler", + "module": "ESNext", + "target": "ESNext", + "lib": ["ESNext", "DOM"], + "types": ["vite/client", "@vitejs/plugin-rsc/types"], + "jsx": "react-jsx" + } +} diff --git a/fixtures/react-rsc/vite.config.ts b/fixtures/react-rsc/vite.config.ts new file mode 100644 index 00000000..049e641e --- /dev/null +++ b/fixtures/react-rsc/vite.config.ts @@ -0,0 +1,39 @@ +import cloudflare from "@distilled.cloud/cloudflare-vite-plugin" +import react from "@vitejs/plugin-react" +import rsc from "@vitejs/plugin-rsc" +import { defineConfig } from "vite" + +// Minimal React Server Components app (the @vitejs/plugin-rsc `starter`) +// wired to the distilled Cloudflare vite plugin, to reproduce and then fix +// RSC dev support (cloudflare-tools#43). +// +// The worker IS the `rsc` environment: plugin-rsc resolves it with the +// `react-server` condition and its `default export` ({ fetch }) is the +// request handler. `serverHandler: false` tells plugin-rsc not to mount its +// own Node dev middleware, so requests route into workerd instead. +export default defineConfig({ + plugins: [ + rsc({ serverHandler: false }), + react(), + cloudflare({ + main: "./src/framework/entry.rsc.tsx", + compatibilityDate: "2026-03-10", + compatibilityFlags: ["nodejs_compat"], + // The Worker is the `rsc` environment; it loads `ssr` modules at runtime. + viteEnvironment: { name: "rsc", childEnvironments: ["ssr"] }, + worker: { name: "fixtures-react-rsc" }, + }), + ], + + environments: { + rsc: { + build: { rollupOptions: { input: { index: "./src/framework/entry.rsc.tsx" } } }, + }, + ssr: { + build: { rollupOptions: { input: { index: "./src/framework/entry.ssr.tsx" } } }, + }, + client: { + build: { rollupOptions: { input: { index: "./src/framework/entry.browser.tsx" } } }, + }, + }, +}) From 22c534d41f56d0f41d87aa87e5084051c5133381 Mon Sep 17 00:00:00 2001 From: Alex Date: Sun, 14 Jun 2026 17:50:30 +0200 Subject: [PATCH 03/11] fix(vite-plugin): address RSC dev review findings - restore the exact non-RSC optimizeDeps.entries behavior: the per-env input fallback now applies only to multi-environment (RSC) topologies; single-worker apps keep entries solely from an explicit `main` (was a behavioral change vs the original single-"ssr" code) - give the actionable maintainer-style hint when a runner is missing for an environment (point at viteEnvironment.childEnvironments) Verified: RR-RSC fixture still renders; non-RSC (static-website) dev unchanged. --- packages/cloudflare-rolldown-plugin/src/plugins/options.ts | 7 +++++++ .../src/module-runner/module-runner.worker.ts | 7 ++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/cloudflare-rolldown-plugin/src/plugins/options.ts b/packages/cloudflare-rolldown-plugin/src/plugins/options.ts index 5a88bf61..4d82e689 100644 --- a/packages/cloudflare-rolldown-plugin/src/plugins/options.ts +++ b/packages/cloudflare-rolldown-plugin/src/plugins/options.ts @@ -80,6 +80,13 @@ export const optionsPlugin = createPlugin<"options", OptionsApi>("options", (plu // are discovered lazily and re-optimized mid-session — which re-hashes // and duplicates singletons like React (a null hooks dispatcher). const resolveEnvironmentEntries = (name: string): Array | undefined => { + // Single-worker (non-RSC) default: preserve the original behavior + // exactly — the optimizer's scan root comes only from an explicit + // `main`, otherwise it's left to Vite's auto-discovery. The per-env + // input fallback below applies only to multi-environment topologies. + if (childEnvs.length === 0) { + return pluginOptions.main ? [vite.normalizePath(pluginOptions.main)] : undefined; + } const rawInput = name === entryEnv ? (pluginOptions.main ?? diff --git a/packages/cloudflare-vite-plugin/src/module-runner/module-runner.worker.ts b/packages/cloudflare-vite-plugin/src/module-runner/module-runner.worker.ts index a7a0771d..250f71ce 100644 --- a/packages/cloudflare-vite-plugin/src/module-runner/module-runner.worker.ts +++ b/packages/cloudflare-vite-plugin/src/module-runner/module-runner.worker.ts @@ -57,7 +57,12 @@ export class ModuleRunnerDO extends DurableObject { globalThis.__VITE_ENVIRONMENT_RUNNER_IMPORT__ = async (environmentName: string, id: string) => { const moduleRunner = this.moduleRunners.get(environmentName); if (!moduleRunner) { - throw new Error(`Module runner not initialized for environment: "${environmentName}"`); + throw new Error( + `Module runner not initialized for environment: "${environmentName}". ` + + `If this is a child environment loaded at runtime (e.g. an RSC app's ` + + `"ssr" environment), ensure it's listed in the plugin's ` + + `viteEnvironment.childEnvironments.`, + ); } return callbacks.run(this.env, () => moduleRunner.import(id)); }; From fbb27bad1d99621256b6b1408411f5a515ea4830 Mon Sep 17 00:00:00 2001 From: Alex Date: Sun, 14 Jun 2026 18:04:33 +0200 Subject: [PATCH 04/11] test(fixtures): exercise worker-loaded ssr render (James Opstad's RSC pattern) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a /worker-render endpoint that loads a custom "worker-ssr" module from the ssr environment via loadModule("ssr", "worker-ssr") instead of importing react-dom/server in the rsc worker entry — the blessed pattern from agcty/vite-rsc-worker-env-repro#1. Proves the distilled plugin handles: multiple ssr inputs (framework index + worker-ssr), the worker loading a non-index ssr module cross-environment, and react-dom/server resolving in the ssr child (it would fail under the rsc react-server condition). Verified: GET /worker-render → 200 with rendered HTML; / still renders. --- .../react-router-vite/entry.worker.tsx | 17 ++++++++++++++++- .../react-router-vite/worker-ssr.tsx | 13 +++++++++++++ fixtures/react-router-rsc/vite.config.ts | 11 +++++++++++ 3 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 fixtures/react-router-rsc/react-router-vite/worker-ssr.tsx diff --git a/fixtures/react-router-rsc/react-router-vite/entry.worker.tsx b/fixtures/react-router-rsc/react-router-vite/entry.worker.tsx index bd5075cc..e3c5b883 100644 --- a/fixtures/react-router-rsc/react-router-vite/entry.worker.tsx +++ b/fixtures/react-router-rsc/react-router-vite/entry.worker.tsx @@ -3,5 +3,20 @@ import handler from "./entry.rsc.single" // The distilled Cloudflare worker wrapper expects a `{ fetch }` default export; // the RSC single-worker handler is a bare (request) => Response function. export default { - fetch: (request: Request) => handler(request), + async fetch(request: Request): Promise { + const url = new URL(request.url) + + // Worker code that needs a non-`react-server` module (here `react-dom/server`) + // must not import it directly in this `rsc` entry — it loads it from the + // `ssr` environment via `loadModule`. Exercises a custom (non-`index`) ssr + // input + cross-environment load through the distilled plugin. + if (url.pathname === "/worker-render") { + const { renderWorkerHtml } = await import.meta.viteRsc.loadModule< + typeof import("./worker-ssr") + >("ssr", "worker-ssr") + return Response.json({ ok: true, html: renderWorkerHtml() }) + } + + return handler(request) + }, } diff --git a/fixtures/react-router-rsc/react-router-vite/worker-ssr.tsx b/fixtures/react-router-rsc/react-router-vite/worker-ssr.tsx new file mode 100644 index 00000000..2ce5ad26 --- /dev/null +++ b/fixtures/react-router-rsc/react-router-vite/worker-ssr.tsx @@ -0,0 +1,13 @@ +import { createElement } from "react" +import { renderToStaticMarkup } from "react-dom/server.edge" + +// Lives in the `ssr` environment (no `react-server` condition), so it can use +// `react-dom/server` — which would fail if imported directly in the worker's +// `rsc` entry. The worker reaches it via `loadModule("ssr", "worker-ssr")`. +// This is the pattern James Opstad landed on in +// github.com/agcty/vite-rsc-worker-env-repro PR #1. +export function renderWorkerHtml(): string { + return renderToStaticMarkup( + createElement("section", null, "Worker render via the ssr environment."), + ) +} diff --git a/fixtures/react-router-rsc/vite.config.ts b/fixtures/react-router-rsc/vite.config.ts index d22d28ae..c8c06db2 100644 --- a/fixtures/react-router-rsc/vite.config.ts +++ b/fixtures/react-router-rsc/vite.config.ts @@ -42,6 +42,17 @@ export default defineConfig({ worker: { name: "fixtures-react-router-rsc" }, }), ], + environments: { + // A second `ssr` input the worker loads on demand via + // loadModule("ssr", "worker-ssr") — alongside the framework's `index`. + ssr: { + build: { + rollupOptions: { + input: { "worker-ssr": "./react-router-vite/worker-ssr.tsx" }, + }, + }, + }, + }, optimizeDeps: { include: ["react-router", "react-router/internal/react-server-client"], }, From f1dafa63780f1c7bb4a354a19309e01adebcf727 Mon Sep 17 00:00:00 2001 From: Alex Date: Sun, 14 Jun 2026 18:16:48 +0200 Subject: [PATCH 05/11] fix(rolldown-plugin): validate worker environment names Reject viteEnvironment configs that would silently corrupt the generated per-env config via computed keys: name "client" (the reserved browser env), children including "client", children colliding with the entry, and duplicate children. Mirrors @cloudflare/vite-plugin. (review finding) --- .../cloudflare-rolldown-plugin/src/options.ts | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/packages/cloudflare-rolldown-plugin/src/options.ts b/packages/cloudflare-rolldown-plugin/src/options.ts index 8da89bc5..53f71450 100644 --- a/packages/cloudflare-rolldown-plugin/src/options.ts +++ b/packages/cloudflare-rolldown-plugin/src/options.ts @@ -60,5 +60,34 @@ export function workerEnvironments(options: CloudflarePluginOptions): { } { const entry = options.viteEnvironment?.name ?? "ssr"; const children = options.viteEnvironment?.childEnvironments ?? []; + + // `client` is Vite's reserved browser environment — it must never be given + // Worker config. Children must be distinct and must not collide with the + // entry, since each environment name becomes a computed key in the generated + // config (a collision would silently overwrite one env with another's + // settings). Mirrors the validation in `@cloudflare/vite-plugin`. + if (entry === "client") { + throw new Error( + `[cloudflare] viteEnvironment.name cannot be "client" (the reserved browser environment).`, + ); + } + for (const child of children) { + if (child === "client") { + throw new Error( + `[cloudflare] viteEnvironment.childEnvironments cannot include "client" (the reserved browser environment).`, + ); + } + if (child === entry) { + throw new Error( + `[cloudflare] viteEnvironment.childEnvironments cannot include the entry environment "${entry}".`, + ); + } + } + if (new Set(children).size !== children.length) { + throw new Error( + `[cloudflare] viteEnvironment.childEnvironments contains duplicate names: ${JSON.stringify(children)}`, + ); + } + return { entry, children, all: [entry, ...children] }; } From c954a5a7b8cb6615a912b36e92f5e43c44667417 Mon Sep 17 00:00:00 2001 From: Alex Date: Sun, 14 Jun 2026 18:16:48 +0200 Subject: [PATCH 06/11] test(fixtures): dev-mode smoke tests for the RSC fixtures Boot `vite dev` and assert the RSC routes render: react-rsc (server + client component + server action) and react-router-rsc (home, /about, and /worker-render exercising loadModule("ssr","worker-ssr")). Makes RSC dev support an automated, runnable check (`bun test`). Build-mode is a separate track and intentionally not covered. --- fixtures/react-router-rsc/package.json | 3 +- fixtures/react-router-rsc/test/dev.test.ts | 57 ++++++++++++++++++++++ fixtures/react-rsc/package.json | 3 +- fixtures/react-rsc/test/dev.test.ts | 46 +++++++++++++++++ 4 files changed, 107 insertions(+), 2 deletions(-) create mode 100644 fixtures/react-router-rsc/test/dev.test.ts create mode 100644 fixtures/react-rsc/test/dev.test.ts diff --git a/fixtures/react-router-rsc/package.json b/fixtures/react-router-rsc/package.json index 653e9aa1..c8f30999 100644 --- a/fixtures/react-router-rsc/package.json +++ b/fixtures/react-router-rsc/package.json @@ -6,7 +6,8 @@ "scripts": { "dev": "vite dev --port 3200", "build": "vite build", - "preview": "vite preview" + "preview": "vite preview", + "test": "bun test" }, "dependencies": { "react": "^19.2.7", diff --git a/fixtures/react-router-rsc/test/dev.test.ts b/fixtures/react-router-rsc/test/dev.test.ts new file mode 100644 index 00000000..3c64995b --- /dev/null +++ b/fixtures/react-router-rsc/test/dev.test.ts @@ -0,0 +1,57 @@ +import { afterAll, beforeAll, expect, test } from "bun:test" +import { spawn, type ChildProcess } from "node:child_process" +import { fileURLToPath } from "node:url" +import path from "node:path" + +// Dev-mode smoke test: boots `vite dev` (the distilled Cloudflare plugin in +// RSC mode) and asserts the RSC routes, routing, and the worker-loaded ssr +// render all respond. Build-mode tests are intentionally absent — RSC build +// is a separate track. +const fixtureDir = path.dirname(path.dirname(fileURLToPath(import.meta.url))) + +let proc: ChildProcess +let baseUrl: string + +beforeAll(async () => { + proc = spawn("bun", ["vite", "dev", "--port", "3251"], { + cwd: fixtureDir, + stdio: ["ignore", "pipe", "pipe"], + }) + baseUrl = await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("dev server did not start in time")), 90_000) + const onData = (chunk: Buffer) => { + const match = String(chunk).match(/Local:\s+(http:\/\/\S+?)\/?\s/) + if (match) { + clearTimeout(timer) + resolve(match[1].replace(/\/$/, "")) + } + } + proc.stdout?.on("data", onData) + proc.stderr?.on("data", onData) + proc.on("exit", (code) => reject(new Error(`dev server exited early (code ${code})`))) + }) +}) + +afterAll(() => { + proc?.kill("SIGTERM") +}) + +test("renders the RSC home route", async () => { + const res = await fetch(`${baseUrl}/`) + expect(res.status).toBe(200) + expect(await res.text()).toContain("React Router Vite") +}) + +test("routes to /about", async () => { + const res = await fetch(`${baseUrl}/about`) + expect(res.status).toBe(200) + expect(await res.text()).toContain("About") +}) + +test("worker loads a custom ssr module via loadModule (react-dom/server in ssr env)", async () => { + const res = await fetch(`${baseUrl}/worker-render`) + expect(res.status).toBe(200) + const body = (await res.json()) as { ok: boolean; html: string } + expect(body.ok).toBe(true) + expect(body.html).toContain("Worker render via the ssr environment.") +}) diff --git a/fixtures/react-rsc/package.json b/fixtures/react-rsc/package.json index 8a3c4259..e98daaf8 100644 --- a/fixtures/react-rsc/package.json +++ b/fixtures/react-rsc/package.json @@ -7,7 +7,8 @@ "scripts": { "dev": "vite dev --port 3100", "build": "vite build", - "preview": "vite preview" + "preview": "vite preview", + "test": "bun test" }, "dependencies": { "react": "^19.2.7", diff --git a/fixtures/react-rsc/test/dev.test.ts b/fixtures/react-rsc/test/dev.test.ts new file mode 100644 index 00000000..7dbcc264 --- /dev/null +++ b/fixtures/react-rsc/test/dev.test.ts @@ -0,0 +1,46 @@ +import { afterAll, beforeAll, expect, test } from "bun:test" +import { spawn, type ChildProcess } from "node:child_process" +import { fileURLToPath } from "node:url" +import path from "node:path" + +// Dev-mode smoke test: boots `vite dev` (the distilled Cloudflare plugin in +// RSC mode) and asserts the minimal RSC app renders — server components, +// the client component, and the server action are all present in the SSR'd +// HTML. Build-mode is a separate track and intentionally not tested here. +const fixtureDir = path.dirname(path.dirname(fileURLToPath(import.meta.url))) + +let proc: ChildProcess +let baseUrl: string + +beforeAll(async () => { + proc = spawn("bun", ["vite", "dev", "--port", "3151"], { + cwd: fixtureDir, + stdio: ["ignore", "pipe", "pipe"], + }) + baseUrl = await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("dev server did not start in time")), 90_000) + const onData = (chunk: Buffer) => { + const match = String(chunk).match(/Local:\s+(http:\/\/\S+?)\/?\s/) + if (match) { + clearTimeout(timer) + resolve(match[1].replace(/\/$/, "")) + } + } + proc.stdout?.on("data", onData) + proc.stderr?.on("data", onData) + proc.on("exit", (code) => reject(new Error(`dev server exited early (code ${code})`))) + }) +}) + +afterAll(() => { + proc?.kill("SIGTERM") +}) + +test("server-renders the RSC app (server component + client component + server action)", async () => { + const res = await fetch(`${baseUrl}/`) + expect(res.status).toBe(200) + const html = await res.text() + expect(html).toContain("Vite + RSC") // server component + expect(html).toContain("Client Counter") // client component + expect(html).toContain("Server Counter") // server action +}) From 032e418869de6ee483551d57eeb8fa67475d5ae0 Mon Sep 17 00:00:00 2001 From: Alex Date: Sun, 14 Jun 2026 18:27:43 +0200 Subject: [PATCH 07/11] test(fixtures): lock RSC dev plugin-order independence (review finding) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A reviewer flagged that the rsc env's resolve.conditions ordering depends on plugin order: with cloudflare() before rsc(), react-server lands at index 5 instead of 0. Verified via resolveConfig probe that the ordering does change — but export-condition resolution is set-membership (React's own exports key order decides), so react-server being present is what matters, not its array index. Adds vite.config.cf-first.ts (cloudflare before rsc) + a test booting it: the app renders 200 with a working RSC flight stream, proving order-independence. Not a correctness bug; locked so it can't silently regress. --- fixtures/react-rsc/test/plugin-order.test.ts | 48 ++++++++++++++++++++ fixtures/react-rsc/vite.config.cf-first.ts | 37 +++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 fixtures/react-rsc/test/plugin-order.test.ts create mode 100644 fixtures/react-rsc/vite.config.cf-first.ts diff --git a/fixtures/react-rsc/test/plugin-order.test.ts b/fixtures/react-rsc/test/plugin-order.test.ts new file mode 100644 index 00000000..f5be454f --- /dev/null +++ b/fixtures/react-rsc/test/plugin-order.test.ts @@ -0,0 +1,48 @@ +import { afterAll, beforeAll, expect, test } from "bun:test" +import { spawn, type ChildProcess } from "node:child_process" +import { fileURLToPath } from "node:url" +import path from "node:path" + +// Locks order-independence (review finding): with the Cloudflare plugin +// registered BEFORE rsc(), the rsc env's resolve.conditions lists `react-server` +// after the workerd conditions (not first). Export-condition resolution is +// set-membership, so the app must still render correctly — if `react-server` +// weren't effective in the rsc env, flight generation would 500. See +// vite.config.cf-first.ts. +const fixtureDir = path.dirname(path.dirname(fileURLToPath(import.meta.url))) + +let proc: ChildProcess +let baseUrl: string + +beforeAll(async () => { + proc = spawn( + "bun", + ["vite", "dev", "-c", "vite.config.cf-first.ts", "--port", "3152"], + { cwd: fixtureDir, stdio: ["ignore", "pipe", "pipe"] }, + ) + baseUrl = await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("dev server did not start in time")), 90_000) + const onData = (chunk: Buffer) => { + const match = String(chunk).match(/Local:\s+(http:\/\/\S+?)\/?\s/) + if (match) { + clearTimeout(timer) + resolve(match[1].replace(/\/$/, "")) + } + } + proc.stdout?.on("data", onData) + proc.stderr?.on("data", onData) + proc.on("exit", (code) => reject(new Error(`dev server exited early (code ${code})`))) + }) +}) + +afterAll(() => { + proc?.kill("SIGTERM") +}) + +test("RSC renders correctly even with cloudflare() before rsc() (react-server not first in conditions)", async () => { + const res = await fetch(`${baseUrl}/`) + expect(res.status).toBe(200) + const html = await res.text() + expect(html).toContain("Vite + RSC") + expect(html).toContain("Client Counter") +}) diff --git a/fixtures/react-rsc/vite.config.cf-first.ts b/fixtures/react-rsc/vite.config.cf-first.ts new file mode 100644 index 00000000..63fe8e9a --- /dev/null +++ b/fixtures/react-rsc/vite.config.cf-first.ts @@ -0,0 +1,37 @@ +import cloudflare from "@distilled.cloud/cloudflare-vite-plugin" +import react from "@vitejs/plugin-react" +import rsc from "@vitejs/plugin-rsc" +import { defineConfig } from "vite" + +// Same app as vite.config.ts but with the Cloudflare plugin registered BEFORE +// `rsc()`. This deliberately produces the non-conventional condition ordering +// (the rsc env's `resolve.conditions` lists workerd conditions before +// `react-server`). The plugin-order.test.ts boots this config to prove RSC dev +// resolves correctly regardless of plugin order — export-condition resolution +// is set-membership (React's own `exports` ordering decides), so `react-server` +// being present is what matters, not its index in the conditions array. +export default defineConfig({ + plugins: [ + cloudflare({ + main: "./src/framework/entry.rsc.tsx", + compatibilityDate: "2026-03-10", + compatibilityFlags: ["nodejs_compat"], + viteEnvironment: { name: "rsc", childEnvironments: ["ssr"] }, + worker: { name: "fixtures-react-rsc-cf-first" }, + }), + rsc({ serverHandler: false }), + react(), + ], + + environments: { + rsc: { + build: { rollupOptions: { input: { index: "./src/framework/entry.rsc.tsx" } } }, + }, + ssr: { + build: { rollupOptions: { input: { index: "./src/framework/entry.ssr.tsx" } } }, + }, + client: { + build: { rollupOptions: { input: { index: "./src/framework/entry.browser.tsx" } } }, + }, + }, +}) From 53fa6599ef03fd6e414e203550110701e6140b66 Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 15 Jun 2026 18:48:49 +0200 Subject: [PATCH 08/11] style(fixtures): satisfy oxfmt and oxlint on RSC dev fixtures --- fixtures/react-router-rsc/app/paper.css | 8 +- fixtures/react-router-rsc/app/root.tsx | 19 +-- fixtures/react-router-rsc/app/routes.ts | 22 ++-- .../react-router-rsc/app/routes/about.tsx | 8 +- .../react-router-rsc/app/routes/client.tsx | 16 +-- .../app/routes/home.actions.ts | 8 +- .../app/routes/home.client.tsx | 10 +- fixtures/react-router-rsc/app/routes/home.tsx | 28 ++--- .../app/routes/root.client.tsx | 16 +-- .../app/routes/test-action-state/client.tsx | 15 +-- .../app/routes/test-action-state/server.tsx | 10 +- fixtures/react-router-rsc/app/styles.css | 4 +- .../react-router-vite/entry.browser.tsx | 37 +++--- .../react-router-vite/entry.rsc.single.tsx | 9 +- .../react-router-vite/entry.rsc.tsx | 12 +- .../react-router-vite/entry.ssr.tsx | 28 ++--- .../react-router-vite/entry.worker.tsx | 18 +-- .../react-router-vite/worker-ssr.tsx | 6 +- fixtures/react-router-rsc/test/dev.test.ts | 68 +++++------ fixtures/react-router-rsc/vite.config.ts | 18 ++- fixtures/react-rsc/src/action.tsx | 8 +- fixtures/react-rsc/src/client.tsx | 12 +- .../react-rsc/src/framework/entry.browser.tsx | 112 +++++++++--------- .../react-rsc/src/framework/entry.rsc.tsx | 89 +++++++------- .../react-rsc/src/framework/entry.ssr.tsx | 54 ++++----- .../src/framework/error-boundary.tsx | 58 ++++----- fixtures/react-rsc/src/framework/request.tsx | 44 +++---- fixtures/react-rsc/src/root.tsx | 29 +++-- fixtures/react-rsc/test/dev.test.ts | 54 ++++----- fixtures/react-rsc/test/plugin-order.test.ts | 59 +++++---- fixtures/react-rsc/vite.config.cf-first.ts | 10 +- fixtures/react-rsc/vite.config.ts | 10 +- 32 files changed, 427 insertions(+), 472 deletions(-) diff --git a/fixtures/react-router-rsc/app/paper.css b/fixtures/react-router-rsc/app/paper.css index 761e5186..84d3e872 100644 --- a/fixtures/react-router-rsc/app/paper.css +++ b/fixtures/react-router-rsc/app/paper.css @@ -1,6 +1,6 @@ @theme { - --default-font-family: 'Patrick Hand SC', sans-serif; - --default-mono-font-family: 'Patrick Hand SC', sans-serif; + --default-font-family: "Patrick Hand SC", sans-serif; + --default-mono-font-family: "Patrick Hand SC", sans-serif; --color-foreground: black; --color-danger: rgb(167, 52, 45); @@ -139,11 +139,11 @@ @layer utilities { .prose { - :where(u):not(:where([class~='not-prose'], [class~='not-prose'] *)) { + :where(u):not(:where([class~="not-prose"], [class~="not-prose"] *)) { @apply paper-underline no-underline; } - :where(a):not(:where([class~='not-prose'], [class~='not-prose'] *)) { + :where(a):not(:where([class~="not-prose"], [class~="not-prose"] *)) { @apply paper-underline-hover no-underline text-secondary; } } diff --git a/fixtures/react-router-rsc/app/root.tsx b/fixtures/react-router-rsc/app/root.tsx index 75b019e9..678b3a48 100644 --- a/fixtures/react-router-rsc/app/root.tsx +++ b/fixtures/react-router-rsc/app/root.tsx @@ -1,10 +1,11 @@ -import './styles.css' -import { Link, Outlet } from 'react-router' -import { TestClientState, TestHydrated } from './routes/client' -import { DumpError, GlobalNavigationLoadingBar } from './routes/root.client' +// oxlint-disable-next-line import/no-unassigned-import +import "./styles.css"; +import { Link, Outlet } from "react-router"; +import { TestClientState, TestHydrated } from "./routes/client"; +import { DumpError, GlobalNavigationLoadingBar } from "./routes/root.client"; export function Layout({ children }: { children: React.ReactNode }) { - console.log('[debug] root - Layout') + console.log("[debug] root - Layout"); return ( @@ -37,18 +38,18 @@ export function Layout({ children }: { children: React.ReactNode }) { {children} - ) + ); } export default function Component() { - console.log('[debug] root - Component') + console.log("[debug] root - Component"); return ( <> - ) + ); } export function ErrorBoundary() { - return + return ; } diff --git a/fixtures/react-router-rsc/app/routes.ts b/fixtures/react-router-rsc/app/routes.ts index 24914e7f..5a2907fb 100644 --- a/fixtures/react-router-rsc/app/routes.ts +++ b/fixtures/react-router-rsc/app/routes.ts @@ -1,21 +1,21 @@ -import type { unstable_RSCRouteConfigEntry } from 'react-router' +import type { unstable_RSCRouteConfigEntry } from "react-router"; -export const routes: unstable_RSCRouteConfigEntry[] = [ +export const routes: Array = [ { - id: 'root', - path: '', - lazy: () => import('./root'), + id: "root", + path: "", + lazy: () => import("./root"), children: [ { - id: 'home', + id: "home", index: true, - lazy: () => import('./routes/home'), + lazy: () => import("./routes/home"), }, { - id: 'about', - path: 'about', - lazy: () => import('./routes/about'), + id: "about", + path: "about", + lazy: () => import("./routes/about"), }, ], }, -] +]; diff --git a/fixtures/react-router-rsc/app/routes/about.tsx b/fixtures/react-router-rsc/app/routes/about.tsx index a4a076ca..583f190f 100644 --- a/fixtures/react-router-rsc/app/routes/about.tsx +++ b/fixtures/react-router-rsc/app/routes/about.tsx @@ -1,9 +1,9 @@ -'use client' +"use client"; -import React from 'react' +import React from "react"; export function Component() { - const [count, setCount] = React.useState(0) + const [count, setCount] = React.useState(0); return (
@@ -16,5 +16,5 @@ export function Component() {
- ) + ); } diff --git a/fixtures/react-router-rsc/app/routes/client.tsx b/fixtures/react-router-rsc/app/routes/client.tsx index 679c9938..8c23e84c 100644 --- a/fixtures/react-router-rsc/app/routes/client.tsx +++ b/fixtures/react-router-rsc/app/routes/client.tsx @@ -1,22 +1,16 @@ -'use client' +"use client"; -import React from 'react' +import React from "react"; export function TestHydrated() { const hydrated = React.useSyncExternalStore( React.useCallback(() => () => {}, []), () => true, () => false, - ) - return [hydrated: {hydrated ? 1 : 0}] + ); + return [hydrated: {hydrated ? 1 : 0}]; } export function TestClientState() { - return ( - - ) + return ; } diff --git a/fixtures/react-router-rsc/app/routes/home.actions.ts b/fixtures/react-router-rsc/app/routes/home.actions.ts index 715166e5..ece1e13a 100644 --- a/fixtures/react-router-rsc/app/routes/home.actions.ts +++ b/fixtures/react-router-rsc/app/routes/home.actions.ts @@ -1,7 +1,7 @@ -'use server' +"use server"; export async function sayHello(defaultName: string, formData: FormData) { - await new Promise((resolve) => setTimeout(resolve, 500)) - const name = formData.get('name') || defaultName - console.log(`[debug] sayHello - ${name}`) + await new Promise((resolve) => setTimeout(resolve, 500)); + const name = formData.get("name") || defaultName; + console.log(`[debug] sayHello - ${name}`); } diff --git a/fixtures/react-router-rsc/app/routes/home.client.tsx b/fixtures/react-router-rsc/app/routes/home.client.tsx index 6da32f10..8f2c4fad 100644 --- a/fixtures/react-router-rsc/app/routes/home.client.tsx +++ b/fixtures/react-router-rsc/app/routes/home.client.tsx @@ -1,12 +1,12 @@ -'use client' +"use client"; -import { useFormStatus } from 'react-dom' +import { useFormStatus } from "react-dom"; export function PendingButton() { - const status = useFormStatus() + const status = useFormStatus(); return ( - ) + ); } diff --git a/fixtures/react-router-rsc/app/routes/home.tsx b/fixtures/react-router-rsc/app/routes/home.tsx index fdaf9db3..f82ecd77 100644 --- a/fixtures/react-router-rsc/app/routes/home.tsx +++ b/fixtures/react-router-rsc/app/routes/home.tsx @@ -1,7 +1,8 @@ -import { sayHello } from './home.actions.ts' -import { PendingButton } from './home.client.tsx' -import './home.css' -import { TestActionStateServer } from './test-action-state/server.tsx' +import { sayHello } from "./home.actions.ts"; +import { PendingButton } from "./home.client.tsx"; +// oxlint-disable-next-line import/no-unassigned-import +import "./home.css"; +import { TestActionStateServer } from "./test-action-state/server.tsx"; const Component = () => { return ( @@ -11,21 +12,12 @@ const Component = () => {

This is the home page.

[test-style-home]

Server Action

-
+
- +
@@ -36,7 +28,7 @@ const Component = () => {
- ) -} + ); +}; -export default Component +export default Component; diff --git a/fixtures/react-router-rsc/app/routes/root.client.tsx b/fixtures/react-router-rsc/app/routes/root.client.tsx index be3a8e3d..8729caa0 100644 --- a/fixtures/react-router-rsc/app/routes/root.client.tsx +++ b/fixtures/react-router-rsc/app/routes/root.client.tsx @@ -1,21 +1,21 @@ -'use client' +"use client"; -import { useNavigation, useRouteError } from 'react-router' +import { useNavigation, useRouteError } from "react-router"; export function GlobalNavigationLoadingBar() { - const navigation = useNavigation() + const navigation = useNavigation(); - if (navigation.state === 'idle') return null + if (navigation.state === "idle") return null; return (
- ) + ); } export function DumpError() { - const error = useRouteError() + const error = useRouteError(); const message = error instanceof Error ? (
@@ -34,11 +34,11 @@ export function DumpError() {
) : (
Unknown Error
- ) + ); return ( <>

Oooops

{message}
- ) + ); } diff --git a/fixtures/react-router-rsc/app/routes/test-action-state/client.tsx b/fixtures/react-router-rsc/app/routes/test-action-state/client.tsx index 520dab49..be67c28b 100644 --- a/fixtures/react-router-rsc/app/routes/test-action-state/client.tsx +++ b/fixtures/react-router-rsc/app/routes/test-action-state/client.tsx @@ -1,19 +1,16 @@ -'use client' +"use client"; -import React from 'react' +import React from "react"; export function TestActionStateClient(props: { - action: (prev: React.ReactNode) => Promise + action: (prev: React.ReactNode) => Promise; }) { - const [state, formAction, isPending] = React.useActionState( - props.action, - null, - ) + const [state, formAction, isPending] = React.useActionState(props.action, null); return ( - {isPending ? 'pending...' : state} + {isPending ? "pending..." : state} - ) + ); } diff --git a/fixtures/react-router-rsc/app/routes/test-action-state/server.tsx b/fixtures/react-router-rsc/app/routes/test-action-state/server.tsx index 128186e2..7044c2e9 100644 --- a/fixtures/react-router-rsc/app/routes/test-action-state/server.tsx +++ b/fixtures/react-router-rsc/app/routes/test-action-state/server.tsx @@ -1,4 +1,4 @@ -import { TestActionStateClient } from './client' +import { TestActionStateClient } from "./client"; // Test case based on // https://github.com/remix-run/react-router/issues/13882 @@ -7,14 +7,14 @@ export function TestActionStateServer({ message }: { message: string }) { return ( { - 'use server' - await new Promise((resolve) => setTimeout(resolve, 200)) + "use server"; + await new Promise((resolve) => setTimeout(resolve, 200)); return ( [(ok) ({message})] {prev} - ) + ); }} /> - ) + ); } diff --git a/fixtures/react-router-rsc/app/styles.css b/fixtures/react-router-rsc/app/styles.css index e1a22e4e..c66d1648 100644 --- a/fixtures/react-router-rsc/app/styles.css +++ b/fixtures/react-router-rsc/app/styles.css @@ -1,7 +1,7 @@ -@import 'tailwindcss'; +@import "tailwindcss"; @plugin "@tailwindcss/typography"; -@import './paper.css'; +@import "./paper.css"; @theme { --animate-progress: progress 1s infinite linear; diff --git a/fixtures/react-router-rsc/react-router-vite/entry.browser.tsx b/fixtures/react-router-rsc/react-router-vite/entry.browser.tsx index 7b30c863..f1c3b792 100644 --- a/fixtures/react-router-rsc/react-router-vite/entry.browser.tsx +++ b/fixtures/react-router-rsc/react-router-vite/entry.browser.tsx @@ -3,18 +3,15 @@ import { createTemporaryReferenceSet, encodeReply, setServerCallback, -} from '@vitejs/plugin-rsc/browser' -import { startTransition, StrictMode } from 'react' -import { hydrateRoot } from 'react-dom/client' -import { - type DataRouter, - type unstable_RSCPayload as RSCServerPayload, -} from 'react-router' +} from "@vitejs/plugin-rsc/browser"; +import { startTransition, StrictMode } from "react"; +import { hydrateRoot } from "react-dom/client"; +import type { DataRouter, unstable_RSCPayload as RSCServerPayload } from "react-router"; import { unstable_createCallServer as createCallServer, unstable_getRSCStream as getRSCStream, unstable_RSCHydratedRouter as RSCHydratedRouter, -} from 'react-router/dom' +} from "react-router/dom"; // Create and set the callServer function to support post-hydration server actions. setServerCallback( @@ -23,34 +20,30 @@ setServerCallback( createTemporaryReferenceSet, encodeReply, }), -) +); // Get and decode the initial server payload createFromReadableStream(getRSCStream()).then((payload) => { startTransition(async () => { - const formState = - payload.type === 'render' ? await payload.formState : undefined + const formState = payload.type === "render" ? await payload.formState : undefined; hydrateRoot( document, - + , { // @ts-expect-error - no types for this yet formState, }, - ) - }) -}) + ); + }); +}); -declare let __reactRouterDataRouter: DataRouter +declare let __reactRouterDataRouter: DataRouter; if (import.meta.hot) { - import.meta.hot.on('rsc:update', () => { - __reactRouterDataRouter.revalidate() - }) + import.meta.hot.on("rsc:update", () => { + __reactRouterDataRouter.revalidate(); + }); } diff --git a/fixtures/react-router-rsc/react-router-vite/entry.rsc.single.tsx b/fixtures/react-router-rsc/react-router-vite/entry.rsc.single.tsx index 3df903d4..5a21ed4f 100644 --- a/fixtures/react-router-rsc/react-router-vite/entry.rsc.single.tsx +++ b/fixtures/react-router-rsc/react-router-vite/entry.rsc.single.tsx @@ -1,9 +1,8 @@ -import { fetchServer } from './entry.rsc' +import type * as EntrySsr from "./entry.ssr"; +import { fetchServer } from "./entry.rsc"; export default async function handler(request: Request) { - const ssr = await import.meta.viteRsc.loadModule< - typeof import('./entry.ssr') - >('ssr', 'index') + const ssr = await import.meta.viteRsc.loadModule("ssr", "index"); - return ssr.default(request, await fetchServer(request)) + return ssr.default(request, await fetchServer(request)); } diff --git a/fixtures/react-router-rsc/react-router-vite/entry.rsc.tsx b/fixtures/react-router-rsc/react-router-vite/entry.rsc.tsx index 847211bf..5d3d27fa 100644 --- a/fixtures/react-router-rsc/react-router-vite/entry.rsc.tsx +++ b/fixtures/react-router-rsc/react-router-vite/entry.rsc.tsx @@ -5,9 +5,9 @@ import { decodeReply, loadServerAction, renderToReadableStream, -} from '@vitejs/plugin-rsc/rsc' -import { unstable_matchRSCServerRequest as matchRSCServerRequest } from 'react-router' -import { routes } from '../app/routes' +} from "@vitejs/plugin-rsc/rsc"; +import { unstable_matchRSCServerRequest as matchRSCServerRequest } from "react-router"; +import { routes } from "../app/routes"; export function fetchServer(request: Request) { return matchRSCServerRequest({ @@ -26,11 +26,11 @@ export function fetchServer(request: Request) { return new Response(renderToReadableStream(match.payload, options), { status: match.statusCode, headers: match.headers, - }) + }); }, - }) + }); } if (import.meta.hot) { - import.meta.hot.accept() + import.meta.hot.accept(); } diff --git a/fixtures/react-router-rsc/react-router-vite/entry.ssr.tsx b/fixtures/react-router-rsc/react-router-vite/entry.ssr.tsx index 4e356c1b..d4559b37 100644 --- a/fixtures/react-router-rsc/react-router-vite/entry.ssr.tsx +++ b/fixtures/react-router-rsc/react-router-vite/entry.ssr.tsx @@ -1,33 +1,29 @@ -import { createFromReadableStream } from '@vitejs/plugin-rsc/ssr' -import { renderToReadableStream as renderHTMLToReadableStream } from 'react-dom/server.edge' +import { createFromReadableStream } from "@vitejs/plugin-rsc/ssr"; +import { renderToReadableStream as renderHTMLToReadableStream } from "react-dom/server.edge"; import { unstable_routeRSCServerRequest as routeRSCServerRequest, unstable_RSCStaticRouter as RSCStaticRouter, -} from 'react-router' +} from "react-router"; export default async function handler( request: Request, serverResponse: Response, ): Promise { - const bootstrapScriptContent = - await import.meta.viteRsc.loadBootstrapScriptContent('index') + const bootstrapScriptContent = await import.meta.viteRsc.loadBootstrapScriptContent("index"); return await routeRSCServerRequest({ request, serverResponse, createFromReadableStream, async renderHTML(getPayload, options) { - const payload = getPayload() + const payload = getPayload(); - return await renderHTMLToReadableStream( - , - { - ...options, - bootstrapScriptContent, - signal: request.signal, - formState: await payload.formState, - }, - ) + return await renderHTMLToReadableStream(, { + ...options, + bootstrapScriptContent, + signal: request.signal, + formState: await payload.formState, + }); }, - }) + }); } diff --git a/fixtures/react-router-rsc/react-router-vite/entry.worker.tsx b/fixtures/react-router-rsc/react-router-vite/entry.worker.tsx index e3c5b883..cc1f54c5 100644 --- a/fixtures/react-router-rsc/react-router-vite/entry.worker.tsx +++ b/fixtures/react-router-rsc/react-router-vite/entry.worker.tsx @@ -1,22 +1,24 @@ -import handler from "./entry.rsc.single" +import type * as WorkerSsr from "./worker-ssr"; +import handler from "./entry.rsc.single"; // The distilled Cloudflare worker wrapper expects a `{ fetch }` default export; // the RSC single-worker handler is a bare (request) => Response function. export default { async fetch(request: Request): Promise { - const url = new URL(request.url) + const url = new URL(request.url); // Worker code that needs a non-`react-server` module (here `react-dom/server`) // must not import it directly in this `rsc` entry — it loads it from the // `ssr` environment via `loadModule`. Exercises a custom (non-`index`) ssr // input + cross-environment load through the distilled plugin. if (url.pathname === "/worker-render") { - const { renderWorkerHtml } = await import.meta.viteRsc.loadModule< - typeof import("./worker-ssr") - >("ssr", "worker-ssr") - return Response.json({ ok: true, html: renderWorkerHtml() }) + const { renderWorkerHtml } = await import.meta.viteRsc.loadModule( + "ssr", + "worker-ssr", + ); + return Response.json({ ok: true, html: renderWorkerHtml() }); } - return handler(request) + return handler(request); }, -} +}; diff --git a/fixtures/react-router-rsc/react-router-vite/worker-ssr.tsx b/fixtures/react-router-rsc/react-router-vite/worker-ssr.tsx index 2ce5ad26..00880f6c 100644 --- a/fixtures/react-router-rsc/react-router-vite/worker-ssr.tsx +++ b/fixtures/react-router-rsc/react-router-vite/worker-ssr.tsx @@ -1,5 +1,5 @@ -import { createElement } from "react" -import { renderToStaticMarkup } from "react-dom/server.edge" +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server.edge"; // Lives in the `ssr` environment (no `react-server` condition), so it can use // `react-dom/server` — which would fail if imported directly in the worker's @@ -9,5 +9,5 @@ import { renderToStaticMarkup } from "react-dom/server.edge" export function renderWorkerHtml(): string { return renderToStaticMarkup( createElement("section", null, "Worker render via the ssr environment."), - ) + ); } diff --git a/fixtures/react-router-rsc/test/dev.test.ts b/fixtures/react-router-rsc/test/dev.test.ts index 3c64995b..434ceb6f 100644 --- a/fixtures/react-router-rsc/test/dev.test.ts +++ b/fixtures/react-router-rsc/test/dev.test.ts @@ -1,57 +1,57 @@ -import { afterAll, beforeAll, expect, test } from "bun:test" -import { spawn, type ChildProcess } from "node:child_process" -import { fileURLToPath } from "node:url" -import path from "node:path" +import { afterAll, beforeAll, expect, test } from "bun:test"; +import { spawn, type ChildProcess } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; // Dev-mode smoke test: boots `vite dev` (the distilled Cloudflare plugin in // RSC mode) and asserts the RSC routes, routing, and the worker-loaded ssr // render all respond. Build-mode tests are intentionally absent — RSC build // is a separate track. -const fixtureDir = path.dirname(path.dirname(fileURLToPath(import.meta.url))) +const fixtureDir = path.dirname(path.dirname(fileURLToPath(import.meta.url))); -let proc: ChildProcess -let baseUrl: string +let proc: ChildProcess; +let baseUrl: string; beforeAll(async () => { proc = spawn("bun", ["vite", "dev", "--port", "3251"], { cwd: fixtureDir, stdio: ["ignore", "pipe", "pipe"], - }) + }); baseUrl = await new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error("dev server did not start in time")), 90_000) + const timer = setTimeout(() => reject(new Error("dev server did not start in time")), 90_000); const onData = (chunk: Buffer) => { - const match = String(chunk).match(/Local:\s+(http:\/\/\S+?)\/?\s/) + const match = String(chunk).match(/Local:\s+(http:\/\/\S+?)\/?\s/); if (match) { - clearTimeout(timer) - resolve(match[1].replace(/\/$/, "")) + clearTimeout(timer); + resolve(match[1].replace(/\/$/, "")); } - } - proc.stdout?.on("data", onData) - proc.stderr?.on("data", onData) - proc.on("exit", (code) => reject(new Error(`dev server exited early (code ${code})`))) - }) -}) + }; + proc.stdout?.on("data", onData); + proc.stderr?.on("data", onData); + proc.on("exit", (code) => reject(new Error(`dev server exited early (code ${code})`))); + }); +}); afterAll(() => { - proc?.kill("SIGTERM") -}) + proc?.kill("SIGTERM"); +}); test("renders the RSC home route", async () => { - const res = await fetch(`${baseUrl}/`) - expect(res.status).toBe(200) - expect(await res.text()).toContain("React Router Vite") -}) + const res = await fetch(`${baseUrl}/`); + expect(res.status).toBe(200); + expect(await res.text()).toContain("React Router Vite"); +}); test("routes to /about", async () => { - const res = await fetch(`${baseUrl}/about`) - expect(res.status).toBe(200) - expect(await res.text()).toContain("About") -}) + const res = await fetch(`${baseUrl}/about`); + expect(res.status).toBe(200); + expect(await res.text()).toContain("About"); +}); test("worker loads a custom ssr module via loadModule (react-dom/server in ssr env)", async () => { - const res = await fetch(`${baseUrl}/worker-render`) - expect(res.status).toBe(200) - const body = (await res.json()) as { ok: boolean; html: string } - expect(body.ok).toBe(true) - expect(body.html).toContain("Worker render via the ssr environment.") -}) + const res = await fetch(`${baseUrl}/worker-render`); + expect(res.status).toBe(200); + const body = (await res.json()) as { ok: boolean; html: string }; + expect(body.ok).toBe(true); + expect(body.html).toContain("Worker render via the ssr environment."); +}); diff --git a/fixtures/react-router-rsc/vite.config.ts b/fixtures/react-router-rsc/vite.config.ts index c8c06db2..8496d54f 100644 --- a/fixtures/react-router-rsc/vite.config.ts +++ b/fixtures/react-router-rsc/vite.config.ts @@ -1,8 +1,8 @@ -import cloudflare from "@distilled.cloud/cloudflare-vite-plugin" -import tailwindcss from "@tailwindcss/vite" -import react from "@vitejs/plugin-react" -import rsc from "@vitejs/plugin-rsc" -import { defineConfig } from "vite" +import cloudflare from "@distilled.cloud/cloudflare-vite-plugin"; +import tailwindcss from "@tailwindcss/vite"; +import react from "@vitejs/plugin-react"; +import rsc from "@vitejs/plugin-rsc"; +import { defineConfig } from "vite"; // React Router (hand-rolled on @vitejs/plugin-rsc) wired to the distilled // Cloudflare vite plugin via the single-worker child-environment model — @@ -19,10 +19,8 @@ export default defineConfig({ // Workaround for https://github.com/tailwindlabs/tailwindcss/pull/19670 name: "fix-tailwind-full-reload", configResolved(config) { - const plugin = config.plugins.find( - (p) => p.name === "@tailwindcss/vite:generate:serve", - ) - delete plugin?.hotUpdate + const plugin = config.plugins.find((p) => p.name === "@tailwindcss/vite:generate:serve"); + delete plugin?.hotUpdate; }, }, react(), @@ -56,4 +54,4 @@ export default defineConfig({ optimizeDeps: { include: ["react-router", "react-router/internal/react-server-client"], }, -}) +}); diff --git a/fixtures/react-rsc/src/action.tsx b/fixtures/react-rsc/src/action.tsx index 4fc55d65..6b5029dc 100644 --- a/fixtures/react-rsc/src/action.tsx +++ b/fixtures/react-rsc/src/action.tsx @@ -1,11 +1,11 @@ -'use server' +"use server"; -let serverCounter = 0 +let serverCounter = 0; export async function getServerCounter() { - return serverCounter + return serverCounter; } export async function updateServerCounter(change: number) { - serverCounter += change + serverCounter += change; } diff --git a/fixtures/react-rsc/src/client.tsx b/fixtures/react-rsc/src/client.tsx index 29bb5d36..ac69d863 100644 --- a/fixtures/react-rsc/src/client.tsx +++ b/fixtures/react-rsc/src/client.tsx @@ -1,13 +1,9 @@ -'use client' +"use client"; -import React from 'react' +import React from "react"; export function ClientCounter() { - const [count, setCount] = React.useState(0) + const [count, setCount] = React.useState(0); - return ( - - ) + return ; } diff --git a/fixtures/react-rsc/src/framework/entry.browser.tsx b/fixtures/react-rsc/src/framework/entry.browser.tsx index 5b48ebdf..5e2c2031 100644 --- a/fixtures/react-rsc/src/framework/entry.browser.tsx +++ b/fixtures/react-rsc/src/framework/entry.browser.tsx @@ -4,64 +4,64 @@ import { setServerCallback, createTemporaryReferenceSet, encodeReply, -} from '@vitejs/plugin-rsc/browser' -import React from 'react' -import { createRoot, hydrateRoot } from 'react-dom/client' -import { rscStream } from 'rsc-html-stream/client' -import type { RscPayload } from './entry.rsc' -import { GlobalErrorBoundary } from './error-boundary' -import { createRscRenderRequest } from './request' +} from "@vitejs/plugin-rsc/browser"; +import React from "react"; +import { createRoot, hydrateRoot } from "react-dom/client"; +import { rscStream } from "rsc-html-stream/client"; +import type { RscPayload } from "./entry.rsc"; +import { GlobalErrorBoundary } from "./error-boundary"; +import { createRscRenderRequest } from "./request"; async function main() { // stash `setPayload` function to trigger re-rendering // from outside of `BrowserRoot` component (e.g. server function call, navigation, hmr) - let setPayload: (v: RscPayload) => void + let setPayload: (v: RscPayload) => void; // deserialize RSC stream back to React VDOM for CSR const initialPayload = await createFromReadableStream( // initial RSC stream is injected in SSR stream as rscStream, - ) + ); // browser root component to (re-)render RSC payload as state function BrowserRoot() { - const [payload, setPayload_] = React.useState(initialPayload) + const [payload, setPayload_] = React.useState(initialPayload); React.useEffect(() => { - setPayload = (v) => React.startTransition(() => setPayload_(v)) - }, [setPayload_]) + setPayload = (v) => React.startTransition(() => setPayload_(v)); + }, [setPayload_]); // re-fetch/render on client side navigation React.useEffect(() => { - return listenNavigation(() => fetchRscPayload()) - }, []) + return listenNavigation(() => fetchRscPayload()); + }, []); - return payload.root + return payload.root; } // re-fetch RSC and trigger re-rendering async function fetchRscPayload() { - const renderRequest = createRscRenderRequest(window.location.href) - const payload = await createFromFetch(fetch(renderRequest)) - setPayload(payload) + const renderRequest = createRscRenderRequest(window.location.href); + const payload = await createFromFetch(fetch(renderRequest)); + setPayload(payload); } // register a handler which will be internally called by React // on server function request after hydration. setServerCallback(async (id, args) => { - const temporaryReferences = createTemporaryReferenceSet() + const temporaryReferences = createTemporaryReferenceSet(); const renderRequest = createRscRenderRequest(window.location.href, { id, body: await encodeReply(args, { temporaryReferences }), - }) + }); const payload = await createFromFetch(fetch(renderRequest), { temporaryReferences, - }) - setPayload(payload) - const { ok, data } = payload.returnValue! - if (!ok) throw data - return data - }) + }); + setPayload(payload); + const { ok, data } = payload.returnValue!; + if (!ok) throw data; + return data; + }); // hydration const browserRoot = ( @@ -70,50 +70,50 @@ async function main() { - ) - if ('__NO_HYDRATE' in globalThis) { - createRoot(document).render(browserRoot) + ); + if ("__NO_HYDRATE" in globalThis) { + createRoot(document).render(browserRoot); } else { hydrateRoot(document, browserRoot, { formState: initialPayload.formState, - }) + }); } // implement server HMR by triggering re-fetch/render of RSC upon server code change if (import.meta.hot) { - import.meta.hot.on('rsc:update', () => { - fetchRscPayload() - }) + import.meta.hot.on("rsc:update", () => { + fetchRscPayload(); + }); } } // a little helper to setup events interception for client side navigation function listenNavigation(onNavigation: () => void) { - window.addEventListener('popstate', onNavigation) + window.addEventListener("popstate", onNavigation); - const oldPushState = window.history.pushState + const oldPushState = window.history.pushState; window.history.pushState = function (...args) { - const res = oldPushState.apply(this, args) - onNavigation() - return res - } + const res = oldPushState.apply(this, args); + onNavigation(); + return res; + }; - const oldReplaceState = window.history.replaceState + const oldReplaceState = window.history.replaceState; window.history.replaceState = function (...args) { - const res = oldReplaceState.apply(this, args) - onNavigation() - return res - } + const res = oldReplaceState.apply(this, args); + onNavigation(); + return res; + }; function onClick(e: MouseEvent) { - let link = (e.target as Element).closest('a') + let link = (e.target as Element).closest("a"); if ( link && link instanceof HTMLAnchorElement && link.href && - (!link.target || link.target === '_self') && + (!link.target || link.target === "_self") && link.origin === location.origin && - !link.hasAttribute('download') && + !link.hasAttribute("download") && e.button === 0 && // left clicks only !e.metaKey && // open in new tab (mac) !e.ctrlKey && // open in new tab (windows) @@ -121,18 +121,18 @@ function listenNavigation(onNavigation: () => void) { !e.shiftKey && !e.defaultPrevented ) { - e.preventDefault() - history.pushState(null, '', link.href) + e.preventDefault(); + history.pushState(null, "", link.href); } } - document.addEventListener('click', onClick) + document.addEventListener("click", onClick); return () => { - document.removeEventListener('click', onClick) - window.removeEventListener('popstate', onNavigation) - window.history.pushState = oldPushState - window.history.replaceState = oldReplaceState - } + document.removeEventListener("click", onClick); + window.removeEventListener("popstate", onNavigation); + window.history.pushState = oldPushState; + window.history.replaceState = oldReplaceState; + }; } -main() +main(); diff --git a/fixtures/react-rsc/src/framework/entry.rsc.tsx b/fixtures/react-rsc/src/framework/entry.rsc.tsx index c9cf5c4b..68f4eb94 100644 --- a/fixtures/react-rsc/src/framework/entry.rsc.tsx +++ b/fixtures/react-rsc/src/framework/entry.rsc.tsx @@ -5,10 +5,11 @@ import { loadServerAction, decodeAction, decodeFormState, -} from '@vitejs/plugin-rsc/rsc' -import type { ReactFormState } from 'react-dom/client' -import { Root } from '../root.tsx' -import { parseRenderRequest } from './request.tsx' +} from "@vitejs/plugin-rsc/rsc"; +import type { ReactFormState } from "react-dom/client"; +import type * as EntrySsr from "./entry.ssr.tsx"; +import { Root } from "../root.tsx"; +import { parseRenderRequest } from "./request.tsx"; // The schema of payload which is serialized into RSC stream on rsc environment // and deserialized on ssr/client environments. @@ -16,59 +17,59 @@ export type RscPayload = { // this demo renders/serializes/deserizlies entire root html element // but this mechanism can be changed to render/fetch different parts of components // based on your own route conventions. - root: React.ReactNode + root: React.ReactNode; // server action return value of non-progressive enhancement case - returnValue?: { ok: boolean; data: unknown } + returnValue?: { ok: boolean; data: unknown }; // server action form state (e.g. useActionState) of progressive enhancement case - formState?: ReactFormState -} + formState?: ReactFormState; +}; // the plugin by default assumes `rsc` entry having default export of request handler. // however, how server entries are executed can be customized by registering own server handler. -export default { fetch: handler } +export default { fetch: handler }; async function handler(request: Request): Promise { // differentiate RSC, SSR, action, etc. - const renderRequest = parseRenderRequest(request) - request = renderRequest.request + const renderRequest = parseRenderRequest(request); + request = renderRequest.request; // handle server function request - let returnValue: RscPayload['returnValue'] | undefined - let formState: ReactFormState | undefined - let temporaryReferences: unknown | undefined - let actionStatus: number | undefined + let returnValue: RscPayload["returnValue"] | undefined; + let formState: ReactFormState | undefined; + let temporaryReferences: unknown | undefined; + let actionStatus: number | undefined; if (renderRequest.isAction === true) { if (renderRequest.actionId) { // action is called via `ReactClient.setServerCallback`. - const contentType = request.headers.get('content-type') - const body = contentType?.startsWith('multipart/form-data') + const contentType = request.headers.get("content-type"); + const body = contentType?.startsWith("multipart/form-data") ? await request.formData() - : await request.text() - temporaryReferences = createTemporaryReferenceSet() - const args = await decodeReply(body, { temporaryReferences }) - const action = await loadServerAction(renderRequest.actionId) + : await request.text(); + temporaryReferences = createTemporaryReferenceSet(); + const args = await decodeReply(body, { temporaryReferences }); + const action = await loadServerAction(renderRequest.actionId); try { - const data = await action.apply(null, args) - returnValue = { ok: true, data } + const data = await action.apply(null, args); + returnValue = { ok: true, data }; } catch (e) { - returnValue = { ok: false, data: e } - actionStatus = 500 + returnValue = { ok: false, data: e }; + actionStatus = 500; } } else { // otherwise server function is called via `
` // before hydration (e.g. when javascript is disabled). // aka progressive enhancement. - const formData = await request.formData() - const decodedAction = await decodeAction(formData) + const formData = await request.formData(); + const decodedAction = await decodeAction(formData); try { - const result = await decodedAction() - formState = await decodeFormState(result, formData) - } catch (e) { + const result = await decodedAction(); + formState = await decodeFormState(result, formData); + } catch { // there's no single general obvious way to surface this error, // so explicitly return classic 500 response. - return new Response('Internal Server Error: server action failed', { + return new Response("Internal Server Error: server action failed", { status: 500, - }) + }); } } } @@ -81,42 +82,40 @@ async function handler(request: Request): Promise { root: , formState, returnValue, - } - const rscOptions = { temporaryReferences } - const rscStream = renderToReadableStream(rscPayload, rscOptions) + }; + const rscOptions = { temporaryReferences }; + const rscStream = renderToReadableStream(rscPayload, rscOptions); // Respond RSC stream without HTML rendering as decided by `RenderRequest` if (renderRequest.isRsc) { return new Response(rscStream, { status: actionStatus, headers: { - 'content-type': 'text/x-component;charset=utf-8', + "content-type": "text/x-component;charset=utf-8", }, - }) + }); } // Delegate to SSR environment for html rendering. // The plugin provides `loadModule` helper to allow loading SSR environment entry module // in RSC environment. however this can be customized by implementing own runtime communication // e.g. `@cloudflare/vite-plugin`'s service binding. - const ssrEntryModule = await import.meta.viteRsc.loadModule< - typeof import('./entry.ssr.tsx') - >('ssr', 'index') + const ssrEntryModule = await import.meta.viteRsc.loadModule("ssr", "index"); const ssrResult = await ssrEntryModule.renderHTML(rscStream, { formState, // allow quick simulation of javascript disabled browser - debugNojs: renderRequest.url.searchParams.has('__nojs'), - }) + debugNojs: renderRequest.url.searchParams.has("__nojs"), + }); // respond html return new Response(ssrResult.stream, { status: ssrResult.status, headers: { - 'Content-type': 'text/html', + "Content-type": "text/html", }, - }) + }); } if (import.meta.hot) { - import.meta.hot.accept() + import.meta.hot.accept(); } diff --git a/fixtures/react-rsc/src/framework/entry.ssr.tsx b/fixtures/react-rsc/src/framework/entry.ssr.tsx index 7fc5a956..27d8ae71 100644 --- a/fixtures/react-rsc/src/framework/entry.ssr.tsx +++ b/fixtures/react-rsc/src/framework/entry.ssr.tsx @@ -1,49 +1,46 @@ -import { createFromReadableStream } from '@vitejs/plugin-rsc/ssr' -import React from 'react' -import type { ReactFormState } from 'react-dom/client' -import { renderToReadableStream } from 'react-dom/server.edge' -import { injectRSCPayload } from 'rsc-html-stream/server' -import type { RscPayload } from './entry.rsc' +import { createFromReadableStream } from "@vitejs/plugin-rsc/ssr"; +import React from "react"; +import type { ReactFormState } from "react-dom/client"; +import { renderToReadableStream } from "react-dom/server.edge"; +import { injectRSCPayload } from "rsc-html-stream/server"; +import type { RscPayload } from "./entry.rsc"; export async function renderHTML( rscStream: ReadableStream, options: { - formState?: ReactFormState - nonce?: string - debugNojs?: boolean + formState?: ReactFormState; + nonce?: string; + debugNojs?: boolean; }, ): Promise<{ stream: ReadableStream; status?: number }> { // duplicate one RSC stream into two. // - one for SSR (ReactClient.createFromReadableStream below) // - another for browser hydration payload by injecting . - const [rscStream1, rscStream2] = rscStream.tee() + const [rscStream1, rscStream2] = rscStream.tee(); // deserialize RSC stream back to React VDOM - let payload: Promise | undefined + let payload: Promise | undefined; function SsrRoot() { // deserialization needs to be kicked off inside ReactDOMServer context // for ReactDomServer preinit/preloading to work - payload ??= createFromReadableStream(rscStream1) - return React.use(payload).root + payload ??= createFromReadableStream(rscStream1); + return React.use(payload).root; } // render html (traditional SSR) - const bootstrapScriptContent = - await import.meta.viteRsc.loadBootstrapScriptContent('index') - let htmlStream: ReadableStream - let status: number | undefined + const bootstrapScriptContent = await import.meta.viteRsc.loadBootstrapScriptContent("index"); + let htmlStream: ReadableStream; + let status: number | undefined; try { htmlStream = await renderToReadableStream(, { - bootstrapScriptContent: options?.debugNojs - ? undefined - : bootstrapScriptContent, + bootstrapScriptContent: options?.debugNojs ? undefined : bootstrapScriptContent, nonce: options?.nonce, formState: options?.formState, - }) - } catch (e) { + }); + } catch { // fallback to render an empty shell and run pure CSR on browser, // which can replay server component error and trigger error boundary. - status = 500 + status = 500; htmlStream = await renderToReadableStream( @@ -52,14 +49,13 @@ export async function renderHTML( , { bootstrapScriptContent: - `self.__NO_HYDRATE=1;` + - (options?.debugNojs ? '' : bootstrapScriptContent), + `self.__NO_HYDRATE=1;` + (options?.debugNojs ? "" : bootstrapScriptContent), nonce: options?.nonce, }, - ) + ); } - let responseStream: ReadableStream = htmlStream + let responseStream: ReadableStream = htmlStream; if (!options?.debugNojs) { // initial RSC stream is injected in HTML stream as // using utility made by devongovett https://github.com/devongovett/rsc-html-stream @@ -67,8 +63,8 @@ export async function renderHTML( injectRSCPayload(rscStream2, { nonce: options?.nonce, }), - ) + ); } - return { stream: responseStream, status } + return { stream: responseStream, status }; } diff --git a/fixtures/react-rsc/src/framework/error-boundary.tsx b/fixtures/react-rsc/src/framework/error-boundary.tsx index 39d91651..ccfb696d 100644 --- a/fixtures/react-rsc/src/framework/error-boundary.tsx +++ b/fixtures/react-rsc/src/framework/error-boundary.tsx @@ -1,41 +1,37 @@ -'use client' +"use client"; -import React from 'react' +import React from "react"; // Minimal ErrorBoundary example to handle errors globally on browser export function GlobalErrorBoundary(props: { children?: React.ReactNode }) { - return ( - - {props.children} - - ) + return {props.children}; } // https://github.com/vercel/next.js/blob/33f8428f7066bf8b2ec61f025427ceb2a54c4bdf/packages/next/src/client/components/error-boundary.tsx // https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary class ErrorBoundary extends React.Component<{ - children?: React.ReactNode + children?: React.ReactNode; errorComponent: React.FC<{ - error: Error - reset: () => void - }> + error: Error; + reset: () => void; + }>; }> { - state: { error?: Error } = {} + state: { error?: Error } = {}; static getDerivedStateFromError(error: Error) { - return { error } + return { error }; } reset = () => { - this.setState({ error: null }) - } + this.setState({ error: null }); + }; render() { - const error = this.state.error + const error = this.state.error; if (error) { - return + return ; } - return this.props.children + return this.props.children; } } @@ -49,33 +45,31 @@ function DefaultGlobalErrorPage(props: { error: Error; reset: () => void }) {

Caught an unexpected error

-          Error:{' '}
-          {import.meta.env.DEV && 'message' in props.error
-            ? props.error.message
-            : '(Unknown)'}
+          Error:{" "}
+          {import.meta.env.DEV && "message" in props.error ? props.error.message : "(Unknown)"}
         
- ) + ); } diff --git a/fixtures/react-rsc/src/framework/request.tsx b/fixtures/react-rsc/src/framework/request.tsx index 4c7c666e..7d48788e 100644 --- a/fixtures/react-rsc/src/framework/request.tsx +++ b/fixtures/react-rsc/src/framework/request.tsx @@ -1,44 +1,44 @@ // Framework conventions (arbitrary choices for this demo): // - Use `_.rsc` URL suffix to differentiate RSC requests from SSR requests // - Use `x-rsc-action` header to pass server action ID -const URL_POSTFIX = '_.rsc' -const HEADER_ACTION_ID = 'x-rsc-action' +const URL_POSTFIX = "_.rsc"; +const HEADER_ACTION_ID = "x-rsc-action"; // Parsed request information used to route between RSC/SSR rendering and action handling. // Created by parseRenderRequest() from incoming HTTP requests. type RenderRequest = { - isRsc: boolean // true if request should return RSC payload (via _.rsc suffix) - isAction: boolean // true if this is a server action call (POST request) - actionId?: string // server action ID from x-rsc-action header - request: Request // normalized Request with _.rsc suffix removed from URL - url: URL // normalized URL with _.rsc suffix removed -} + isRsc: boolean; // true if request should return RSC payload (via _.rsc suffix) + isAction: boolean; // true if this is a server action call (POST request) + actionId?: string; // server action ID from x-rsc-action header + request: Request; // normalized Request with _.rsc suffix removed from URL + url: URL; // normalized URL with _.rsc suffix removed +}; export function createRscRenderRequest( urlString: string, action?: { id: string; body: BodyInit }, ): Request { - const url = new URL(urlString) - url.pathname += URL_POSTFIX - const headers = new Headers() + const url = new URL(urlString); + url.pathname += URL_POSTFIX; + const headers = new Headers(); if (action) { - headers.set(HEADER_ACTION_ID, action.id) + headers.set(HEADER_ACTION_ID, action.id); } return new Request(url.toString(), { - method: action ? 'POST' : 'GET', + method: action ? "POST" : "GET", headers, body: action?.body, - }) + }); } export function parseRenderRequest(request: Request): RenderRequest { - const url = new URL(request.url) - const isAction = request.method === 'POST' + const url = new URL(request.url); + const isAction = request.method === "POST"; if (url.pathname.endsWith(URL_POSTFIX)) { - url.pathname = url.pathname.slice(0, -URL_POSTFIX.length) - const actionId = request.headers.get(HEADER_ACTION_ID) || undefined - if (request.method === 'POST' && !actionId) { - throw new Error('Missing action id header for RSC action request') + url.pathname = url.pathname.slice(0, -URL_POSTFIX.length); + const actionId = request.headers.get(HEADER_ACTION_ID) || undefined; + if (request.method === "POST" && !actionId) { + throw new Error("Missing action id header for RSC action request"); } return { isRsc: true, @@ -46,13 +46,13 @@ export function parseRenderRequest(request: Request): RenderRequest { actionId, request: new Request(url, request), url, - } + }; } else { return { isRsc: false, isAction, request, url, - } + }; } } diff --git a/fixtures/react-rsc/src/root.tsx b/fixtures/react-rsc/src/root.tsx index bc4ab1b4..aaf322d0 100644 --- a/fixtures/react-rsc/src/root.tsx +++ b/fixtures/react-rsc/src/root.tsx @@ -1,8 +1,10 @@ -import './index.css' // css import is automatically injected in exported server components -import viteLogo from '/vite.svg' -import { getServerCounter, updateServerCounter } from './action.tsx' -import reactLogo from './assets/react.svg' -import { ClientCounter } from './client.tsx' +// oxlint-disable-next-line import/no-unassigned-import +import "./index.css"; // css import is automatically injected in exported server components +// oxlint-disable-next-line import/no-absolute-path +import viteLogo from "/vite.svg"; +import { getServerCounter, updateServerCounter } from "./action.tsx"; +import reactLogo from "./assets/react.svg"; +import { ClientCounter } from "./client.tsx"; export function Root(props: { url: URL }) { return ( @@ -17,7 +19,7 @@ export function Root(props: { url: URL }) { - ) + ); } function App(props: { url: URL }) { @@ -27,10 +29,7 @@ function App(props: { url: URL }) { Vite logo - + React logo
@@ -52,20 +51,20 @@ function App(props: { url: URL }) { Edit src/root.tsx to test server HMR.
  • - Visit{' '} + Visit{" "} _.rsc - {' '} + {" "} to view RSC stream payload.
  • - Visit{' '} + Visit{" "} ?__nojs - {' '} + {" "} to test server action without js enabled.
  • - ) + ); } diff --git a/fixtures/react-rsc/test/dev.test.ts b/fixtures/react-rsc/test/dev.test.ts index 7dbcc264..6a86c442 100644 --- a/fixtures/react-rsc/test/dev.test.ts +++ b/fixtures/react-rsc/test/dev.test.ts @@ -1,46 +1,46 @@ -import { afterAll, beforeAll, expect, test } from "bun:test" -import { spawn, type ChildProcess } from "node:child_process" -import { fileURLToPath } from "node:url" -import path from "node:path" +import { afterAll, beforeAll, expect, test } from "bun:test"; +import { spawn, type ChildProcess } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; // Dev-mode smoke test: boots `vite dev` (the distilled Cloudflare plugin in // RSC mode) and asserts the minimal RSC app renders — server components, // the client component, and the server action are all present in the SSR'd // HTML. Build-mode is a separate track and intentionally not tested here. -const fixtureDir = path.dirname(path.dirname(fileURLToPath(import.meta.url))) +const fixtureDir = path.dirname(path.dirname(fileURLToPath(import.meta.url))); -let proc: ChildProcess -let baseUrl: string +let proc: ChildProcess; +let baseUrl: string; beforeAll(async () => { proc = spawn("bun", ["vite", "dev", "--port", "3151"], { cwd: fixtureDir, stdio: ["ignore", "pipe", "pipe"], - }) + }); baseUrl = await new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error("dev server did not start in time")), 90_000) + const timer = setTimeout(() => reject(new Error("dev server did not start in time")), 90_000); const onData = (chunk: Buffer) => { - const match = String(chunk).match(/Local:\s+(http:\/\/\S+?)\/?\s/) + const match = String(chunk).match(/Local:\s+(http:\/\/\S+?)\/?\s/); if (match) { - clearTimeout(timer) - resolve(match[1].replace(/\/$/, "")) + clearTimeout(timer); + resolve(match[1].replace(/\/$/, "")); } - } - proc.stdout?.on("data", onData) - proc.stderr?.on("data", onData) - proc.on("exit", (code) => reject(new Error(`dev server exited early (code ${code})`))) - }) -}) + }; + proc.stdout?.on("data", onData); + proc.stderr?.on("data", onData); + proc.on("exit", (code) => reject(new Error(`dev server exited early (code ${code})`))); + }); +}); afterAll(() => { - proc?.kill("SIGTERM") -}) + proc?.kill("SIGTERM"); +}); test("server-renders the RSC app (server component + client component + server action)", async () => { - const res = await fetch(`${baseUrl}/`) - expect(res.status).toBe(200) - const html = await res.text() - expect(html).toContain("Vite + RSC") // server component - expect(html).toContain("Client Counter") // client component - expect(html).toContain("Server Counter") // server action -}) + const res = await fetch(`${baseUrl}/`); + expect(res.status).toBe(200); + const html = await res.text(); + expect(html).toContain("Vite + RSC"); // server component + expect(html).toContain("Client Counter"); // client component + expect(html).toContain("Server Counter"); // server action +}); diff --git a/fixtures/react-rsc/test/plugin-order.test.ts b/fixtures/react-rsc/test/plugin-order.test.ts index f5be454f..3aa02ec4 100644 --- a/fixtures/react-rsc/test/plugin-order.test.ts +++ b/fixtures/react-rsc/test/plugin-order.test.ts @@ -1,7 +1,7 @@ -import { afterAll, beforeAll, expect, test } from "bun:test" -import { spawn, type ChildProcess } from "node:child_process" -import { fileURLToPath } from "node:url" -import path from "node:path" +import { afterAll, beforeAll, expect, test } from "bun:test"; +import { spawn, type ChildProcess } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; // Locks order-independence (review finding): with the Cloudflare plugin // registered BEFORE rsc(), the rsc env's resolve.conditions lists `react-server` @@ -9,40 +9,39 @@ import path from "node:path" // set-membership, so the app must still render correctly — if `react-server` // weren't effective in the rsc env, flight generation would 500. See // vite.config.cf-first.ts. -const fixtureDir = path.dirname(path.dirname(fileURLToPath(import.meta.url))) +const fixtureDir = path.dirname(path.dirname(fileURLToPath(import.meta.url))); -let proc: ChildProcess -let baseUrl: string +let proc: ChildProcess; +let baseUrl: string; beforeAll(async () => { - proc = spawn( - "bun", - ["vite", "dev", "-c", "vite.config.cf-first.ts", "--port", "3152"], - { cwd: fixtureDir, stdio: ["ignore", "pipe", "pipe"] }, - ) + proc = spawn("bun", ["vite", "dev", "-c", "vite.config.cf-first.ts", "--port", "3152"], { + cwd: fixtureDir, + stdio: ["ignore", "pipe", "pipe"], + }); baseUrl = await new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error("dev server did not start in time")), 90_000) + const timer = setTimeout(() => reject(new Error("dev server did not start in time")), 90_000); const onData = (chunk: Buffer) => { - const match = String(chunk).match(/Local:\s+(http:\/\/\S+?)\/?\s/) + const match = String(chunk).match(/Local:\s+(http:\/\/\S+?)\/?\s/); if (match) { - clearTimeout(timer) - resolve(match[1].replace(/\/$/, "")) + clearTimeout(timer); + resolve(match[1].replace(/\/$/, "")); } - } - proc.stdout?.on("data", onData) - proc.stderr?.on("data", onData) - proc.on("exit", (code) => reject(new Error(`dev server exited early (code ${code})`))) - }) -}) + }; + proc.stdout?.on("data", onData); + proc.stderr?.on("data", onData); + proc.on("exit", (code) => reject(new Error(`dev server exited early (code ${code})`))); + }); +}); afterAll(() => { - proc?.kill("SIGTERM") -}) + proc?.kill("SIGTERM"); +}); test("RSC renders correctly even with cloudflare() before rsc() (react-server not first in conditions)", async () => { - const res = await fetch(`${baseUrl}/`) - expect(res.status).toBe(200) - const html = await res.text() - expect(html).toContain("Vite + RSC") - expect(html).toContain("Client Counter") -}) + const res = await fetch(`${baseUrl}/`); + expect(res.status).toBe(200); + const html = await res.text(); + expect(html).toContain("Vite + RSC"); + expect(html).toContain("Client Counter"); +}); diff --git a/fixtures/react-rsc/vite.config.cf-first.ts b/fixtures/react-rsc/vite.config.cf-first.ts index 63fe8e9a..a1c9ebb1 100644 --- a/fixtures/react-rsc/vite.config.cf-first.ts +++ b/fixtures/react-rsc/vite.config.cf-first.ts @@ -1,7 +1,7 @@ -import cloudflare from "@distilled.cloud/cloudflare-vite-plugin" -import react from "@vitejs/plugin-react" -import rsc from "@vitejs/plugin-rsc" -import { defineConfig } from "vite" +import cloudflare from "@distilled.cloud/cloudflare-vite-plugin"; +import react from "@vitejs/plugin-react"; +import rsc from "@vitejs/plugin-rsc"; +import { defineConfig } from "vite"; // Same app as vite.config.ts but with the Cloudflare plugin registered BEFORE // `rsc()`. This deliberately produces the non-conventional condition ordering @@ -34,4 +34,4 @@ export default defineConfig({ build: { rollupOptions: { input: { index: "./src/framework/entry.browser.tsx" } } }, }, }, -}) +}); diff --git a/fixtures/react-rsc/vite.config.ts b/fixtures/react-rsc/vite.config.ts index 049e641e..c0895e05 100644 --- a/fixtures/react-rsc/vite.config.ts +++ b/fixtures/react-rsc/vite.config.ts @@ -1,7 +1,7 @@ -import cloudflare from "@distilled.cloud/cloudflare-vite-plugin" -import react from "@vitejs/plugin-react" -import rsc from "@vitejs/plugin-rsc" -import { defineConfig } from "vite" +import cloudflare from "@distilled.cloud/cloudflare-vite-plugin"; +import react from "@vitejs/plugin-react"; +import rsc from "@vitejs/plugin-rsc"; +import { defineConfig } from "vite"; // Minimal React Server Components app (the @vitejs/plugin-rsc `starter`) // wired to the distilled Cloudflare vite plugin, to reproduce and then fix @@ -36,4 +36,4 @@ export default defineConfig({ build: { rollupOptions: { input: { index: "./src/framework/entry.browser.tsx" } } }, }, }, -}) +}); From 5368394af74c043552c52498a2d92b172e00874b Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 15 Jun 2026 18:48:49 +0200 Subject: [PATCH 09/11] style(vite-plugin): reflow dev-plugin to oxfmt line width --- packages/cloudflare-vite-plugin/src/dev-plugin.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/cloudflare-vite-plugin/src/dev-plugin.ts b/packages/cloudflare-vite-plugin/src/dev-plugin.ts index d44244dc..2bab1038 100644 --- a/packages/cloudflare-vite-plugin/src/dev-plugin.ts +++ b/packages/cloudflare-vite-plugin/src/dev-plugin.ts @@ -34,8 +34,7 @@ export function dev(options: CloudflareVitePluginOptions): vite.Plugin { createEnvironment(name: string, config: vite.ResolvedConfig) { const hasConfigureServer = config.plugins.some( (plugin) => - plugin.name === "distilled-cloudflare:dev" && - plugin.configureServer !== undefined, + plugin.name === "distilled-cloudflare:dev" && plugin.configureServer !== undefined, ); if (!hasConfigureServer) { return vite.createRunnableDevEnvironment(name, config); From 85595d8e04da62d9094d4990ae4c8872a740deb7 Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 15 Jun 2026 23:51:10 +0200 Subject: [PATCH 10/11] fix(vite-plugin): serve Vite assets in dev --- fixtures/static-website/src/server.ts | 8 ++- fixtures/static-website/test/dev.test.ts | 49 +++++++++++++++++++ fixtures/static-website/vite.config.ts | 13 ++++- .../cloudflare-runtime/src/PluginContext.ts | 6 ++- packages/cloudflare-runtime/src/Runtime.ts | 7 ++- .../src/bindings/assets/Assets.ts | 4 +- .../cloudflare-runtime/test/Plugin.test.ts | 49 +++++++++++++++++++ .../test/bindings/Assets.test.ts | 20 +++++++- .../src/assets/ViteAssets.ts | 2 +- .../cloudflare-vite-plugin/src/dev-server.ts | 18 +++++-- 10 files changed, 164 insertions(+), 12 deletions(-) create mode 100644 fixtures/static-website/test/dev.test.ts diff --git a/fixtures/static-website/src/server.ts b/fixtures/static-website/src/server.ts index 47ae6e97..8771f28c 100644 --- a/fixtures/static-website/src/server.ts +++ b/fixtures/static-website/src/server.ts @@ -1,5 +1,9 @@ export default { - async fetch(_: Request) { - return new Response("Hello World"); + async fetch(request: Request) { + const url = new URL(request.url); + if (url.pathname.startsWith("/api/")) { + return Response.json({ name: "Cloudflare" }); + } + return new Response(null, { status: 404 }); }, }; diff --git a/fixtures/static-website/test/dev.test.ts b/fixtures/static-website/test/dev.test.ts new file mode 100644 index 00000000..871b8739 --- /dev/null +++ b/fixtures/static-website/test/dev.test.ts @@ -0,0 +1,49 @@ +import { expect, test } from "@playwright/test"; +import { spawn, type ChildProcess } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const fixtureDir = path.dirname(path.dirname(fileURLToPath(import.meta.url))); + +let proc: ChildProcess; +let baseUrl: string; + +test.beforeAll(async () => { + proc = spawn("bun", ["vite", "dev", "--host", "127.0.0.1", "--port", "3351"], { + cwd: fixtureDir, + stdio: ["ignore", "pipe", "pipe"], + }); + baseUrl = await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("dev server did not start in time")), 90_000); + const onData = (chunk: Buffer) => { + const match = String(chunk).match(/Local:\s+(http:\/\/\S+?)\/?\s/); + if (match) { + clearTimeout(timer); + resolve(match[1].replace(/\/$/, "")); + } + }; + proc.stdout?.on("data", onData); + proc.stderr?.on("data", onData); + proc.on("exit", (code) => reject(new Error(`dev server exited early (code ${code})`))); + }); +}); + +test.afterAll(() => { + proc?.kill("SIGTERM"); +}); + +test("dev server serves Vite HTML assets before falling through to the Worker", async ({ + request, +}) => { + const home = await request.get(`${baseUrl}/`); + expect(home.status()).toBe(200); + expect(await home.text()).toContain('/src/main.ts"'); + + const html = await request.get(`${baseUrl}/index.html`); + expect(html.status()).toBe(200); + expect(await html.text()).toContain('/src/main.ts"'); + + const api = await request.get(`${baseUrl}/api/test`); + expect(api.status()).toBe(200); + expect(await api.json()).toEqual({ name: "Cloudflare" }); +}); diff --git a/fixtures/static-website/vite.config.ts b/fixtures/static-website/vite.config.ts index 82edbf46..6853ab00 100644 --- a/fixtures/static-website/vite.config.ts +++ b/fixtures/static-website/vite.config.ts @@ -2,7 +2,18 @@ import cloudflare from "@distilled.cloud/cloudflare-vite-plugin"; import { defineConfig } from "vite"; const config = defineConfig({ - plugins: [cloudflare({})], + plugins: [ + cloudflare({ + main: "./src/server.ts", + compatibilityDate: "2025-09-27", + worker: { + name: "fixtures-static-website", + assets: { + notFoundHandling: "single-page-application", + }, + }, + }), + ], }); export default config; diff --git a/packages/cloudflare-runtime/src/PluginContext.ts b/packages/cloudflare-runtime/src/PluginContext.ts index 5a09742b..d25627d9 100644 --- a/packages/cloudflare-runtime/src/PluginContext.ts +++ b/packages/cloudflare-runtime/src/PluginContext.ts @@ -60,7 +60,11 @@ export const make = ( const services = configs.flatMap((config) => config.services ?? []); const sockets = configs.flatMap((config) => config.sockets ?? []); const extensions = configs.flatMap((config) => config.extensions ?? []); - const middlewares = configs.flatMap((config) => config.middlewares ?? []); + const middlewareOrder = (middleware: Plugin.Middleware) => + middleware.name === "plugin:entry" ? 1 : 0; + const middlewares = configs + .flatMap((config) => config.middlewares ?? []) + .sort((a, b) => middlewareOrder(a) - middlewareOrder(b)); return { entry: middlewares[0]?.name, sockets, diff --git a/packages/cloudflare-runtime/src/Runtime.ts b/packages/cloudflare-runtime/src/Runtime.ts index 1b601317..e18eda9a 100644 --- a/packages/cloudflare-runtime/src/Runtime.ts +++ b/packages/cloudflare-runtime/src/Runtime.ts @@ -35,9 +35,14 @@ export const RuntimeLive = Layer.effect( const workerd = yield* Workerd.Workerd; const storage = yield* Storage.Storage; const docker = yield* Docker.Docker; - const plugins = yield* PluginContext.pickPluginsFromContext(); + const basePlugins = yield* PluginContext.pickPluginsFromContext(); const preparePlugins = Effect.fnUntraced(function* (worker: RuntimeWorker) { + const plugins = new Map(basePlugins); + const overrides = yield* PluginContext.pickPluginsFromContext(); + for (const [key, builder] of overrides) { + plugins.set(key, builder); + } const context = yield* PluginContext.make(worker as RuntimeWorker, plugins); const bindings = yield* Effect.all(worker.bindings as ReadonlyArray>, { concurrency: "unbounded", diff --git a/packages/cloudflare-runtime/src/bindings/assets/Assets.ts b/packages/cloudflare-runtime/src/bindings/assets/Assets.ts index 0989ccfe..618f303d 100644 --- a/packages/cloudflare-runtime/src/bindings/assets/Assets.ts +++ b/packages/cloudflare-runtime/src/bindings/assets/Assets.ts @@ -274,7 +274,7 @@ export const AssetsLive = Layer.effect( }, }, ], - middleware: [ + middlewares: [ { name: "assets:router", worker: { @@ -332,7 +332,7 @@ export const buildAssetConfigs = ( staticRouting = parseStaticRouting(worker.assets.runWorkerFirst); } const routerConfig: RouterConfig = { - invoke_user_worker_ahead_of_assets: worker.assets?.runWorkerFirst !== false, + invoke_user_worker_ahead_of_assets: worker.assets?.runWorkerFirst === true, static_routing: staticRouting, has_user_worker: true, }; diff --git a/packages/cloudflare-runtime/test/Plugin.test.ts b/packages/cloudflare-runtime/test/Plugin.test.ts index 56c71cc5..aabac49d 100644 --- a/packages/cloudflare-runtime/test/Plugin.test.ts +++ b/packages/cloudflare-runtime/test/Plugin.test.ts @@ -92,6 +92,55 @@ describe("Plugin / PluginContext", () => { }), ); + it.effect("keeps plugin:entry as the innermost middleware before the user worker", () => + Effect.gen(function* () { + class Outer extends Plugin.Service()("cloudflare-runtime/plugin/Outer") {} + class Entry extends Plugin.Service()("cloudflare-runtime/plugin/Entry") {} + const outerLayer = Layer.succeed( + Outer, + Outer.of({ + middlewares: [ + { + name: "assets:router", + worker: { compatibilityDate: "2026-03-10" }, + upstreamBindingName: "USER_WORKER", + }, + ], + }), + ); + const entryLayer = Layer.succeed( + Entry, + Entry.of({ + middlewares: [ + { + name: "plugin:entry", + worker: { compatibilityDate: "2026-03-10" }, + upstreamBindingName: "USER_WORKER", + }, + ], + }), + ); + const ctx = yield* PluginContext.make(makeWorker()).pipe( + Effect.provide(Layer.mergeAll(entryLayer, outerLayer)), + ); + const config = yield* ctx.config; + expect(config.entry).toBe("assets:router"); + + const router = config.services.find((s) => s.name === "assets:router") as { + worker: { bindings: Array }; + }; + const entry = config.services.find((s) => s.name === "plugin:entry") as { + worker: { bindings: Array }; + }; + expect(router.worker.bindings.find((b) => b.name === "USER_WORKER")?.service?.name).toBe( + "plugin:entry", + ); + expect(entry.worker.bindings.find((b) => b.name === "USER_WORKER")?.service?.name).toBe( + SERVICE_USER_WORKER, + ); + }), + ); + it.effect("Plugin.useSync reads a plugin field synchronously", () => Effect.gen(function* () { const ctx = yield* PluginContext.make(makeWorker()); diff --git a/packages/cloudflare-runtime/test/bindings/Assets.test.ts b/packages/cloudflare-runtime/test/bindings/Assets.test.ts index 74eb12f0..260e9e55 100644 --- a/packages/cloudflare-runtime/test/bindings/Assets.test.ts +++ b/packages/cloudflare-runtime/test/bindings/Assets.test.ts @@ -66,7 +66,7 @@ describe("Assets / buildAssetConfigs", () => { }, }); expect(routerConfig).toMatchObject({ - invoke_user_worker_ahead_of_assets: true, + invoke_user_worker_ahead_of_assets: false, has_user_worker: true, }); expect(routerConfig.static_routing).toBeDefined(); @@ -86,4 +86,22 @@ describe("Assets / buildAssetConfigs", () => { }); expect(routerConfig.invoke_user_worker_ahead_of_assets).toBe(false); }); + + it("defaults to assets-first routing when runWorkerFirst is not specified", () => { + const { routerConfig } = Assets.buildAssetConfigs({ + compatibilityDate: "2026-03-10", + compatibilityFlags: [], + assets: { directory: "/tmp/x" }, + }); + expect(routerConfig.invoke_user_worker_ahead_of_assets).toBe(false); + }); + + it("routes every request to the user worker when runWorkerFirst is true", () => { + const { routerConfig } = Assets.buildAssetConfigs({ + compatibilityDate: "2026-03-10", + compatibilityFlags: [], + assets: { directory: "/tmp/x", runWorkerFirst: true }, + }); + expect(routerConfig.invoke_user_worker_ahead_of_assets).toBe(true); + }); }); diff --git a/packages/cloudflare-vite-plugin/src/assets/ViteAssets.ts b/packages/cloudflare-vite-plugin/src/assets/ViteAssets.ts index 42543905..aa4210a9 100644 --- a/packages/cloudflare-vite-plugin/src/assets/ViteAssets.ts +++ b/packages/cloudflare-vite-plugin/src/assets/ViteAssets.ts @@ -82,7 +82,7 @@ export const ViteAssetsLive = (viteDevServer: vite.ViteDevServer) => }, }, ], - middleware: [ + middlewares: [ { name: "assets:router", worker: { diff --git a/packages/cloudflare-vite-plugin/src/dev-server.ts b/packages/cloudflare-vite-plugin/src/dev-server.ts index efee3e90..d0ffcfc2 100644 --- a/packages/cloudflare-vite-plugin/src/dev-server.ts +++ b/packages/cloudflare-vite-plugin/src/dev-server.ts @@ -1,6 +1,7 @@ import { workerEnvironments } from "@distilled.cloud/cloudflare-rolldown-plugin/options"; import type { BindingHooks, Module, RuntimeServices } from "@distilled.cloud/cloudflare-runtime"; import { layerRuntime, Runtime } from "@distilled.cloud/cloudflare-runtime"; +import { RuntimeLive } from "@distilled.cloud/cloudflare-runtime/Runtime"; import { DurableObjectNamespace, Json, @@ -8,7 +9,7 @@ import { UnsafeEval, } from "@distilled.cloud/cloudflare-runtime/bindings"; import * as Credentials from "@distilled.cloud/cloudflare/Credentials"; -import type * as Context from "effect/Context"; +import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; @@ -37,9 +38,20 @@ export const startServer = async ( context: Context.Context, ) => { const scope = Scope.makeUnsafe(); - const address = await serve(options, entry, server).pipe( - Effect.provide(ViteAssets.ViteAssetsLive(server)), + const assetsContext = await ViteAssets.ViteAssetsLive(server).pipe( + Layer.buildWithScope(scope), Effect.provide(context), + Effect.runPromise, + ); + const contextWithAssets = Context.merge(context, assetsContext); + const runtimeContext = await RuntimeLive.pipe( + Layer.buildWithScope(scope), + Effect.provideContext(contextWithAssets), + Effect.runPromise, + ); + const devContext = Context.merge(contextWithAssets, runtimeContext); + const address = await serve(options, entry, server).pipe( + Effect.provideContext(devContext), Scope.provide(scope), Effect.runPromise, ); From afe8e395476cf43df61c8852c569cab3d783faaf Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 15 Jun 2026 23:52:55 +0200 Subject: [PATCH 11/11] test(fixtures): keep static build assets-only by default --- fixtures/static-website/test/dev.test.ts | 1 + fixtures/static-website/vite.config.ts | 26 +++++++++++++++--------- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/fixtures/static-website/test/dev.test.ts b/fixtures/static-website/test/dev.test.ts index 871b8739..4e938e4e 100644 --- a/fixtures/static-website/test/dev.test.ts +++ b/fixtures/static-website/test/dev.test.ts @@ -11,6 +11,7 @@ let baseUrl: string; test.beforeAll(async () => { proc = spawn("bun", ["vite", "dev", "--host", "127.0.0.1", "--port", "3351"], { cwd: fixtureDir, + env: { ...process.env, DISTILLED_STATIC_WEBSITE_WORKER: "1" }, stdio: ["ignore", "pipe", "pipe"], }); baseUrl = await new Promise((resolve, reject) => { diff --git a/fixtures/static-website/vite.config.ts b/fixtures/static-website/vite.config.ts index 6853ab00..58324733 100644 --- a/fixtures/static-website/vite.config.ts +++ b/fixtures/static-website/vite.config.ts @@ -1,18 +1,24 @@ import cloudflare from "@distilled.cloud/cloudflare-vite-plugin"; import { defineConfig } from "vite"; +const enableTestWorker = process.env.DISTILLED_STATIC_WEBSITE_WORKER === "1"; + const config = defineConfig({ plugins: [ - cloudflare({ - main: "./src/server.ts", - compatibilityDate: "2025-09-27", - worker: { - name: "fixtures-static-website", - assets: { - notFoundHandling: "single-page-application", - }, - }, - }), + cloudflare( + enableTestWorker + ? { + main: "./src/server.ts", + compatibilityDate: "2025-09-27", + worker: { + name: "fixtures-static-website", + assets: { + notFoundHandling: "single-page-application", + }, + }, + } + : {}, + ), ], });