Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(remix-dev/vite): tree-shake unused route exports #8468

Merged
merged 5 commits into from
Jan 11, 2024
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/polite-weeks-poke.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@remix-run/dev": patch
---

Vite: Tree-shake unused route exports in the client build
37 changes: 37 additions & 0 deletions integration/vite-unused-route-exports-test.ts
Original file line number Diff line number Diff line change
@@ -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 <h1>Custom route exports</h1>
}
`,
"app/routes/use-route-export.tsx": String.raw`
import { usedRouteExport } from "./custom-route-exports";

export default function CustomExportsRoute() {
return <h1>{usedRouteExport}</h1>
}
`,
});
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);
});
77 changes: 57 additions & 20 deletions packages/remix-dev/vite/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,20 @@ const supportedRemixConfigKeys = [
type SupportedRemixConfigKey = typeof supportedRemixConfigKeys[number];
type SupportedRemixConfig = Pick<RemixUserConfig, SupportedRemixConfigKey>;

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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}`
);
}

Expand All @@ -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}`;
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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}`
),
],
},
Expand Down Expand Up @@ -796,10 +809,27 @@ export const remixVitePlugin: RemixVitePlugin = (options = {}) => {
});
await viteChildCompiler.pluginContainer.buildStart({});
},
transform(code, id) {
async transform(code, id) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reading through the Vite conventions for virtual modules again and wondering if we should do something to ensure the client routes don't get processed/handled by other plugins. We could just prepend with \0 or model them fully as vmods.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it should be okay if it's handled by other plugins. In terms of resolving the module, since it's a real file on disk, it'll be treated the same as the original route file. In terms of transforming the file, it's going to behave as if you'd manually written a re-export file, which also would work fine.

The benefit of this approach from what I understand is that Vite will treat this pseudo-virtual module the same as the route file in terms of file changes, invalidation, HMR. This means we don't need to do anything special to invalidate virtual modules when the underlying route changes.

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);
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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 =
Expand All @@ -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,
};
},
Expand Down Expand Up @@ -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,
Expand Down