Skip to content

Commit 2012d2f

Browse files
Add option to enforce route chunks
1 parent 8db4099 commit 2012d2f

File tree

5 files changed

+161
-18
lines changed

5 files changed

+161
-18
lines changed

integration/helpers/vite.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,9 @@ export const viteConfig = {
4141
port: number;
4242
fsAllow?: string[];
4343
spaMode?: boolean;
44-
routeChunks?: boolean;
44+
routeChunks?: NonNullable<
45+
ReactRouterConfig["future"]
46+
>["unstable_routeChunks"];
4547
}) => {
4648
let config: ReactRouterConfig = {
4749
ssr: !args.spaMode,

integration/route-chunks-test.ts

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { test, expect, type Page } from "@playwright/test";
22
import getPort from "get-port";
3+
import dedent from "dedent";
34

45
import {
56
createProject,
@@ -89,7 +90,7 @@ const files = {
8990
return () => "clientAction in main chunk: " + eval("typeof inUnchunkableMainChunk === 'function'");
9091
})();
9192
92-
export default function UnchunckableRoute() {
93+
export default function UnchunkableRoute() {
9394
inUnchunkableMainChunk();
9495
const loaderData = useLoaderData();
9596
const actionData = useActionData();
@@ -153,6 +154,55 @@ test.describe("Route chunks", async () => {
153154
await workflow({ cwd, page, port, routeChunks, mode: "production" });
154155
});
155156
});
157+
158+
test.describe("enforce", () => {
159+
let routeChunks = "enforce" as const;
160+
let port: number;
161+
let cwd: string;
162+
163+
test.describe("chunkable routes", () => {
164+
test.beforeAll(async () => {
165+
port = await getPort();
166+
cwd = await createProject({
167+
"vite.config.js": await viteConfig.basic({ port, routeChunks }),
168+
...files,
169+
"app/routes/unchunkable.tsx": files["app/routes/chunkable.tsx"],
170+
});
171+
});
172+
173+
test("build passes", async () => {
174+
let { status } = build({ cwd });
175+
expect(status).toBe(0);
176+
});
177+
});
178+
179+
test.describe("unchunkable routes", () => {
180+
test.beforeAll(async () => {
181+
port = await getPort();
182+
cwd = await createProject({
183+
"vite.config.js": await viteConfig.basic({ port, routeChunks }),
184+
...files,
185+
});
186+
});
187+
188+
test("build fails", async () => {
189+
let { stderr, status } = build({ cwd });
190+
expect(status).toBe(1);
191+
expect(stderr.toString()).toMatch(
192+
dedent`
193+
Route chunks error: routes/unchunkable.tsx
194+
195+
- clientAction
196+
- clientLoader
197+
198+
These exports were unable to be split into their own chunks because they reference code in the same file that is used by other route module exports.
199+
200+
If you need to share code between these and other exports, you should extract the shared code into a separate module.
201+
`
202+
);
203+
});
204+
});
205+
});
156206
});
157207

158208
async function workflow({

packages/react-router-dev/vite/config.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ interface FutureConfig {
8282
/**
8383
* Automatically split route modules into multiple chunks when possible.
8484
*/
85-
unstable_routeChunks?: boolean;
85+
unstable_routeChunks?: boolean | "enforce";
8686
}
8787

8888
export type BuildManifest = DefaultBuildManifest | ServerBundlesBuildManifest;
@@ -502,7 +502,7 @@ export async function resolveReactRouterConfig({
502502
}
503503

504504
let future: FutureConfig = {
505-
unstable_routeChunks: Boolean(userFuture?.unstable_routeChunks),
505+
unstable_routeChunks: userFuture?.unstable_routeChunks ?? false,
506506
};
507507

508508
let reactRouterConfig: ResolvedReactRouterConfig = deepFreeze({

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

Lines changed: 104 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import { combineURLs } from "./combine-urls";
4040
import { removeExports } from "./remove-exports";
4141
import {
4242
type RouteChunkName,
43+
chunkedExportNames,
4344
detectRouteChunks,
4445
isRouteChunkName,
4546
getRouteChunk,
@@ -218,7 +219,7 @@ const normalizeRelativeFilePath = (
218219
let fullPath = path.resolve(reactRouterConfig.appDirectory, file);
219220
let relativePath = path.relative(reactRouterConfig.appDirectory, fullPath);
220221

221-
return vite.normalizePath(relativePath);
222+
return vite.normalizePath(relativePath).split("?")[0];
222223
};
223224

224225
const resolveRelativeRouteFilePath = (
@@ -342,6 +343,11 @@ const writeFileSafe = async (file: string, contents: string): Promise<void> => {
342343
await fse.writeFile(file, contents);
343344
};
344345

346+
const getExportNames = (code: string): string[] => {
347+
let [, exportSpecifiers] = esModuleLexer(code);
348+
return exportSpecifiers.map(({ n: name }) => name);
349+
};
350+
345351
const getRouteManifestModuleExports = async (
346352
viteChildCompiler: Vite.ViteDevServer | null,
347353
ctx: ReactRouterPluginContext
@@ -414,10 +420,7 @@ const getRouteModuleExports = async (
414420
readRouteFile
415421
);
416422

417-
let [, exports] = esModuleLexer(code);
418-
let exportNames = exports.map((e) => e.n);
419-
420-
return exportNames;
423+
return getExportNames(code);
421424
};
422425

423426
const getServerBundleBuildConfig = (
@@ -691,6 +694,9 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = (_config) => {
691694
ctx
692695
);
693696

697+
let enforceRouteChunks =
698+
ctx.reactRouterConfig.future.unstable_routeChunks === "enforce";
699+
694700
for (let [key, route] of Object.entries(ctx.reactRouterConfig.routes)) {
695701
let routeFile = path.join(ctx.reactRouterConfig.appDirectory, route.file);
696702
let sourceExports = routeManifestExports[key];
@@ -719,6 +725,17 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = (_config) => {
719725
)
720726
: null;
721727

728+
if (enforceRouteChunks) {
729+
validateRouteChunks({
730+
ctx,
731+
id: route.file,
732+
valid: {
733+
clientAction: !hasClientAction || hasClientActionChunk,
734+
clientLoader: !hasClientLoader || hasClientLoaderChunk,
735+
},
736+
});
737+
}
738+
722739
let routeManifestEntry: ReactRouterManifest["routes"][string] = {
723740
id: route.id,
724741
parentId: route.parentId,
@@ -800,6 +817,9 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = (_config) => {
800817
ctx
801818
);
802819

820+
let enforceRouteChunks =
821+
ctx.reactRouterConfig.future.unstable_routeChunks === "enforce";
822+
803823
for (let [key, route] of Object.entries(ctx.reactRouterConfig.routes)) {
804824
let routeFile = route.file;
805825
let sourceExports = routeManifestExports[key];
@@ -819,6 +839,17 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = (_config) => {
819839
viteChildCompiler,
820840
});
821841

842+
if (enforceRouteChunks) {
843+
validateRouteChunks({
844+
ctx,
845+
id: route.file,
846+
valid: {
847+
clientAction: !hasClientAction || hasClientActionChunk,
848+
clientLoader: !hasClientLoader || hasClientLoaderChunk,
849+
},
850+
});
851+
}
852+
822853
routes[key] = {
823854
id: route.id,
824855
parentId: route.parentId,
@@ -1523,6 +1554,22 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = (_config) => {
15231554
return "// Route chunks disabled";
15241555
}
15251556

1557+
let enforceRouteChunks =
1558+
ctx.reactRouterConfig.future.unstable_routeChunks === "enforce";
1559+
1560+
if (enforceRouteChunks && chunkName === "main" && chunk) {
1561+
let exportNames = getExportNames(chunk.code);
1562+
1563+
validateRouteChunks({
1564+
ctx,
1565+
id,
1566+
valid: {
1567+
clientAction: !exportNames.includes("clientAction"),
1568+
clientLoader: !exportNames.includes("clientLoader"),
1569+
},
1570+
});
1571+
}
1572+
15261573
return chunk ?? `// No ${chunkName} chunk`;
15271574
},
15281575
},
@@ -1636,10 +1683,10 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = (_config) => {
16361683
let clientFileRE = /\.client(\.[cm]?[jt]sx?)?$/;
16371684
let clientDirRE = /\/\.client\//;
16381685
if (clientFileRE.test(id) || clientDirRE.test(id)) {
1639-
let exports = esModuleLexer(code)[1];
1686+
let exports = getExportNames(code);
16401687
return {
16411688
code: exports
1642-
.map(({ n: name }) =>
1689+
.map((name) =>
16431690
name === "default"
16441691
? "export default undefined;"
16451692
: `export const ${name} = undefined;`
@@ -1658,9 +1705,10 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = (_config) => {
16581705
if (!route) return;
16591706

16601707
if (!options?.ssr && !ctx.reactRouterConfig.ssr) {
1661-
let serverOnlyExports = esModuleLexer(code)[1]
1662-
.map((exp) => exp.n)
1663-
.filter((exp) => SERVER_ONLY_ROUTE_EXPORTS.includes(exp));
1708+
let exportNames = getExportNames(code);
1709+
let serverOnlyExports = exportNames.filter((exp) =>
1710+
SERVER_ONLY_ROUTE_EXPORTS.includes(exp)
1711+
);
16641712
if (serverOnlyExports.length > 0) {
16651713
let str = serverOnlyExports.map((e) => `\`${e}\``).join(", ");
16661714
let message =
@@ -1671,9 +1719,9 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = (_config) => {
16711719
}
16721720

16731721
if (route.id !== "root") {
1674-
let hasHydrateFallback = esModuleLexer(code)[1]
1675-
.map((exp) => exp.n)
1676-
.some((exp) => exp === "HydrateFallback");
1722+
let hasHydrateFallback = exportNames.some(
1723+
(exp) => exp === "HydrateFallback"
1724+
);
16771725
if (hasHydrateFallback) {
16781726
let message =
16791727
`SPA Mode: Invalid \`HydrateFallback\` export found in ` +
@@ -2458,3 +2506,46 @@ async function getRouteChunkIfEnabled(
24582506

24592507
return getRouteChunk(code, chunkName, cache, cacheKey);
24602508
}
2509+
2510+
function validateRouteChunks({
2511+
ctx,
2512+
id,
2513+
valid,
2514+
}: {
2515+
ctx: ReactRouterPluginContext;
2516+
id: string;
2517+
valid: Record<Exclude<RouteChunkName, "main">, boolean>;
2518+
}): void {
2519+
let invalidChunks = Object.entries(valid)
2520+
.filter(([_, isValid]) => !isValid)
2521+
.map(([chunkName]) => chunkName);
2522+
2523+
if (invalidChunks.length === 0) {
2524+
return;
2525+
}
2526+
2527+
let plural = invalidChunks.length > 1;
2528+
2529+
throw new Error(
2530+
[
2531+
`Route chunks error: ${normalizeRelativeFilePath(
2532+
id,
2533+
ctx.reactRouterConfig
2534+
)}`,
2535+
2536+
invalidChunks.map((name) => `- ${name}`).join("\n"),
2537+
2538+
`${
2539+
plural ? `These exports were` : `This export was`
2540+
} unable to be split into ${
2541+
plural ? "their own chunks" : "its own chunk"
2542+
} because ${
2543+
plural ? "they reference" : "it references"
2544+
} code in the same file that is used by other route module exports.`,
2545+
2546+
`If you need to share code between ${
2547+
plural ? `these` : `this`
2548+
} and other exports, you should extract the shared code into a separate module.`,
2549+
].join("\n\n")
2550+
);
2551+
}

packages/react-router-dev/vite/route-chunks.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -928,7 +928,7 @@ export function detectRouteChunks(
928928
}
929929

930930
const mainChunkName = "main" as const;
931-
const chunkedExportNames = ["clientAction", "clientLoader"] as const;
931+
export const chunkedExportNames = ["clientAction", "clientLoader"] as const;
932932
export type RouteChunkName =
933933
| typeof mainChunkName
934934
| (typeof chunkedExportNames)[number];

0 commit comments

Comments
 (0)