diff --git a/.changeset/polite-weeks-poke.md b/.changeset/polite-weeks-poke.md
new file mode 100644
index 00000000000..4097d1f618c
--- /dev/null
+++ b/.changeset/polite-weeks-poke.md
@@ -0,0 +1,5 @@
+---
+"@remix-run/dev": patch
+---
+
+Vite: Tree-shake unused route exports in the client build
diff --git a/integration/vite-unused-route-exports-test.ts b/integration/vite-unused-route-exports-test.ts
new file mode 100644
index 00000000000..1dd7d2afd5c
--- /dev/null
+++ b/integration/vite-unused-route-exports-test.ts
@@ -0,0 +1,37 @@
+import * as path from "node:path";
+import { test, expect } from "@playwright/test";
+
+import { createProject, grep, viteBuild } from "./helpers/vite.js";
+
+test("Vite / dead-code elimination for unused route exports", async () => {
+ let cwd = await createProject({
+ "app/routes/custom-route-exports.tsx": String.raw`
+ const unusedMessage = "ROUTE_EXPORT_THAT_ISNT_USED";
+ const usedMessage = "ROUTE_EXPORT_THAT_IS_USED";
+
+ export const unusedRouteExport = unusedMessage;
+ export const usedRouteExport = usedMessage;
+
+ export default function CustomExportsRoute() {
+ return
Custom route exports
+ }
+ `,
+ "app/routes/use-route-export.tsx": String.raw`
+ import { usedRouteExport } from "./custom-route-exports";
+
+ export default function CustomExportsRoute() {
+ return {usedRouteExport}
+ }
+ `,
+ });
+ let { status } = viteBuild({ cwd });
+ expect(status).toBe(0);
+
+ expect(
+ grep(path.join(cwd, "build/client"), /ROUTE_EXPORT_THAT_ISNT_USED/).length
+ ).toBe(0);
+
+ expect(
+ grep(path.join(cwd, "build/client"), /ROUTE_EXPORT_THAT_IS_USED/).length
+ ).toBeGreaterThanOrEqual(1);
+});
diff --git a/packages/remix-dev/vite/plugin.ts b/packages/remix-dev/vite/plugin.ts
index 279d92117e9..a78c772a764 100644
--- a/packages/remix-dev/vite/plugin.ts
+++ b/packages/remix-dev/vite/plugin.ts
@@ -46,7 +46,20 @@ const supportedRemixConfigKeys = [
type SupportedRemixConfigKey = typeof supportedRemixConfigKeys[number];
type SupportedRemixConfig = Pick;
-const SERVER_ONLY_EXPORTS = ["loader", "action", "headers"];
+const SERVER_ONLY_ROUTE_EXPORTS = ["loader", "action", "headers"];
+const CLIENT_ROUTE_EXPORTS = [
+ "clientAction",
+ "clientLoader",
+ "default",
+ "ErrorBoundary",
+ "handle",
+ "HydrateFallback",
+ "links",
+ "meta",
+ "shouldRevalidate",
+];
+
+const CLIENT_ROUTE_QUERY_STRING = "?client-route";
// We need to provide different JSDoc comments in some cases due to differences
// between the Remix config and the Vite plugin.
@@ -140,8 +153,6 @@ let remixReactProxyId = VirtualModule.id("remix-react-proxy");
let hmrRuntimeId = VirtualModule.id("hmr-runtime");
let injectHmrRuntimeId = VirtualModule.id("inject-hmr-runtime");
-const isJsFile = (filePath: string) => /\.[cm]?[jt]sx?$/i.test(filePath);
-
const resolveRelativeRouteFilePath = (
route: ConfigRoute,
pluginConfig: ResolvedRemixVitePluginConfig
@@ -177,19 +188,19 @@ const resolveChunk = (
absoluteFilePath: string
) => {
let vite = importViteEsmSync();
- let rootRelativeFilePath = path.relative(
- pluginConfig.rootDirectory,
- absoluteFilePath
+ let rootRelativeFilePath = vite.normalizePath(
+ path.relative(pluginConfig.rootDirectory, absoluteFilePath)
);
- let manifestKey = vite.normalizePath(rootRelativeFilePath);
- let entryChunk = viteManifest[manifestKey];
+ let entryChunk =
+ viteManifest[rootRelativeFilePath + CLIENT_ROUTE_QUERY_STRING] ??
+ viteManifest[rootRelativeFilePath];
if (!entryChunk) {
let knownManifestKeys = Object.keys(viteManifest)
.map((key) => '"' + key + '"')
.join(", ");
throw new Error(
- `No manifest entry found for "${manifestKey}". Known manifest keys: ${knownManifestKeys}`
+ `No manifest entry found for "${rootRelativeFilePath}". Known manifest keys: ${knownManifestKeys}`
);
}
@@ -215,7 +226,7 @@ const resolveBuildAssetPaths = (
]);
return {
- module: `${pluginConfig.publicPath}${entryChunk.file}`,
+ module: `${pluginConfig.publicPath}${entryChunk.file}${CLIENT_ROUTE_QUERY_STRING}`,
imports:
dedupe(chunks.flatMap((e) => e.imports ?? [])).map((imported) => {
return `${pluginConfig.publicPath}${viteManifest[imported].file}`;
@@ -296,7 +307,7 @@ const getRouteModuleExports = async (
let ssr = true;
let { pluginContainer, moduleGraph } = viteChildCompiler;
- let routePath = path.join(pluginConfig.appDirectory, routeFile);
+ let routePath = path.resolve(pluginConfig.appDirectory, routeFile);
let url = resolveFileUrl(pluginConfig, routePath);
let resolveId = async () => {
@@ -576,9 +587,7 @@ export const remixVitePlugin: RemixVitePlugin = (options = {}) => {
module: `${resolveFileUrl(
pluginConfig,
resolveRelativeRouteFilePath(route, pluginConfig)
- )}${
- isJsFile(route.file) ? "" : "?import" // Ensure the Vite dev server responds with a JS module
- }`,
+ )}${CLIENT_ROUTE_QUERY_STRING}`,
hasAction: sourceExports.includes("action"),
hasLoader: sourceExports.includes("loader"),
hasClientAction: sourceExports.includes("clientAction"),
@@ -692,8 +701,12 @@ export const remixVitePlugin: RemixVitePlugin = (options = {}) => {
preserveEntrySignatures: "exports-only",
input: [
pluginConfig.entryClientFilePath,
- ...Object.values(pluginConfig.routes).map((route) =>
- path.resolve(pluginConfig.appDirectory, route.file)
+ ...Object.values(pluginConfig.routes).map(
+ (route) =>
+ `${path.resolve(
+ pluginConfig.appDirectory,
+ route.file
+ )}${CLIENT_ROUTE_QUERY_STRING}`
),
],
},
@@ -796,10 +809,27 @@ export const remixVitePlugin: RemixVitePlugin = (options = {}) => {
});
await viteChildCompiler.pluginContainer.buildStart({});
},
- transform(code, id) {
+ async transform(code, id) {
if (isCssModulesFile(id)) {
cssModulesManifest[id] = code;
}
+
+ if (id.endsWith(CLIENT_ROUTE_QUERY_STRING)) {
+ invariant(cachedPluginConfig);
+ let routeModuleId = id.replace(CLIENT_ROUTE_QUERY_STRING, "");
+ let sourceExports = await getRouteModuleExports(
+ viteChildCompiler,
+ cachedPluginConfig,
+ routeModuleId
+ );
+
+ let routeFileName = path.basename(routeModuleId);
+ let clientExports = sourceExports
+ .filter((exportName) => CLIENT_ROUTE_EXPORTS.includes(exportName))
+ .join(", ");
+
+ return `export { ${clientExports} } from "./${routeFileName}";`;
+ }
},
buildStart() {
invariant(viteConfig);
@@ -1054,7 +1084,7 @@ export const remixVitePlugin: RemixVitePlugin = (options = {}) => {
let isRoute = getRoute(pluginConfig, importer);
if (isRoute) {
- let serverOnlyExports = SERVER_ONLY_EXPORTS.map(
+ let serverOnlyExports = SERVER_ONLY_ROUTE_EXPORTS.map(
(xport) => `\`${xport}\``
).join(", ");
throw Error(
@@ -1144,7 +1174,7 @@ export const remixVitePlugin: RemixVitePlugin = (options = {}) => {
if (pluginConfig.isSpaMode) {
let serverOnlyExports = esModuleLexer(code)[1]
.map((exp) => exp.n)
- .filter((exp) => SERVER_ONLY_EXPORTS.includes(exp));
+ .filter((exp) => SERVER_ONLY_ROUTE_EXPORTS.includes(exp));
if (serverOnlyExports.length > 0) {
let str = serverOnlyExports.map((e) => `\`${e}\``).join(", ");
let message =
@@ -1170,7 +1200,7 @@ export const remixVitePlugin: RemixVitePlugin = (options = {}) => {
}
return {
- code: removeExports(code, SERVER_ONLY_EXPORTS),
+ code: removeExports(code, SERVER_ONLY_ROUTE_EXPORTS),
map: null,
};
},
@@ -1286,6 +1316,13 @@ export const remixVitePlugin: RemixVitePlugin = (options = {}) => {
let useFastRefresh = !ssr && (isJSX || code.includes(devRuntime));
if (!useFastRefresh) return;
+ if (id.endsWith(CLIENT_ROUTE_QUERY_STRING)) {
+ let pluginConfig =
+ cachedPluginConfig || (await resolvePluginConfig());
+
+ return { code: addRefreshWrapper(pluginConfig, code, id) };
+ }
+
let result = await babel.transformAsync(code, {
filename: id,
sourceFileName: filepath,