diff --git a/integration/vite-hmr-hdr-rsc-test.ts b/integration/vite-hmr-hdr-rsc-test.ts
new file mode 100644
index 0000000000..0fd031af35
--- /dev/null
+++ b/integration/vite-hmr-hdr-rsc-test.ts
@@ -0,0 +1,431 @@
+import fs from "node:fs/promises";
+import path from "node:path";
+import { expect } from "@playwright/test";
+
+import type { Files, TemplateName } from "./helpers/vite.js";
+import {
+ test,
+ createEditor,
+ viteConfig,
+ reactRouterConfig,
+} from "./helpers/vite.js";
+
+const templateName = "rsc-vite-framework" as const satisfies TemplateName;
+
+test.describe("Vite HMR & HDR (RSC)", () => {
+ test("vite dev", async ({ page, dev }) => {
+ let files: Files = async ({ port }) => ({
+ "vite.config.js": await viteConfig.basic({ port, templateName }),
+ "react-router.config.ts": reactRouterConfig({
+ viteEnvironmentApi: templateName.includes("rsc"),
+ }),
+ "app/routes/hmr/route.tsx": `
+ // imports
+ import { Mounted } from "./route.client";
+
+ // loader
+
+ export function ServerComponent() {
+ return (
+
+
Index
+
+
+
HMR updated: 0
+ {/* elements */}
+
+ );
+ }
+ `,
+ "app/routes/hmr/route.client.tsx": `
+ "use client";
+
+ import { useState, useEffect } from "react";
+
+ export function Mounted() {
+ const [mounted, setMounted] = useState(false);
+ useEffect(() => {
+ setMounted(true);
+ }, []);
+
+ return Mounted: {mounted ? "yes" : "no"}
;
+ }
+ `,
+ });
+ let { cwd, port } = await dev(files, templateName);
+ let edit = createEditor(cwd);
+
+ // setup: initial render
+ await page.goto(`http://localhost:${port}/hmr`, {
+ waitUntil: "networkidle",
+ });
+ await expect(page.locator("#index [data-title]")).toHaveText("Index");
+
+ // setup: hydration
+ await expect(page.locator("#index [data-mounted]")).toHaveText(
+ "Mounted: yes",
+ );
+
+ // setup: browser state
+ let hmrStatus = page.locator("#index [data-hmr]");
+
+ await expect(hmrStatus).toHaveText("HMR updated: 0");
+ let input = page.locator("#index input");
+ await expect(input).toBeVisible();
+ await input.type("stateful");
+ expect(page.errors).toEqual([]);
+
+ // route: HMR
+ await edit("app/routes/hmr/route.tsx", (contents) =>
+ contents.replace("HMR updated: 0", "HMR updated: 1"),
+ );
+ await page.waitForLoadState("networkidle");
+
+ await expect(hmrStatus).toHaveText("HMR updated: 1");
+ await expect(input).toHaveValue("stateful");
+ expect(page.errors).toEqual([]);
+
+ // route: add loader
+ await edit("app/routes/hmr/route.tsx", (contents) =>
+ contents
+ .replace(
+ "// loader",
+ `// loader\nexport const loader = () => ({ message: "HDR updated: 0" });`,
+ )
+ .replace(
+ "export function ServerComponent() {",
+ `export function ServerComponent({ loaderData }: { loaderData: { message: string } }) {`,
+ )
+ .replace(
+ "{/* elements */}",
+ `{/* elements */}\n{loaderData.message}
`,
+ ),
+ );
+ await page.waitForLoadState("networkidle");
+ let hdrStatus = page.locator("#index [data-hdr]");
+ await expect(hdrStatus).toHaveText("HDR updated: 0");
+ await expect(input).toHaveValue("stateful");
+ expect(page.errors).toEqual([]);
+
+ // route: HDR
+ await edit("app/routes/hmr/route.tsx", (contents) =>
+ contents.replace("HDR updated: 0", "HDR updated: 1"),
+ );
+ await page.waitForLoadState("networkidle");
+ await expect(hdrStatus).toHaveText("HDR updated: 1");
+ await expect(input).toHaveValue("stateful");
+ expect(page.errors).toEqual([]);
+
+ // route: HMR + HDR
+ await edit("app/routes/hmr/route.tsx", (contents) =>
+ contents
+ .replace("HMR updated: 1", "HMR updated: 2")
+ .replace("HDR updated: 1", "HDR updated: 2"),
+ );
+ await page.waitForLoadState("networkidle");
+ await expect(hmrStatus).toHaveText("HMR updated: 2");
+ await expect(hdrStatus).toHaveText("HDR updated: 2");
+ await expect(input).toHaveValue("stateful");
+ expect(page.errors).toEqual([]);
+
+ // create new non-route imported server component
+ await fs.writeFile(
+ path.join(cwd, "app/imported-server-component.tsx"),
+ `
+ import { ImportedServerComponentClientMounted } from "./imported-server-component-client";
+
+ export function ImportedServerComponent() {
+ return (
+
+
Imported Server Component HMR: 0
+
+
+ );
+ }
+ `,
+ "utf8",
+ );
+ await fs.writeFile(
+ path.join(cwd, "app/imported-server-component-client.tsx"),
+ `
+ "use client";
+
+ import { useState, useEffect } from "react";
+
+ export function ImportedServerComponentClientMounted() {
+ const [mounted, setMounted] = useState(false);
+ useEffect(() => {
+ setMounted(true);
+ }, []);
+
+ return (
+
+ Imported Server Component Client Mounted: {mounted ? "yes" : "no"}
+
+ );
+ }
+ `,
+ "utf8",
+ );
+ await edit("app/routes/hmr/route.tsx", (contents) =>
+ contents
+ .replace(
+ "// imports",
+ `// imports\nimport { ImportedServerComponent } from "../../imported-server-component";`,
+ )
+ .replace(
+ "{/* elements */}",
+ "{/* elements */}\n",
+ ),
+ );
+ await page.waitForLoadState("networkidle");
+ let serverComponent = page.locator(
+ "#index [data-imported-server-component]",
+ );
+ let importedServerComponentClientMounted = page.locator(
+ "#index [data-imported-server-component-client-mounted]",
+ );
+ await expect(serverComponent).toBeVisible();
+ await expect(serverComponent).toHaveText(
+ "Imported Server Component HMR: 0",
+ );
+ await expect(importedServerComponentClientMounted).toBeVisible();
+ await expect(importedServerComponentClientMounted).toHaveText(
+ "Imported Server Component Client Mounted: yes",
+ );
+ await expect(input).toHaveValue("stateful");
+ expect(page.errors).toEqual([]);
+
+ // non-route imported server component: HMR
+ await edit("app/imported-server-component.tsx", (contents) =>
+ contents.replace(
+ "Imported Server Component HMR: 0",
+ "Imported Server Component HMR: 1",
+ ),
+ );
+ await page.waitForLoadState("networkidle");
+ await expect(serverComponent).toHaveText(
+ "Imported Server Component HMR: 1",
+ );
+ await expect(input).toHaveValue("stateful");
+ expect(page.errors).toEqual([]);
+
+ // create new non-route imported client component
+ await fs.writeFile(
+ path.join(cwd, "app/imported-client-component.tsx"),
+ `
+ "use client";
+
+ import { useState } from "react";
+
+ export function ImportedClientComponent() {
+ const [count, setCount] = useState(0);
+ return (
+
+
Imported Client Component HMR: 0
+
+
+ );
+ }
+ `,
+ "utf8",
+ );
+ await edit("app/routes/hmr/route.tsx", (contents) =>
+ contents
+ .replace(
+ "// imports",
+ `// imports\nimport { ImportedClientComponent } from "../../imported-client-component";`,
+ )
+ .replace(
+ "{/* elements */}",
+ "{/* elements */}\n",
+ ),
+ );
+ await page.waitForLoadState("networkidle");
+ let clientComponent = page.locator(
+ "#index [data-imported-client-component]",
+ );
+ let clientButton = page.locator(
+ "#index [data-imported-client-component-button]",
+ );
+ await expect(clientComponent).toBeVisible();
+ await expect(clientComponent).toHaveText(
+ "Imported Client Component HMR: 0",
+ );
+ await expect(clientButton).toHaveText("Count: 0");
+ await expect(input).toHaveValue("stateful");
+ expect(page.errors).toEqual([]);
+
+ // non-route imported client component: HMR
+ await edit("app/imported-client-component.tsx", (contents) =>
+ contents.replace(
+ "Imported Client Component HMR: 0",
+ "Imported Client Component HMR: 1",
+ ),
+ );
+ await page.waitForLoadState("networkidle");
+ await expect(clientComponent).toHaveText(
+ "Imported Client Component HMR: 1",
+ );
+ await expect(clientButton).toHaveText("Count: 0");
+ await expect(input).toHaveValue("stateful");
+ expect(page.errors).toEqual([]);
+
+ // non-route imported client component: state preservation
+ await clientButton.click();
+ await expect(clientButton).toHaveText("Count: 1");
+ await edit("app/imported-client-component.tsx", (contents) =>
+ contents.replace(
+ "Imported Client Component HMR: 1",
+ "Imported Client Component HMR: 2",
+ ),
+ );
+ await page.waitForLoadState("networkidle");
+ await expect(clientComponent).toHaveText(
+ "Imported Client Component HMR: 2",
+ );
+ await expect(clientButton).toHaveText("Count: 1");
+ await expect(input).toHaveValue("stateful");
+ expect(page.errors).toEqual([]);
+
+ // create new non-route server module
+ await fs.writeFile(
+ path.join(cwd, "app/indirect-hdr-dep.ts"),
+ `export const indirect = "indirect 0"`,
+ "utf8",
+ );
+ await fs.writeFile(
+ path.join(cwd, "app/direct-hdr-dep.ts"),
+ `
+ import { indirect } from "./indirect-hdr-dep"
+ export const direct = "direct 0 & " + indirect
+ `,
+ "utf8",
+ );
+ await edit("app/routes/hmr/route.tsx", (contents) =>
+ contents
+ .replace(
+ "// imports",
+ `// imports\nimport { direct } from "../../direct-hdr-dep"`,
+ )
+ .replace(
+ `{ message: "HDR updated: 2" }`,
+ `{ message: "HDR updated: " + direct }`,
+ )
+ .replace(`HDR updated: 2`, `HDR updated: direct 0 & indirect 0`),
+ );
+ await page.waitForLoadState("networkidle");
+ await expect(hdrStatus).toHaveText("HDR updated: direct 0 & indirect 0");
+ await expect(input).toHaveValue("stateful");
+ expect(page.errors).toEqual([]);
+
+ // non-route: HDR for direct dependency
+ await edit("app/direct-hdr-dep.ts", (contents) =>
+ contents.replace("direct 0 &", "direct 1 &"),
+ );
+ await page.waitForLoadState("networkidle");
+ await expect(hdrStatus).toHaveText("HDR updated: direct 1 & indirect 0");
+ await expect(input).toHaveValue("stateful");
+ expect(page.errors).toEqual([]);
+
+ // non-route: HDR for indirect dependency
+ await edit("app/indirect-hdr-dep.ts", (contents) =>
+ contents.replace("indirect 0", "indirect 1"),
+ );
+ await page.waitForLoadState("networkidle");
+ await expect(hdrStatus).toHaveText("HDR updated: direct 1 & indirect 1");
+ await expect(input).toHaveValue("stateful");
+ expect(page.errors).toEqual([]);
+
+ // everything everywhere all at once
+ await Promise.all([
+ edit("app/routes/hmr/route.tsx", (contents) =>
+ contents
+ .replace("HMR updated: 2", "HMR updated: 3")
+ .replace("HDR updated: ", "HDR updated: route & "),
+ ),
+ edit("app/imported-server-component.tsx", (contents) =>
+ contents.replace(
+ "Imported Server Component HMR: 1",
+ "Imported Server Component HMR: 2",
+ ),
+ ),
+ edit("app/imported-client-component.tsx", (contents) =>
+ contents.replace(
+ "Imported Client Component HMR: 2",
+ "Imported Client Component HMR: 3",
+ ),
+ ),
+ edit("app/direct-hdr-dep.ts", (contents) =>
+ contents.replace("direct 1 &", "direct 2 &"),
+ ),
+ edit("app/indirect-hdr-dep.ts", (contents) =>
+ contents.replace("indirect 1", "indirect 2"),
+ ),
+ ]);
+ await page.waitForLoadState("networkidle");
+ await expect(hmrStatus).toHaveText("HMR updated: 3");
+ await expect(serverComponent).toHaveText(
+ "Imported Server Component HMR: 2",
+ );
+ await expect(clientComponent).toHaveText(
+ "Imported Client Component HMR: 3",
+ );
+ await expect(clientButton).toHaveText("Count: 1");
+ await expect(hdrStatus).toHaveText(
+ "HDR updated: route & direct 2 & indirect 2",
+ );
+ await expect(input).toHaveValue("stateful");
+ expect(page.errors).toEqual([]);
+
+ // switch from server-first to client route
+ await edit("app/routes/hmr/route.tsx", (contents) =>
+ contents
+ .replace(
+ "export function ServerComponent",
+ "export default function ClientComponent",
+ )
+ .replace("HMR updated: 3", "Client Route HMR: 0"),
+ );
+ await page.waitForLoadState("networkidle");
+ await expect(hmrStatus).toHaveText("Client Route HMR: 0");
+ // adding/removing client component exports causes an HMR invalidation and a
+ // page reload. some browsers maintain input state, so we forcibly clear
+ await input.clear();
+ await input.type("client stateful");
+ expect(page.errors).toEqual([]);
+ await edit("app/routes/hmr/route.tsx", (contents) =>
+ contents.replace("Client Route HMR: 0", "Client Route HMR: 1"),
+ );
+ await page.waitForLoadState("networkidle");
+ await expect(hmrStatus).toHaveText("Client Route HMR: 1");
+ await expect(input).toHaveValue("client stateful");
+ expect(page.errors).toEqual([]);
+
+ // switch from client route back to server-first route
+ await edit("app/routes/hmr/route.tsx", (contents) =>
+ contents
+ .replace(
+ "export default function ClientComponent",
+ "export function ServerComponent",
+ )
+ .replace("Client Route HMR: 1", "Server Route HMR: 0"),
+ );
+ await page.waitForLoadState("networkidle");
+ await expect(hmrStatus).toHaveText("Server Route HMR: 0");
+ // adding/removing client component exports causes an HMR invalidation and a
+ // page reload. some browsers maintain input state, so we forcibly clear
+ await input.clear();
+ await input.type("server stateful");
+ expect(page.errors).toEqual([]);
+ await edit("app/routes/hmr/route.tsx", (contents) =>
+ contents.replace("Server Route HMR: 0", "Server Route HMR: 1"),
+ );
+ await page.waitForLoadState("networkidle");
+ await expect(hmrStatus).toHaveText("Server Route HMR: 1");
+ await expect(input).toHaveValue("server stateful");
+ expect(page.errors).toEqual([]);
+ });
+});
diff --git a/integration/vite-hmr-hdr-test.ts b/integration/vite-hmr-hdr-test.ts
index d830894477..bd5b6bec49 100644
--- a/integration/vite-hmr-hdr-test.ts
+++ b/integration/vite-hmr-hdr-test.ts
@@ -3,15 +3,27 @@ import path from "node:path";
import type { Page, PlaywrightWorkerOptions } from "@playwright/test";
import { expect } from "@playwright/test";
-import type { Files } from "./helpers/vite.js";
+import type { Files, TemplateName } from "./helpers/vite.js";
import {
test,
createEditor,
EXPRESS_SERVER,
viteConfig,
viteMajorTemplates,
+ reactRouterConfig,
} from "./helpers/vite.js";
+const templates = [
+ ...viteMajorTemplates,
+ {
+ templateName: "rsc-vite-framework",
+ templateDisplayName: "RSC Framework Mode",
+ },
+] as const satisfies ReadonlyArray<{
+ templateName: TemplateName;
+ templateDisplayName: string;
+}>;
+
const indexRoute = `
// imports
import { useState, useEffect } from "react";
@@ -40,28 +52,36 @@ const indexRoute = `
`;
test.describe("Vite HMR & HDR", () => {
- viteMajorTemplates.forEach(({ templateName, templateDisplayName }) => {
+ templates.forEach(({ templateName, templateDisplayName }) => {
test.describe(templateDisplayName, () => {
test("vite dev", async ({ page, browserName, dev }) => {
let files: Files = async ({ port }) => ({
- "vite.config.js": await viteConfig.basic({ port }),
+ "vite.config.js": await viteConfig.basic({ port, templateName }),
+ "react-router.config.ts": reactRouterConfig({
+ viteEnvironmentApi: templateName.includes("rsc"),
+ }),
"app/routes/_index.tsx": indexRoute,
});
let { cwd, port } = await dev(files, templateName);
- await workflow({ page, browserName, cwd, port });
+ await workflow({ templateName, page, browserName, cwd, port });
});
test("express", async ({ page, browserName, customDev }) => {
+ test.skip(templateName.includes("rsc"), "RSC is not supported");
let files: Files = async ({ port }) => ({
- "vite.config.js": await viteConfig.basic({ port }),
+ "vite.config.js": await viteConfig.basic({ port, templateName }),
+ "react-router.config.ts": reactRouterConfig({
+ viteEnvironmentApi: templateName.includes("rsc"),
+ }),
"server.mjs": EXPRESS_SERVER({ port }),
"app/routes/_index.tsx": indexRoute,
});
let { cwd, port } = await customDev(files, templateName);
- await workflow({ page, browserName, cwd, port });
+ await workflow({ templateName, page, browserName, cwd, port });
});
test("mdx", async ({ page, dev }) => {
+ test.skip(templateName.includes("rsc"), "RSC is not supported");
let files: Files = async ({ port }) => ({
"vite.config.ts": `
import { defineConfig } from "vite";
@@ -120,11 +140,13 @@ test.describe("Vite HMR & HDR", () => {
});
async function workflow({
+ templateName,
page,
browserName,
cwd,
port,
}: {
+ templateName: TemplateName;
page: Page;
browserName: PlaywrightWorkerOptions["browserName"];
cwd: string;
@@ -145,7 +167,12 @@ async function workflow({
// setup: browser state
let hmrStatus = page.locator("#index [data-hmr]");
- await expect(page).toHaveTitle("HMR updated title: 0");
+
+ // RSC doesn't support meta export?
+ if (!templateName.includes("rsc")) {
+ await expect(page).toHaveTitle("HMR updated title: 0");
+ }
+
await expect(hmrStatus).toHaveText("HMR updated: 0");
let input = page.locator("#index input");
await expect(input).toBeVisible();
@@ -159,7 +186,12 @@ async function workflow({
.replace("HMR updated: 0", "HMR updated: 1"),
);
await page.waitForLoadState("networkidle");
- await expect(page).toHaveTitle("HMR updated title: 1");
+
+ // RSC doesn't support meta export?
+ if (!templateName.includes("rsc")) {
+ await expect(page).toHaveTitle("HMR updated title: 1");
+ }
+
await expect(hmrStatus).toHaveText("HMR updated: 1");
await expect(input).toHaveValue("stateful");
expect(page.errors).toEqual([]);
diff --git a/packages/react-router-dev/config/default-rsc-entries/entry.client.tsx b/packages/react-router-dev/config/default-rsc-entries/entry.client.tsx
index be0fa76e80..740e2fc6bb 100644
--- a/packages/react-router-dev/config/default-rsc-entries/entry.client.tsx
+++ b/packages/react-router-dev/config/default-rsc-entries/entry.client.tsx
@@ -1,3 +1,5 @@
+import "virtual:react-router/unstable_rsc/inject-hmr-runtime";
+
import { startTransition, StrictMode } from "react";
import { hydrateRoot } from "react-dom/client";
import {
diff --git a/packages/react-router-dev/package.json b/packages/react-router-dev/package.json
index ee43e86487..2d00ecc3d0 100644
--- a/packages/react-router-dev/package.json
+++ b/packages/react-router-dev/package.json
@@ -78,7 +78,6 @@
"@babel/types": "^7.27.7",
"@npmcli/package-json": "^4.0.1",
"@react-router/node": "workspace:*",
- "@vitejs/plugin-react": "^4.5.2",
"@vitejs/plugin-rsc": "0.4.11",
"arg": "^5.0.1",
"babel-dead-code-elimination": "^1.0.6",
diff --git a/packages/react-router-dev/tsup.config.ts b/packages/react-router-dev/tsup.config.ts
index e13ddaf79e..b5f291b4c3 100644
--- a/packages/react-router-dev/tsup.config.ts
+++ b/packages/react-router-dev/tsup.config.ts
@@ -16,7 +16,11 @@ const entry = [
"vite/cloudflare.ts",
];
-const external = ["./static/refresh-utils.mjs", /\.json$/];
+const external = [
+ "./static/refresh-utils.mjs",
+ "./static/rsc-refresh-utils.mjs",
+ /\.json$/,
+];
export default defineConfig([
{
@@ -39,6 +43,16 @@ export default defineConfig([
"dist/static/refresh-utils.mjs",
);
+ await fsp.mkdir("dist/static", { recursive: true });
+ await fsp.copyFile(
+ "vite/static/refresh-utils.mjs",
+ "dist/static/refresh-utils.mjs",
+ );
+ await fsp.copyFile(
+ "vite/static/rsc-refresh-utils.mjs",
+ "dist/static/rsc-refresh-utils.mjs",
+ );
+
await fsp.mkdir("dist/config/defaults", { recursive: true });
const files = await fsp.readdir("config/defaults");
for (const file of files) {
diff --git a/packages/react-router-dev/vite/rsc/plugin.ts b/packages/react-router-dev/vite/rsc/plugin.ts
index d7f22fd8c3..f2854d12e5 100644
--- a/packages/react-router-dev/vite/rsc/plugin.ts
+++ b/packages/react-router-dev/vite/rsc/plugin.ts
@@ -1,30 +1,38 @@
import type * as Vite from "vite";
import rsc, { type RscPluginOptions } from "@vitejs/plugin-rsc";
-import react from "@vitejs/plugin-react";
import { init as initEsModuleLexer } from "es-module-lexer";
+import * as babel from "@babel/core";
import { create } from "../virtual-module";
import * as Typegen from "../../typegen";
import { readFileSync } from "fs";
-import { join, dirname } from "path";
+import { readFile } from "fs/promises";
+import path, { join, dirname } from "pathe";
import {
type ConfigLoader,
type ResolvedReactRouterConfig,
createConfigLoader,
} from "../../config/config";
-import { createVirtualRouteConfigCode } from "./virtual-route-config";
-import { transformVirtualRouteModules } from "./virtual-route-modules";
+import { createVirtualRouteConfig } from "./virtual-route-config";
+import {
+ transformVirtualRouteModules,
+ parseRouteExports,
+ CLIENT_NON_COMPONENT_EXPORTS,
+} from "./virtual-route-modules";
export function reactRouterRSCVitePlugin(): Vite.PluginOption[] {
let configLoader: ConfigLoader;
let config: ResolvedReactRouterConfig;
let typegenWatcherPromise: Promise | undefined;
+ let viteCommand: Vite.ConfigEnv["command"];
+ let routeIdByFile: Map | undefined;
return [
{
name: "react-router/rsc/config",
async config(viteUserConfig, { command, mode }) {
await initEsModuleLexer;
+ viteCommand = command;
const rootDirectory = getRootDirectory(viteUserConfig);
const watch = command === "serve";
@@ -35,11 +43,72 @@ export function reactRouterRSCVitePlugin(): Vite.PluginOption[] {
config = configResult.value;
return {
+ resolve: {
+ dedupe: [
+ // https://react.dev/warnings/invalid-hook-call-warning#duplicate-react
+ "react",
+ "react-dom",
+ // Avoid router duplicates since mismatching routers cause `Error:
+ // You must render this element inside a element`.
+ "react-router",
+ "react-router/dom",
+ "react-router-dom",
+ ],
+ },
+ optimizeDeps: {
+ esbuildOptions: {
+ jsx: "automatic",
+ },
+ include: [
+ // Pre-bundle React dependencies to avoid React duplicates,
+ // even if React dependencies are not direct dependencies.
+ // https://react.dev/warnings/invalid-hook-call-warning#duplicate-react
+ "react",
+ "react/jsx-runtime",
+ "react/jsx-dev-runtime",
+ "react-dom",
+ "react-dom/client",
+ ],
+ },
+ esbuild: {
+ jsx: "automatic",
+ jsxDev: viteCommand !== "build",
+ },
environments: {
client: { build: { outDir: "build/client" } },
rsc: { build: { outDir: "build/server" } },
ssr: { build: { outDir: "build/server/__ssr_build" } },
},
+ build: {
+ rollupOptions: {
+ // Copied from https://github.com/vitejs/vite-plugin-react/blob/c602225271d4acf462ba00f8d6d8a2e42492c5cd/packages/common/warning.ts
+ onwarn(warning, defaultHandler) {
+ if (
+ warning.code === "MODULE_LEVEL_DIRECTIVE" &&
+ (warning.message.includes("use client") ||
+ warning.message.includes("use server"))
+ ) {
+ return;
+ }
+ // https://github.com/vitejs/vite/issues/15012
+ if (
+ warning.code === "SOURCEMAP_ERROR" &&
+ warning.message.includes("resolve original location") &&
+ warning.pos === 0
+ ) {
+ return;
+ }
+ if (viteUserConfig.build?.rollupOptions?.onwarn) {
+ viteUserConfig.build.rollupOptions.onwarn(
+ warning,
+ defaultHandler,
+ );
+ } else {
+ defaultHandler(warning);
+ }
+ },
+ },
+ },
};
},
async buildEnd() {
@@ -77,26 +146,168 @@ export function reactRouterRSCVitePlugin(): Vite.PluginOption[] {
},
load(id) {
if (id === virtual.routeConfig.resolvedId) {
- return createVirtualRouteConfigCode({
+ const result = createVirtualRouteConfig({
appDirectory: config.appDirectory,
routeConfig: config.unstable_routeConfig,
});
+ routeIdByFile = result.routeIdByFile;
+ return result.code;
}
},
},
{
name: "react-router/rsc/virtual-route-modules",
transform(code, id) {
- return transformVirtualRouteModules({ code, id });
+ return transformVirtualRouteModules({ code, id, viteCommand });
+ },
+ },
+ {
+ name: "react-router/rsc/hmr/inject-runtime",
+ enforce: "pre",
+ resolveId(id) {
+ if (id === virtual.injectHmrRuntime.id) {
+ return virtual.injectHmrRuntime.resolvedId;
+ }
+ },
+ async load(id) {
+ if (id !== virtual.injectHmrRuntime.resolvedId) return;
+
+ return viteCommand === "serve"
+ ? [
+ `import RefreshRuntime from "${virtual.hmrRuntime.id}"`,
+ "RefreshRuntime.injectIntoGlobalHook(window)",
+ "window.$RefreshReg$ = () => {}",
+ "window.$RefreshSig$ = () => (type) => type",
+ "window.__vite_plugin_react_preamble_installed__ = true",
+ ].join("\n")
+ : "";
+ },
+ },
+ {
+ name: "react-router/rsc/hmr/runtime",
+ enforce: "pre",
+ resolveId(id) {
+ if (id === virtual.hmrRuntime.id) return virtual.hmrRuntime.resolvedId;
+ },
+ async load(id) {
+ if (id !== virtual.hmrRuntime.resolvedId) return;
+
+ const reactRefreshDir = path.dirname(
+ require.resolve("react-refresh/package.json"),
+ );
+ const reactRefreshRuntimePath = path.join(
+ reactRefreshDir,
+ "cjs/react-refresh-runtime.development.js",
+ );
+
+ return [
+ "const exports = {}",
+ await readFile(reactRefreshRuntimePath, "utf8"),
+ await readFile(
+ require.resolve("./static/rsc-refresh-utils.mjs"),
+ "utf8",
+ ),
+ "export default exports",
+ ].join("\n");
+ },
+ },
+ {
+ name: "react-router/rsc/hmr/react-refresh",
+ async transform(code, id, options) {
+ if (viteCommand !== "serve") return;
+ if (id.includes("/node_modules/")) return;
+
+ const filepath = id.split("?")[0];
+ const extensionsRE = /\.(jsx?|tsx?|mdx?)$/;
+ if (!extensionsRE.test(filepath)) return;
+
+ const devRuntime = "react/jsx-dev-runtime";
+ const ssr = options?.ssr === true;
+ const isJSX = filepath.endsWith("x");
+ const useFastRefresh = !ssr && (isJSX || code.includes(devRuntime));
+ if (!useFastRefresh) return;
+
+ const routeId = routeIdByFile?.get(filepath);
+ if (routeId !== undefined) {
+ return { code: addRefreshWrapper({ routeId, code, id }) };
+ }
+
+ const result = await babel.transformAsync(code, {
+ babelrc: false,
+ configFile: false,
+ filename: id,
+ sourceFileName: filepath,
+ parserOpts: {
+ sourceType: "module",
+ allowAwaitOutsideFunction: true,
+ },
+ plugins: [[require("react-refresh/babel"), { skipEnvCheck: true }]],
+ sourceMaps: true,
+ });
+ if (result === null) return;
+
+ code = result.code!;
+ const refreshContentRE = /\$Refresh(?:Reg|Sig)\$\(/;
+ if (refreshContentRE.test(code)) {
+ code = addRefreshWrapper({ code, id });
+ }
+ return { code, map: result.map };
+ },
+ },
+ {
+ name: "react-router/rsc/hmr/updates",
+ async hotUpdate(this, { server, file, modules }) {
+ if (this.environment.name !== "rsc") return;
+
+ const isServerOnlyChange =
+ (server.environments.client.moduleGraph.getModulesByFile(file)
+ ?.size ?? 0) === 0;
+
+ for (const mod of getModulesWithImporters(modules)) {
+ if (!mod.file) continue;
+
+ const normalizedPath = path.normalize(mod.file);
+ const routeId = routeIdByFile?.get(normalizedPath);
+ if (routeId !== undefined) {
+ const routeSource = await readFile(normalizedPath, "utf8");
+ const virtualRouteModuleCode = (
+ await server.environments.rsc.pluginContainer.transform(
+ routeSource,
+ `${normalizedPath}?route-module`,
+ )
+ ).code;
+ const { staticExports } = parseRouteExports(virtualRouteModuleCode);
+ const hasAction = staticExports.includes("action");
+ const hasComponent = staticExports.includes("default");
+ const hasErrorBoundary = staticExports.includes("ErrorBoundary");
+ const hasLoader = staticExports.includes("loader");
+
+ server.hot.send({
+ type: "custom",
+ event: "react-router:hmr",
+ data: {
+ routeId,
+ isServerOnlyChange,
+ hasAction,
+ hasComponent,
+ hasErrorBoundary,
+ hasLoader,
+ },
+ });
+ }
+ }
+
+ return modules;
},
},
- react(),
rsc({ entries: getRscEntries() }),
];
}
const virtual = {
routeConfig: create("unstable_rsc/routes"),
+ injectHmrRuntime: create("unstable_rsc/inject-hmr-runtime"),
+ hmrRuntime: create("unstable_rsc/runtime"),
};
function getRootDirectory(viteUserConfig: Vite.UserConfig) {
@@ -131,3 +342,84 @@ function getDevPackageRoot(): string {
}
throw new Error("Could not find package.json");
}
+
+function getModulesWithImporters(
+ modules: Vite.EnvironmentModuleNode[],
+): Set {
+ const visited = new Set();
+ const result = new Set();
+
+ function walk(module: Vite.EnvironmentModuleNode) {
+ if (visited.has(module)) return;
+
+ visited.add(module);
+ result.add(module);
+
+ for (const importer of module.importers) {
+ walk(importer);
+ }
+ }
+
+ for (const module of modules) {
+ walk(module);
+ }
+
+ return result;
+}
+
+function addRefreshWrapper({
+ routeId,
+ code,
+ id,
+}: {
+ routeId?: string;
+ code: string;
+ id: string;
+}): string {
+ const acceptExports =
+ routeId !== undefined ? CLIENT_NON_COMPONENT_EXPORTS : [];
+ return (
+ REACT_REFRESH_HEADER.replaceAll("__SOURCE__", JSON.stringify(id)) +
+ code +
+ REACT_REFRESH_FOOTER.replaceAll("__SOURCE__", JSON.stringify(id))
+ .replaceAll("__ACCEPT_EXPORTS__", JSON.stringify(acceptExports))
+ .replaceAll("__ROUTE_ID__", JSON.stringify(routeId))
+ );
+}
+
+const REACT_REFRESH_HEADER = `
+import RefreshRuntime from "${virtual.hmrRuntime.id}";
+
+const inWebWorker = typeof WorkerGlobalScope !== 'undefined' && self instanceof WorkerGlobalScope;
+let prevRefreshReg;
+let prevRefreshSig;
+
+if (import.meta.hot && !inWebWorker) {
+ if (!window.__vite_plugin_react_preamble_installed__) {
+ throw new Error(
+ "React Router Vite plugin can't detect preamble. Something is wrong."
+ );
+ }
+
+ prevRefreshReg = window.$RefreshReg$;
+ prevRefreshSig = window.$RefreshSig$;
+ window.$RefreshReg$ = (type, id) => {
+ RefreshRuntime.register(type, __SOURCE__ + " " + id)
+ };
+ window.$RefreshSig$ = RefreshRuntime.createSignatureFunctionForTransform;
+}`.replaceAll("\n", ""); // Header is all on one line so source maps aren't affected
+
+const REACT_REFRESH_FOOTER = `
+if (import.meta.hot && !inWebWorker) {
+ window.$RefreshReg$ = prevRefreshReg;
+ window.$RefreshSig$ = prevRefreshSig;
+ RefreshRuntime.__hmr_import(import.meta.url).then((currentExports) => {
+ RefreshRuntime.registerExportsForReactRefresh(__SOURCE__, currentExports);
+ import.meta.hot.accept((nextExports) => {
+ if (!nextExports) return;
+ __ROUTE_ID__ && window.__reactRouterRouteModuleUpdates.set(__ROUTE_ID__, nextExports);
+ const invalidateMessage = RefreshRuntime.validateRefreshBoundaryAndEnqueueUpdate(currentExports, nextExports, __ACCEPT_EXPORTS__);
+ if (invalidateMessage) import.meta.hot.invalidate(invalidateMessage);
+ });
+ });
+}`;
diff --git a/packages/react-router-dev/vite/rsc/virtual-route-config.ts b/packages/react-router-dev/vite/rsc/virtual-route-config.ts
index fd3687ac76..a9a4352b4a 100644
--- a/packages/react-router-dev/vite/rsc/virtual-route-config.ts
+++ b/packages/react-router-dev/vite/rsc/virtual-route-config.ts
@@ -1,13 +1,14 @@
-import path from "node:path";
+import path from "pathe";
import type { RouteConfigEntry } from "../../routes";
-export function createVirtualRouteConfigCode({
+export function createVirtualRouteConfig({
appDirectory,
routeConfig,
}: {
appDirectory: string;
routeConfig: RouteConfigEntry[];
-}) {
+}): { code: string; routeIdByFile: Map } {
+ let routeIdByFile = new Map();
let code = "export default [";
const closeRouteSymbol = Symbol("CLOSE_ROUTE");
@@ -23,15 +24,14 @@ export function createVirtualRouteConfigCode({
}
code += "{";
+ const routeFile = path.resolve(appDirectory, route.file);
+ const routeId = route.id || createRouteId(route.file, appDirectory);
+ routeIdByFile.set(routeFile, routeId);
code += `lazy: () => import(${JSON.stringify(
- `${path.resolve(appDirectory, route.file)}?route-module${
- route.id === "root" ? "&root-route=true" : ""
- }`,
+ `${routeFile}?route-module${routeId === "root" ? "&root-route=true" : ""}`,
)}),`;
- code += `id: ${JSON.stringify(
- route.id || createRouteId(route.file, appDirectory),
- )},`;
+ code += `id: ${JSON.stringify(routeId)},`;
if (typeof route.path === "string") {
code += `path: ${JSON.stringify(route.path)},`;
}
@@ -52,7 +52,7 @@ export function createVirtualRouteConfigCode({
code += "];\n";
- return code;
+ return { code, routeIdByFile };
}
function createRouteId(file: string, appDirectory: string) {
diff --git a/packages/react-router-dev/vite/rsc/virtual-route-modules.ts b/packages/react-router-dev/vite/rsc/virtual-route-modules.ts
index be7b232557..e3785edf58 100644
--- a/packages/react-router-dev/vite/rsc/virtual-route-modules.ts
+++ b/packages/react-router-dev/vite/rsc/virtual-route-modules.ts
@@ -1,3 +1,4 @@
+import type { ConfigEnv } from "vite";
import * as babel from "../babel";
import { parse as esModuleLexer } from "es-module-lexer";
import { removeExports } from "../remove-exports";
@@ -17,7 +18,7 @@ const COMPONENT_EXPORTS = [
"Layout",
] as const;
-const CLIENT_NON_COMPONENT_EXPORTS = [
+export const CLIENT_NON_COMPONENT_EXPORTS = [
"clientAction",
"clientLoader",
"unstable_clientMiddleware",
@@ -44,19 +45,23 @@ function isClientRouteExport(name: string): name is ClientRouteExport {
return CLIENT_ROUTE_EXPORTS_SET.has(name as ClientRouteExport);
}
+type ViteCommand = ConfigEnv["command"];
+
export function transformVirtualRouteModules({
id,
code,
+ viteCommand,
}: {
id: string;
code: string;
+ viteCommand: ViteCommand;
}) {
if (!id.includes("route-module")) {
return;
}
if (isVirtualRouteModuleId(id)) {
- return createVirtualRouteModuleCode({ id, code });
+ return createVirtualRouteModuleCode({ id, code, viteCommand });
}
if (isVirtualServerRouteModuleId(id)) {
@@ -64,18 +69,21 @@ export function transformVirtualRouteModules({
}
if (isVirtualClientRouteModuleId(id)) {
- return createVirtualClientRouteModuleCode({ id, code });
+ return createVirtualClientRouteModuleCode({ id, code, viteCommand });
}
}
async function createVirtualRouteModuleCode({
id,
code: routeSource,
+ viteCommand,
}: {
id: string;
code: string;
+ viteCommand: ViteCommand;
}) {
- const { staticExports, isServerFirstRoute } = parseRouteExports(routeSource);
+ const { staticExports, isServerFirstRoute, hasClientExports } =
+ parseRouteExports(routeSource);
const clientModuleId = getVirtualClientModuleId(id);
const serverModuleId = getVirtualServerModuleId(id);
@@ -91,6 +99,9 @@ async function createVirtualRouteModuleCode({
code += `export { ${staticExport} } from "${serverModuleId}";\n`;
}
}
+ if (viteCommand === "serve" && !hasClientExports) {
+ code += `export { __ensureClientRouteModuleForHMR } from "${clientModuleId}";\n`;
+ }
} else {
for (const staticExport of staticExports) {
if (isClientRouteExport(staticExport)) {
@@ -142,11 +153,14 @@ function createVirtualServerRouteModuleCode({
function createVirtualClientRouteModuleCode({
id,
code: routeSource,
+ viteCommand,
}: {
id: string;
code: string;
+ viteCommand: ViteCommand;
}) {
- const { staticExports, isServerFirstRoute } = parseRouteExports(routeSource);
+ const { staticExports, isServerFirstRoute, hasClientExports } =
+ parseRouteExports(routeSource);
const exportsToRemove = isServerFirstRoute
? [...SERVER_ONLY_ROUTE_EXPORTS, ...COMPONENT_EXPORTS]
: SERVER_ONLY_ROUTE_EXPORTS;
@@ -168,16 +182,24 @@ function createVirtualClientRouteModuleCode({
generatorResult.code += `}\n`;
}
+ if (viteCommand === "serve" && isServerFirstRoute && !hasClientExports) {
+ generatorResult.code += `\nexport const __ensureClientRouteModuleForHMR = true;`;
+ }
+
return generatorResult;
}
-function parseRouteExports(code: string) {
+export function parseRouteExports(code: string) {
const [, exportSpecifiers] = esModuleLexer(code);
const staticExports = exportSpecifiers.map(({ n: name }) => name);
+ const isServerFirstRoute = staticExports.some(
+ (staticExport) => staticExport === "ServerComponent",
+ );
return {
staticExports,
- isServerFirstRoute: staticExports.some(
- (staticExport) => staticExport === "ServerComponent",
+ isServerFirstRoute,
+ hasClientExports: staticExports.some(
+ isServerFirstRoute ? isClientNonComponentExport : isClientRouteExport,
),
};
}
diff --git a/packages/react-router-dev/vite/static/rsc-refresh-utils.mjs b/packages/react-router-dev/vite/static/rsc-refresh-utils.mjs
new file mode 100644
index 0000000000..6772b8cbaa
--- /dev/null
+++ b/packages/react-router-dev/vite/static/rsc-refresh-utils.mjs
@@ -0,0 +1,126 @@
+// adapted from https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/src/refreshUtils.js
+// This file gets injected into the browser as a part of the HMR runtime
+
+function debounce(fn, delay) {
+ let handle;
+ return () => {
+ clearTimeout(handle);
+ handle = setTimeout(fn, delay);
+ };
+}
+
+/* eslint-disable no-undef */
+const enqueueUpdate = debounce(async () => {
+ if (routeUpdates.size > 0) {
+ const routeUpdateByRouteId = new Map();
+ for (const routeUpdate of routeUpdates) {
+ const routeId = routeUpdate.routeId;
+ const routeModule = window.__reactRouterRouteModuleUpdates.get(routeId);
+ if (routeModule) {
+ routeUpdateByRouteId.set(routeId, { routeModule, ...routeUpdate });
+ }
+ }
+ routeUpdates.clear();
+ __reactRouterDataRouter._updateRoutesForHMR(routeUpdateByRouteId);
+ }
+
+ try {
+ window.__reactRouterHdrActive = true;
+ await __reactRouterDataRouter.revalidate();
+ } finally {
+ window.__reactRouterHdrActive = false;
+ }
+
+ exports.performReactRefresh();
+}, 16);
+
+// Taken from https://github.com/pmmmwh/react-refresh-webpack-plugin/blob/main/lib/runtime/RefreshUtils.js#L141
+// This allows to resister components not detected by SWC like styled component
+function registerExportsForReactRefresh(filename, moduleExports) {
+ for (const key in moduleExports) {
+ if (key === "__esModule") continue;
+ const exportValue = moduleExports[key];
+ if (exports.isLikelyComponentType(exportValue)) {
+ // 'export' is required to avoid key collision when renamed exports that
+ // shadow a local component name: https://github.com/vitejs/vite-plugin-react/issues/116
+ // The register function has an identity check to not register twice the same component,
+ // so this is safe to not used the same key here.
+ exports.register(exportValue, filename + " export " + key);
+ }
+ }
+}
+
+function validateRefreshBoundaryAndEnqueueUpdate(
+ prevExports,
+ nextExports,
+ // non-component exports that are handled by the framework (e.g. `meta` and `links` for route modules)
+ acceptExports = [],
+) {
+ if (
+ !predicateOnExport(
+ prevExports,
+ (key) => key in nextExports || acceptExports.includes(key),
+ )
+ ) {
+ return "Could not Fast Refresh (export removed)";
+ }
+ if (
+ !predicateOnExport(
+ nextExports,
+ (key) => key in prevExports || acceptExports.includes(key),
+ )
+ ) {
+ return "Could not Fast Refresh (new export)";
+ }
+
+ let hasExports = false;
+ const allExportsAreHandledOrUnchanged = predicateOnExport(
+ nextExports,
+ (key, value) => {
+ hasExports = true;
+ // React Router can handle additional exports (e.g. `meta` and `links`)
+ if (acceptExports.includes(key)) return true;
+ // React Fast Refresh can handle component exports
+ if (exports.isLikelyComponentType(value)) return true;
+ // Unchanged exports are implicitly handled
+ return prevExports[key] === nextExports[key];
+ },
+ );
+ if (hasExports && allExportsAreHandledOrUnchanged) {
+ enqueueUpdate();
+ } else {
+ return "Could not Fast Refresh. Learn more at https://github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-react#consistent-components-exports";
+ }
+}
+
+function predicateOnExport(moduleExports, predicate) {
+ for (const key in moduleExports) {
+ if (key === "__esModule") continue;
+ const desc = Object.getOwnPropertyDescriptor(moduleExports, key);
+ if (desc && desc.get) return false;
+ if (!predicate(key, moduleExports[key])) return false;
+ }
+ return true;
+}
+
+// Hides vite-ignored dynamic import so that Vite can skip analysis if no other
+// dynamic import is present (https://github.com/vitejs/vite/pull/12732)
+function __hmr_import(module) {
+ return import(/* @vite-ignore */ module);
+}
+
+const routeUpdates = new Set();
+window.__reactRouterRouteModuleUpdates = new Map();
+
+import.meta.hot.on("react-router:hmr", async (routeUpdate) => {
+ routeUpdates.add(routeUpdate);
+ if (routeUpdate.isServerOnlyChange) {
+ enqueueUpdate();
+ }
+});
+
+exports.__hmr_import = __hmr_import;
+exports.registerExportsForReactRefresh = registerExportsForReactRefresh;
+exports.validateRefreshBoundaryAndEnqueueUpdate =
+ validateRefreshBoundaryAndEnqueueUpdate;
+exports.enqueueUpdate = enqueueUpdate;
diff --git a/packages/react-router/lib/rsc/browser.tsx b/packages/react-router/lib/rsc/browser.tsx
index 82cd595044..c20a896a47 100644
--- a/packages/react-router/lib/rsc/browser.tsx
+++ b/packages/react-router/lib/rsc/browser.tsx
@@ -56,7 +56,7 @@ export type EncodeReplyFunction = (
type WindowWithRouterGlobals = Window &
typeof globalThis & {
- __router: DataRouter;
+ __reactRouterDataRouter: DataRouter;
__routerInitialized: boolean;
__routerActionID: number;
};
@@ -139,7 +139,7 @@ export function createCallServer({
return;
}
- globalVar.__router.navigate(payload.location, {
+ globalVar.__reactRouterDataRouter.navigate(payload.location, {
replace: payload.replace,
});
@@ -168,7 +168,7 @@ export function createCallServer({
window.location.href = rerender.location;
return;
}
- globalVar.__router.navigate(rerender.location, {
+ globalVar.__reactRouterDataRouter.navigate(rerender.location, {
replace: rerender.replace,
});
return;
@@ -176,7 +176,7 @@ export function createCallServer({
let lastMatch: RSCRouteManifest | undefined;
for (const match of rerender.matches) {
- globalVar.__router.patchRoutes(
+ globalVar.__reactRouterDataRouter.patchRoutes(
lastMatch?.id ?? null,
[createRouteFromServerManifest(match)],
true,
@@ -185,25 +185,29 @@ export function createCallServer({
}
(
window as WindowWithRouterGlobals
- ).__router._internalSetStateDoNotUseOrYouWillBreakYourApp({});
+ ).__reactRouterDataRouter._internalSetStateDoNotUseOrYouWillBreakYourApp(
+ {},
+ );
React.startTransition(() => {
(
window as WindowWithRouterGlobals
- ).__router._internalSetStateDoNotUseOrYouWillBreakYourApp({
- loaderData: Object.assign(
- {},
- globalVar.__router.state.loaderData,
- rerender.loaderData,
- ),
- errors: rerender.errors
- ? Object.assign(
- {},
- globalVar.__router.state.errors,
- rerender.errors,
- )
- : null,
- });
+ ).__reactRouterDataRouter._internalSetStateDoNotUseOrYouWillBreakYourApp(
+ {
+ loaderData: Object.assign(
+ {},
+ globalVar.__reactRouterDataRouter.state.loaderData,
+ rerender.loaderData,
+ ),
+ errors: rerender.errors
+ ? Object.assign(
+ {},
+ globalVar.__reactRouterDataRouter.state.errors,
+ rerender.errors,
+ )
+ : null,
+ },
+ );
});
}
},
@@ -227,7 +231,8 @@ function createRouterFromPayload({
}) {
const globalVar = window as WindowWithRouterGlobals;
- if (globalVar.__router) return globalVar.__router;
+ if (globalVar.__reactRouterDataRouter)
+ return globalVar.__reactRouterDataRouter;
if (payload.type !== "render") throw new Error("Invalid payload type");
@@ -256,7 +261,7 @@ function createRouterFromPayload({
return [route];
}, [] as DataRouteObject[]);
- globalVar.__router = createRouter({
+ globalVar.__reactRouterDataRouter = createRouter({
routes,
unstable_getContext,
basename: payload.basename,
@@ -294,7 +299,7 @@ function createRouterFromPayload({
},
// FIXME: Pass `build.ssr` into this function
dataStrategy: getRSCSingleFetchDataStrategy(
- () => globalVar.__router,
+ () => globalVar.__reactRouterDataRouter,
true,
payload.basename,
createFromReadableStream,
@@ -304,21 +309,96 @@ function createRouterFromPayload({
// We can call initialize() immediately if the router doesn't have any
// loaders to run on hydration
- if (globalVar.__router.state.initialized) {
+ if (globalVar.__reactRouterDataRouter.state.initialized) {
globalVar.__routerInitialized = true;
- globalVar.__router.initialize();
+ globalVar.__reactRouterDataRouter.initialize();
} else {
globalVar.__routerInitialized = false;
}
let lastLoaderData: unknown = undefined;
- globalVar.__router.subscribe(({ loaderData, actionData }) => {
+ globalVar.__reactRouterDataRouter.subscribe(({ loaderData, actionData }) => {
if (lastLoaderData !== loaderData) {
globalVar.__routerActionID = (globalVar.__routerActionID ??= 0) + 1;
}
});
- return globalVar.__router;
+ // @ts-expect-error
+ globalVar.__reactRouterDataRouter._updateRoutesForHMR = (
+ routeUpdateByRouteId: Map<
+ string,
+ {
+ routeModule: any;
+ hasAction: boolean;
+ hasComponent: boolean;
+ hasErrorBoundary: boolean;
+ hasLoader: boolean;
+ }
+ >,
+ ) => {
+ const oldRoutes = (window as WindowWithRouterGlobals)
+ .__reactRouterDataRouter.routes;
+ const newRoutes: DataRouteObjectWithManifestInfo[] = [];
+
+ function walkRoutes(
+ routes: DataRouteObjectWithManifestInfo[],
+ parentId?: string,
+ ): DataRouteObjectWithManifestInfo[] {
+ return routes.map((route) => {
+ const routeUpdate = routeUpdateByRouteId.get(route.id);
+
+ if (routeUpdate) {
+ const {
+ routeModule,
+ hasAction,
+ hasComponent,
+ hasErrorBoundary,
+ hasLoader,
+ } = routeUpdate;
+ const newRoute = createRouteFromServerManifest({
+ clientAction: routeModule.clientAction,
+ clientLoader: routeModule.clientLoader,
+ element: route.element as React.ReactElement,
+ errorElement: route.errorElement as React.ReactElement,
+ handle: route.handle,
+ hasAction,
+ hasComponent,
+ hasErrorBoundary,
+ hasLoader,
+ hydrateFallbackElement:
+ route.hydrateFallbackElement as React.ReactElement,
+ id: route.id,
+ index: route.index,
+ links: routeModule.links,
+ meta: routeModule.meta,
+ parentId: parentId,
+ path: route.path,
+ shouldRevalidate: routeModule.shouldRevalidate,
+ });
+ if (route.children) {
+ newRoute.children = walkRoutes(route.children, route.id);
+ }
+ return newRoute;
+ }
+
+ const updatedRoute = { ...route };
+ if (route.children) {
+ updatedRoute.children = walkRoutes(route.children, route.id);
+ }
+ return updatedRoute;
+ });
+ }
+
+ newRoutes.push(
+ ...walkRoutes(oldRoutes as DataRouteObjectWithManifestInfo[], undefined),
+ );
+
+ (
+ window as WindowWithRouterGlobals
+ ).__reactRouterDataRouter._internalSetRoutes(newRoutes);
+ };
+
+ return globalVar.__reactRouterDataRouter;
}
const renderedRoutesContext = unstable_createContext();
@@ -398,7 +478,9 @@ export function getRSCSingleFetchDataStrategy(
const renderedRoutes = renderedRoutesById.get(match.route.id);
if (renderedRoutes) {
for (const rendered of renderedRoutes) {
- (window as WindowWithRouterGlobals).__router.patchRoutes(
+ (
+ window as WindowWithRouterGlobals
+ ).__reactRouterDataRouter.patchRoutes(
rendered.parentId ?? null,
[createRouteFromServerManifest(rendered)],
true,
@@ -602,7 +684,7 @@ export function RSCHydratedRouter({
const globalVar = window as WindowWithRouterGlobals;
if (!globalVar.__routerInitialized) {
globalVar.__routerInitialized = true;
- globalVar.__router.initialize();
+ globalVar.__reactRouterDataRouter.initialize();
}
}, []);
@@ -726,6 +808,7 @@ export function RSCHydratedRouter({
}
type DataRouteObjectWithManifestInfo = DataRouteObject & {
+ children?: DataRouteObjectWithManifestInfo[];
hasLoader: boolean;
hasClientLoader: boolean;
hasAction: boolean;
@@ -871,7 +954,10 @@ function getManifestUrl(paths: string[]): URL | null {
}
const globalVar = window as WindowWithRouterGlobals;
- let basename = (globalVar.__router.basename ?? "").replace(/^\/|\/$/g, "");
+ let basename = (globalVar.__reactRouterDataRouter.basename ?? "").replace(
+ /^\/|\/$/g,
+ "",
+ );
let url = new URL(`${basename}/.manifest`, window.location.origin);
paths.sort().forEach((path) => url.searchParams.append("p", path));
@@ -915,7 +1001,7 @@ async function fetchAndApplyManifestPatches(
// Without the `allowElementMutations` flag, this will no-op if the route
// already exists so we can just call it for all returned patches
payload.patches.forEach((p) => {
- (window as WindowWithRouterGlobals).__router.patchRoutes(
+ (window as WindowWithRouterGlobals).__reactRouterDataRouter.patchRoutes(
p.parentId ?? null,
[createRouteFromServerManifest(p)],
);
diff --git a/packages/react-router/lib/rsc/server.rsc.ts b/packages/react-router/lib/rsc/server.rsc.ts
index 6df28ac897..58cadc5da2 100644
--- a/packages/react-router/lib/rsc/server.rsc.ts
+++ b/packages/react-router/lib/rsc/server.rsc.ts
@@ -1121,6 +1121,14 @@ async function getRSCRouteMatch({
pathname: match.pathname,
pathnameBase: match.pathnameBase,
shouldRevalidate: (match.route as any).shouldRevalidate,
+ // Add an unused client-only export (if present) so HMR can support
+ // switching between server-first and client-only routes during development
+ ...((match.route as any).__ensureClientRouteModuleForHMR
+ ? {
+ __ensureClientRouteModuleForHMR: (match.route as any)
+ .__ensureClientRouteModuleForHMR,
+ }
+ : {}),
};
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 994b7704b9..18a1fd2beb 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1117,9 +1117,6 @@ importers:
'@react-router/node':
specifier: workspace:*
version: link:../react-router-node
- '@vitejs/plugin-react':
- specifier: ^4.5.2
- version: 4.5.2(vite@6.2.5(@types/node@20.11.30)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.19.3)(yaml@2.8.0))
'@vitejs/plugin-rsc':
specifier: 0.4.11
version: 0.4.11(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vite@6.2.5(@types/node@20.11.30)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.19.3)(yaml@2.8.0))
@@ -13788,18 +13785,6 @@ snapshots:
- tsx
- yaml
- '@vitejs/plugin-react@4.5.2(vite@6.2.5(@types/node@20.11.30)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.19.3)(yaml@2.8.0))':
- dependencies:
- '@babel/core': 7.27.7
- '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.27.7)
- '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.27.7)
- '@rolldown/pluginutils': 1.0.0-beta.11
- '@types/babel__core': 7.20.5
- react-refresh: 0.17.0
- vite: 6.2.5(@types/node@20.11.30)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.19.3)(yaml@2.8.0)
- transitivePeerDependencies:
- - supports-color
-
'@vitejs/plugin-react@4.5.2(vite@6.2.5(@types/node@22.14.0)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.19.3)(yaml@2.8.0))':
dependencies:
'@babel/core': 7.27.7