diff --git a/integration/deduped-route-modules-test.ts b/integration/deduped-route-modules-test.ts new file mode 100644 index 0000000000..cb09d61ac4 --- /dev/null +++ b/integration/deduped-route-modules-test.ts @@ -0,0 +1,294 @@ +import { test, expect } from "@playwright/test"; + +import { createFixture, createAppFixture } from "./helpers/create-fixture.js"; +import type { Fixture, AppFixture } from "./helpers/create-fixture.js"; +import { PlaywrightFixture } from "./helpers/playwright-fixture.js"; +import { + type TemplateName, + reactRouterConfig, + viteConfig, +} from "./helpers/vite.js"; + +const templateNames = [ + "vite-5-template", + "rsc-vite-framework", +] as const satisfies TemplateName[]; + +// This test ensures that code is not accidentally duplicated when a route is +// imported within user code since they're not importing one of our internal +// virtual route modules. +test.describe("Deduped route modules", () => { + for (const templateName of templateNames) { + test.describe(`template: ${templateName}`, () => { + let fixture: Fixture; + let appFixture: AppFixture; + + test.beforeAll(async () => { + fixture = await createFixture({ + templateName, + files: { + "vite.config.js": await viteConfig.basic({ + templateName, + }), + "react-router.config.ts": reactRouterConfig({ + viteEnvironmentApi: templateName.includes("rsc"), + }), + "app/routes/client-first.a.tsx": ` + import { Link } from "react-router"; + + export const customExport = (() => { + globalThis.custom_export_count = (globalThis.custom_export_count || 0) + 1; + return () => true; + })(); + + export const loader = (() => { + globalThis.loader_count = (globalThis.loader_count || 0) + 1; + return () => ({ + customExportCount: globalThis.custom_export_count, + loaderCount: globalThis.loader_count, + componentCount: globalThis.component_count, + }); + })(); + + export const clientLoader = (() => { + globalThis.client_loader_count = (globalThis.client_loader_count || 0) + 1; + return async ({ serverLoader }) => { + const loaderData = await serverLoader(); + return { + loaderCount: loaderData.loaderCount, + clientLoaderCount: globalThis.client_loader_count, + serverCustomExportCount: loaderData.customExportCount, + clientCustomExportCount: globalThis.custom_export_count, + serverComponentCount: loaderData.componentCount, + clientComponentCount: globalThis.component_count, + }; + }; + })(); + clientLoader.hydrate = true; + + const RouteA = (() => { + globalThis.component_count = (globalThis.component_count || 0) + 1; + return ({ loaderData }: Route.ComponentProps) => { + return ( + <> +
Loader count: {loaderData.loaderCount}
+Client loader count: {loaderData.clientLoaderCount}
+Server custom export count: {loaderData.serverCustomExportCount}
+Client custom export count: {loaderData.clientCustomExportCount}
+Server component count: {loaderData.serverComponentCount}
+Client component count: {loaderData.clientComponentCount}
+Go to Route B
+ > + ); + }; + })(); + + export default RouteA; + `, + "app/routes/client-first.b.tsx": ` + import { Link } from "react-router"; + + import { customExport } from "./client-first.a"; + + export default function RouteB() { + return customExport && ( + <> +This route imports the route module from Route A, so could potentially cause code duplication.
+Go to Route A
+ > + ); + } + `, + + ...(templateName.includes("rsc") + ? { + "app/routes/rsc-server-first.a/route.tsx": ` + import { Link } from "react-router"; + import { ModuleCounts, clientLoader } from "./client"; + + export const customExport = (() => { + globalThis.rsc_custom_export_count = (globalThis.rsc_custom_export_count || 0) + 1; + return () => true; + })(); + + export const loader = (() => { + globalThis.rsc_loader_count = (globalThis.rsc_loader_count || 0) + 1; + return () => ({ + customExportCount: globalThis.rsc_custom_export_count, + loaderCount: globalThis.rsc_loader_count, + componentCount: globalThis.rsc_component_count, + }); + })(); + + export { clientLoader }; + + export const ServerComponent = (() => { + globalThis.rsc_component_count = (globalThis.rsc_component_count || 0) + 1; + return () => { + return ( + <> +Go to RSC Route B
+ > + ); + }; + })(); + `, + "app/routes/rsc-server-first.a/client.tsx": ` + "use client"; + + import { useLoaderData } from "react-router"; + + export const clientLoader = (() => { + globalThis.rsc_client_loader_count = (globalThis.rsc_client_loader_count || 0) + 1; + return async ({ serverLoader }) => { + const loaderData = await serverLoader(); + return { + loaderCount: loaderData.loaderCount, + clientLoaderCount: globalThis.rsc_client_loader_count, + serverCustomExportCount: loaderData.customExportCount, + clientCustomExportCount: globalThis.rsc_custom_export_count, + serverComponentCount: loaderData.componentCount, + }; + }; + })(); + clientLoader.hydrate = true; + + export function ModuleCounts() { + const loaderData = useLoaderData(); + return ( + <> +Loader count: {loaderData.loaderCount}
+Client loader count: {loaderData.clientLoaderCount}
+Server custom export count: {loaderData.serverCustomExportCount}
+Client custom export count: {loaderData.clientCustomExportCount}
+Server component count: {loaderData.serverComponentCount}
+ > + ); + } + `, + "app/routes/rsc-server-first.b.tsx": ` + import { Link } from "react-router"; + + import { customExport } from "./rsc-server-first.a/route"; + + // Ensure custom export is used in the client build in this route + export const handle = customExport; + + export function ServerComponent() { + return customExport && ( + <> +This route imports the route module from RSC Route A, so could potentially cause code duplication.
+Go to RSC Route A
+ > + ); + } + `, + } + : {}), + }, + }); + + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + let logs: string[] = []; + + test.beforeEach(({ page }) => { + page.on("console", (msg) => { + logs.push(msg.text()); + }); + }); + + test.afterEach(() => { + expect(logs).toHaveLength(0); + }); + + test("Client-first routes", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + + let pageErrors: unknown[] = []; + page.on("pageerror", (error) => pageErrors.push(error)); + + await app.goto(`/client-first/b`, true); + expect(pageErrors).toEqual([]); + + await app.clickLink("/client-first/a"); + await page.waitForSelector("[data-loader-count]"); + expect(await page.locator("[data-loader-count]").textContent()).toBe( + "1", + ); + expect( + await page.locator("[data-client-loader-count]").textContent(), + ).toBe("1"); + expect( + await page.locator("[data-server-custom-export-count]").textContent(), + ).toBe( + templateName.includes("rsc") + ? // In RSC, custom exports are present in both the react-server and react-client + // environments (so they're available to be imported by both), + // which means the Node server actually gets 2 copies + "2" + : "1", + ); + expect( + await page.locator("[data-client-custom-export-count]").textContent(), + ).toBe("1"); + expect( + await page.locator("[data-server-component-count]").textContent(), + ).toBe("1"); + expect( + await page.locator("[data-client-component-count]").textContent(), + ).toBe("1"); + expect(pageErrors).toEqual([]); + }); + + test("Server-first routes", async ({ page }) => { + test.skip( + !templateName.includes("rsc"), + "Server-first routes are an RSC-only feature", + ); + + let app = new PlaywrightFixture(appFixture, page); + + let pageErrors: unknown[] = []; + page.on("pageerror", (error) => pageErrors.push(error)); + + await app.goto(`/rsc-server-first/b`, true); + expect(pageErrors).toEqual([]); + + await app.clickLink("/rsc-server-first/a"); + await page.waitForSelector("[data-loader-count]"); + expect(await page.locator("[data-loader-count]").textContent()).toBe( + "1", + ); + expect( + await page.locator("[data-client-loader-count]").textContent(), + ).toBe("1"); + expect( + await page.locator("[data-server-custom-export-count]").textContent(), + ).toBe( + // In RSC, custom exports are present in both the react-server and react-client + // environments (so they're available to be imported by both), + // which means the Node server actually gets 2 copies + "2", + ); + expect( + await page.locator("[data-client-custom-export-count]").textContent(), + ).toBe("1"); + expect( + await page.locator("[data-server-component-count]").textContent(), + ).toBe("1"); + expect(pageErrors).toEqual([]); + }); + }); + } +}); diff --git a/packages/react-router-dev/vite/rsc/plugin.ts b/packages/react-router-dev/vite/rsc/plugin.ts index 9ba3cb2b96..47c67e00ec 100644 --- a/packages/react-router-dev/vite/rsc/plugin.ts +++ b/packages/react-router-dev/vite/rsc/plugin.ts @@ -17,6 +17,7 @@ import { createVirtualRouteConfig } from "./virtual-route-config"; import { transformVirtualRouteModules, parseRouteExports, + isVirtualClientRouteModuleId, CLIENT_NON_COMPONENT_EXPORTS, } from "./virtual-route-modules"; import validatePluginOrder from "../plugins/validate-plugin-order"; @@ -159,7 +160,14 @@ export function reactRouterRSCVitePlugin(): Vite.PluginOption[] { { name: "react-router/rsc/virtual-route-modules", transform(code, id) { - return transformVirtualRouteModules({ code, id, viteCommand }); + if (!routeIdByFile) return; + return transformVirtualRouteModules({ + code, + id, + viteCommand, + routeIdByFile, + viteEnvironment: this.environment, + }); }, }, { @@ -228,8 +236,8 @@ export function reactRouterRSCVitePlugin(): Vite.PluginOption[] { const useFastRefresh = !ssr && (isJSX || code.includes(devRuntime)); if (!useFastRefresh) return; - const routeId = routeIdByFile?.get(filepath); - if (routeId !== undefined) { + if (isVirtualClientRouteModuleId(id)) { + const routeId = routeIdByFile?.get(filepath); return { code: addRefreshWrapper({ routeId, code, id }) }; } 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 e3785edf58..e0093b7cc3 100644 --- a/packages/react-router-dev/vite/rsc/virtual-route-modules.ts +++ b/packages/react-router-dev/vite/rsc/virtual-route-modules.ts @@ -1,4 +1,4 @@ -import type { ConfigEnv } from "vite"; +import type * as Vite from "vite"; import * as babel from "../babel"; import { parse as esModuleLexer } from "es-module-lexer"; import { removeExports } from "../remove-exports"; @@ -10,6 +10,11 @@ const SERVER_ONLY_ROUTE_EXPORTS = [ "headers", "ServerComponent", ] as const; +type ServerOnlyRouteExport = (typeof SERVER_ONLY_ROUTE_EXPORTS)[number]; +const SERVER_ONLY_ROUTE_EXPORTS_SET = new Set(SERVER_ONLY_ROUTE_EXPORTS); +function isServerOnlyRouteExport(name: string): name is ServerOnlyRouteExport { + return SERVER_ONLY_ROUTE_EXPORTS_SET.has(name as ServerOnlyRouteExport); +} const COMPONENT_EXPORTS = [ "default", @@ -45,27 +50,49 @@ function isClientRouteExport(name: string): name is ClientRouteExport { return CLIENT_ROUTE_EXPORTS_SET.has(name as ClientRouteExport); } -type ViteCommand = ConfigEnv["command"]; +const ROUTE_EXPORTS = [ + ...SERVER_ONLY_ROUTE_EXPORTS, + ...CLIENT_ROUTE_EXPORTS, +] as const; +type RouteExport = (typeof ROUTE_EXPORTS)[number]; +const ROUTE_EXPORTS_SET = new Set(ROUTE_EXPORTS); +function isRouteExport(name: string): name is RouteExport { + return ROUTE_EXPORTS_SET.has(name as RouteExport); +} +function isCustomRouteExport(name: string) { + return !isRouteExport(name); +} + +function hasReactServerCondition(viteEnvironment: Vite.Environment) { + return viteEnvironment.config.resolve.conditions.includes("react-server"); +} + +type ViteCommand = Vite.ConfigEnv["command"]; export function transformVirtualRouteModules({ id, code, viteCommand, + routeIdByFile, + viteEnvironment, }: { id: string; code: string; viteCommand: ViteCommand; + routeIdByFile: Map