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