Skip to content

Commit 2a84d62

Browse files
committed
support switching from server-first to client route
1 parent 1511adc commit 2a84d62

File tree

4 files changed

+84
-8
lines changed

4 files changed

+84
-8
lines changed

integration/vite-hmr-hdr-rsc-test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,5 +379,51 @@ test.describe("Vite HMR & HDR (RSC)", () => {
379379
);
380380
await expect(input).toHaveValue("stateful");
381381
expect(page.errors).toEqual([]);
382+
383+
// switch from server-first to client route
384+
await edit("app/routes/hmr/route.tsx", (contents) =>
385+
contents
386+
.replace(
387+
"export function ServerComponent",
388+
"export default function ClientComponent",
389+
)
390+
.replace("HMR updated: 3", "Client Route HMR: 0"),
391+
);
392+
await page.waitForLoadState("networkidle");
393+
await expect(hmrStatus).toHaveText("Client Route HMR: 0");
394+
// state is not preserved when switching from server to client route
395+
await expect(input).toHaveValue("");
396+
await input.type("client stateful");
397+
expect(page.errors).toEqual([]);
398+
await edit("app/routes/hmr/route.tsx", (contents) =>
399+
contents.replace("Client Route HMR: 0", "Client Route HMR: 1"),
400+
);
401+
await page.waitForLoadState("networkidle");
402+
await expect(hmrStatus).toHaveText("Client Route HMR: 1");
403+
await expect(input).toHaveValue("client stateful");
404+
expect(page.errors).toEqual([]);
405+
406+
// switch from client route back to server-first route
407+
await edit("app/routes/hmr/route.tsx", (contents) =>
408+
contents
409+
.replace(
410+
"export default function ClientComponent",
411+
"export function ServerComponent",
412+
)
413+
.replace("Client Route HMR: 1", "Server Route HMR: 0"),
414+
);
415+
await page.waitForLoadState("networkidle");
416+
await expect(hmrStatus).toHaveText("Server Route HMR: 0");
417+
// State is not preserved when switching from client to server route
418+
await expect(input).toHaveValue("");
419+
await input.type("server stateful");
420+
expect(page.errors).toEqual([]);
421+
await edit("app/routes/hmr/route.tsx", (contents) =>
422+
contents.replace("Server Route HMR: 0", "Server Route HMR: 1"),
423+
);
424+
await page.waitForLoadState("networkidle");
425+
await expect(hmrStatus).toHaveText("Server Route HMR: 1");
426+
await expect(input).toHaveValue("server stateful");
427+
expect(page.errors).toEqual([]);
382428
});
383429
});

packages/react-router-dev/vite/rsc/plugin.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ export function reactRouterRSCVitePlugin(): Vite.PluginOption[] {
158158
{
159159
name: "react-router/rsc/virtual-route-modules",
160160
transform(code, id) {
161-
return transformVirtualRouteModules({ code, id });
161+
return transformVirtualRouteModules({ code, id, viteCommand });
162162
},
163163
},
164164
{

packages/react-router-dev/vite/rsc/virtual-route-modules.ts

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { ConfigEnv } from "vite";
12
import * as babel from "../babel";
23
import { parse as esModuleLexer } from "es-module-lexer";
34
import { removeExports } from "../remove-exports";
@@ -44,38 +45,45 @@ function isClientRouteExport(name: string): name is ClientRouteExport {
4445
return CLIENT_ROUTE_EXPORTS_SET.has(name as ClientRouteExport);
4546
}
4647

48+
type ViteCommand = ConfigEnv["command"];
49+
4750
export function transformVirtualRouteModules({
4851
id,
4952
code,
53+
viteCommand,
5054
}: {
5155
id: string;
5256
code: string;
57+
viteCommand: ViteCommand;
5358
}) {
5459
if (!id.includes("route-module")) {
5560
return;
5661
}
5762

5863
if (isVirtualRouteModuleId(id)) {
59-
return createVirtualRouteModuleCode({ id, code });
64+
return createVirtualRouteModuleCode({ id, code, viteCommand });
6065
}
6166

6267
if (isVirtualServerRouteModuleId(id)) {
6368
return createVirtualServerRouteModuleCode({ id, code });
6469
}
6570

6671
if (isVirtualClientRouteModuleId(id)) {
67-
return createVirtualClientRouteModuleCode({ id, code });
72+
return createVirtualClientRouteModuleCode({ id, code, viteCommand });
6873
}
6974
}
7075

7176
async function createVirtualRouteModuleCode({
7277
id,
7378
code: routeSource,
79+
viteCommand,
7480
}: {
7581
id: string;
7682
code: string;
83+
viteCommand: ViteCommand;
7784
}) {
78-
const { staticExports, isServerFirstRoute } = parseRouteExports(routeSource);
85+
const { staticExports, isServerFirstRoute, hasClientExports } =
86+
parseRouteExports(routeSource);
7987

8088
const clientModuleId = getVirtualClientModuleId(id);
8189
const serverModuleId = getVirtualServerModuleId(id);
@@ -91,6 +99,9 @@ async function createVirtualRouteModuleCode({
9199
code += `export { ${staticExport} } from "${serverModuleId}";\n`;
92100
}
93101
}
102+
if (viteCommand === "serve" && !hasClientExports) {
103+
code += `export { __ensureClientRouteModuleForHmr } from "${clientModuleId}";\n`;
104+
}
94105
} else {
95106
for (const staticExport of staticExports) {
96107
if (isClientRouteExport(staticExport)) {
@@ -142,11 +153,14 @@ function createVirtualServerRouteModuleCode({
142153
function createVirtualClientRouteModuleCode({
143154
id,
144155
code: routeSource,
156+
viteCommand,
145157
}: {
146158
id: string;
147159
code: string;
160+
viteCommand: ViteCommand;
148161
}) {
149-
const { staticExports, isServerFirstRoute } = parseRouteExports(routeSource);
162+
const { staticExports, isServerFirstRoute, hasClientExports } =
163+
parseRouteExports(routeSource);
150164
const exportsToRemove = isServerFirstRoute
151165
? [...SERVER_ONLY_ROUTE_EXPORTS, ...COMPONENT_EXPORTS]
152166
: SERVER_ONLY_ROUTE_EXPORTS;
@@ -168,17 +182,25 @@ function createVirtualClientRouteModuleCode({
168182
generatorResult.code += `}\n`;
169183
}
170184

185+
if (viteCommand === "serve" && isServerFirstRoute && !hasClientExports) {
186+
generatorResult.code += `\nexport const __ensureClientRouteModuleForHmr = true;`;
187+
}
188+
171189
return generatorResult;
172190
}
173191

174192
export function parseRouteExports(code: string) {
175193
const [, exportSpecifiers] = esModuleLexer(code);
176194
const staticExports = exportSpecifiers.map(({ n: name }) => name);
195+
const isServerFirstRoute = staticExports.some(
196+
(staticExport) => staticExport === "ServerComponent",
197+
);
177198
return {
178199
staticExports,
179-
isServerFirstRoute: staticExports.some(
180-
(staticExport) => staticExport === "ServerComponent",
181-
),
200+
isServerFirstRoute,
201+
hasClientExports: isServerFirstRoute
202+
? staticExports.some(isClientNonComponentExport)
203+
: staticExports.some(isClientRouteExport),
182204
};
183205
}
184206

packages/react-router/lib/rsc/server.rsc.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1110,6 +1110,14 @@ async function getRSCRouteMatch(
11101110
pathname: match.pathname,
11111111
pathnameBase: match.pathnameBase,
11121112
shouldRevalidate: (match.route as any).shouldRevalidate,
1113+
// Add an unused client-only export (if present) so HMR can support
1114+
// switching from server-first to client-only routes during development
1115+
...((match.route as any).__ensureClientRouteModuleForHmr
1116+
? {
1117+
__ensureClientRouteModuleForHmr: (match.route as any)
1118+
.__ensureClientRouteModuleForHmr,
1119+
}
1120+
: {}),
11131121
};
11141122
}
11151123

0 commit comments

Comments
 (0)