From 03b2b535b2253686070a88a5bf22c21faca59eb6 Mon Sep 17 00:00:00 2001 From: Brett Burley Date: Tue, 18 Mar 2025 12:19:05 -0700 Subject: [PATCH] Add support for custom entry file paths. --- contributors.yml | 1 + integration/file-path-config-test.ts | 192 +++++++++++++++++++++ packages/react-router-dev/config/config.ts | 110 ++++++++---- 3 files changed, 272 insertions(+), 31 deletions(-) create mode 100644 integration/file-path-config-test.ts diff --git a/contributors.yml b/contributors.yml index df521ac7f3..457f383bc3 100644 --- a/contributors.yml +++ b/contributors.yml @@ -51,6 +51,7 @@ - bobziroll - bravo-kernel - Brendonovich +- brettburley - briankb - BrianT1414 - brockross diff --git a/integration/file-path-config-test.ts b/integration/file-path-config-test.ts new file mode 100644 index 0000000000..8b2d004c6d --- /dev/null +++ b/integration/file-path-config-test.ts @@ -0,0 +1,192 @@ +import { expect } from "@playwright/test"; +import type { Files } from "./helpers/vite.js"; +import { test, viteConfig, build, createProject } from "./helpers/vite.js"; + +const js = String.raw; + +const simpleFiles: Files = async ({ port }) => ({ + "vite.config.ts": await viteConfig.basic({ port }), + "react-router.config.ts": js` + export default { + rootRouteFile: "custom/root.tsx", + routesFile: "custom/app-routes.ts", + clientEntryFile: "custom/entry.client.tsx", + serverEntryFile: "custom/entry.server.tsx", + }; + `, + "app/custom/root.tsx": js` + import { Links, Meta, Outlet, Scripts } from "react-router"; + + export default function Root() { + return ( + + + + + + +
+

Custom Root

+ +
+ + + + ); + } + `, + "app/custom/app-routes.ts": js` + import { type RouteConfig, index } from "@react-router/dev/routes"; + + export default [ + index("index.tsx"), + ] satisfies RouteConfig; + `, + "app/index.tsx": js` + export default function IndexRoute() { + return
{}}>Custom IndexRoute
+ } + `, + "app/custom/entry.client.tsx": js` + import { HydratedRouter } from "react-router/dom"; + import { startTransition, StrictMode } from "react"; + import { hydrateRoot } from "react-dom/client"; + + window.__customClientEntryExecuted = true; + + startTransition(() => { + hydrateRoot( + document, + + + + ); + }); + `, + "app/custom/entry.server.tsx": js` + import * as React from "react"; + import { ServerRouter } from "react-router"; + import { renderToString } from "react-dom/server"; + + export default function handleRequest( + request, + responseStatusCode, + responseHeaders, + remixContext + ) { + let markup = renderToString( + + ); + responseHeaders.set("Content-Type", "text/html"); + responseHeaders.set("X-Custom-Server-Entry", "true"); + return new Response('' + markup, { + headers: responseHeaders, + status: responseStatusCode, + }); + } + `, +}); + +test.describe("File path configuration", () => { + test("uses custom file paths", async ({ page, dev, request }) => { + let { port } = await dev(simpleFiles); + const response = await page.goto(`http://localhost:${port}/`); + + // Verify custom root.tsx and app-routes.ts is being used. + await expect(page.locator("h1")).toHaveText("Custom Root"); + await expect(page.locator("#content div")).toHaveText("Custom IndexRoute"); + + // Verify client entry is being used. + expect( + await page.evaluate(() => (window as any).__customClientEntryExecuted) + ).toBe(true); + + // Verify server entry is used by checking for the custom header. + expect(response?.headers()["x-custom-server-entry"]).toBe("true"); + }); + + test("fails build when custom rootRouteFile doesn't exist", async () => { + let cwd = await createProject({ + "react-router.config.ts": js` + export default { + rootRouteFile: "custom/nonexistent-root.tsx" + }; + `, + }); + let buildResult = build({ cwd }); + expect(buildResult.status).toBe(1); + expect(buildResult.stderr.toString()).toContain( + 'Could not find "root" entry file at' + ); + expect(buildResult.stderr.toString()).toContain("nonexistent-root.tsx"); + }); + + test("fails build when custom routesFile doesn't exist", async () => { + let cwd = await createProject({ + "app/root.tsx": js` + export default function Root() { + return
Root
; + } + `, + "react-router.config.ts": js` + export default { + routesFile: "custom/nonexistent-routes.ts" + }; + `, + }); + let buildResult = build({ cwd }); + expect(buildResult.status).toBe(1); + expect(buildResult.stderr.toString()).toContain( + 'Could not find "routes" entry file at' + ); + expect(buildResult.stderr.toString()).toContain("nonexistent-routes.ts"); + }); + + test("fails build when custom clientEntryFile doesn't exist", async () => { + let cwd = await createProject({ + "app/root.tsx": js` + export default function Root() { + return
Root
; + } + `, + "app/routes.ts": js` + export default []; + `, + "react-router.config.ts": js` + export default { + clientEntryFile: "custom/nonexistent-entry.client.tsx" + }; + `, + }); + let buildResult = build({ cwd }); + expect(buildResult.status).toBe(1); + expect(buildResult.stderr.toString()).toContain( + 'Could not find "entry.client" entry file at' + ); + expect(buildResult.stderr.toString()).toContain("nonexistent-entry.client.tsx"); + }); + + test("fails build when custom serverEntryFile doesn't exist", async () => { + let cwd = await createProject({ + "app/root.tsx": js` + export default function Root() { + return
Root
; + } + `, + "app/routes.ts": js` + export default []; + `, + "react-router.config.ts": js` + export default { + serverEntryFile: "custom/nonexistent-entry.server.tsx" + }; + `, + }); + let buildResult = build({ cwd }); + expect(buildResult.status).toBe(1); + expect(buildResult.stderr.toString()).toContain( + 'Could not find "entry.server" entry file at' + ); + expect(buildResult.stderr.toString()).toContain("nonexistent-entry.server.tsx"); + }); +}); diff --git a/packages/react-router-dev/config/config.ts b/packages/react-router-dev/config/config.ts index 86b6833cd0..246d7b794c 100644 --- a/packages/react-router-dev/config/config.ts +++ b/packages/react-router-dev/config/config.ts @@ -116,6 +116,32 @@ export type ReactRouterConfig = { */ appDirectory?: string; + /** + * The path to the root route module file, relative to the app directory or absolute. + * If not specified, defaults to root. + */ + rootRouteFile?: string; + + /** + * The path to the routes configuration file, relative to the app directory or absolute. + * If not specified, defaults to routes. + */ + routesFile?: string; + + /** + * The path to the client entry file, relative to the app directory or absolute. + * If not specified, defaults entry.client in the app directory, or falls back + * to the default implementation. + */ + clientEntryFile?: string; + + /** + * The path to the server entry file, relative to the app directory or absolute. + * If not specified, defaults to entry.server in the app directory, or falls back + * to the default implementation. + */ + serverEntryFile?: string; + /** * The output format of the server build. Defaults to "esm". */ @@ -231,6 +257,22 @@ export type ResolvedReactRouterConfig = Readonly<{ * SPA without server-rendering. Default's to `true`. */ ssr: boolean; + /** + * The path to the root route module file. + */ + rootRouteFile: string; + /** + * The path to the routes configuration file. + */ + routesFile: string; + /** + * The path to a custom client entry file, if one exists. + */ + clientEntryFile?: string; + /** + * The path to a custom server entry file, if one exists. + */ + serverEntryFile?: string; }>; let mergeReactRouterConfig = ( @@ -392,9 +434,13 @@ async function resolveConfig({ basename, buildDirectory: userBuildDirectory, buildEnd, + clientEntryFile, prerender, + rootRouteFile: userRootRouteFile, + routesFile: userRoutesFile, serverBuildFile, serverBundles, + serverEntryFile, serverModuleFormat, ssr, } = { @@ -422,7 +468,8 @@ async function resolveConfig({ let appDirectory = path.resolve(root, userAppDirectory || "app"); let buildDirectory = path.resolve(root, userBuildDirectory); - let rootRouteFile = findEntry(appDirectory, "root"); + let rootRouteFile = findEntry(appDirectory, "root", userRootRouteFile); + if (!rootRouteFile) { let rootRouteDisplayPath = path.relative( root, @@ -434,10 +481,10 @@ async function resolveConfig({ } let routes: RouteManifest = { - root: { path: "", id: "root", file: rootRouteFile }, + root: { path: "", id: "root", file: path.relative(appDirectory, rootRouteFile) }, }; - let routeConfigFile = findEntry(appDirectory, "routes"); + let routeConfigFile = findEntry(appDirectory, "routes", userRoutesFile); try { if (!routeConfigFile) { @@ -450,9 +497,7 @@ async function resolveConfig({ setAppDirectory(appDirectory); let routeConfigExport = ( - await viteNodeContext.runner.executeFile( - path.join(appDirectory, routeConfigFile) - ) + await viteNodeContext.runner.executeFile(routeConfigFile) ).default; let routeConfig = await routeConfigExport; @@ -513,6 +558,10 @@ async function resolveConfig({ serverBundles, serverModuleFormat, ssr, + rootRouteFile, + routesFile: routeConfigFile, + clientEntryFile, + serverEntryFile, }); for (let preset of reactRouterUserConfig.presets ?? []) { @@ -553,9 +602,7 @@ export async function createConfigLoader({ mode: watch ? "development" : "production", }); - let reactRouterConfigFile = findEntry(root, "react-router.config", { - absolute: true, - }); + let reactRouterConfigFile = findEntry(root, "react-router.config"); let getConfig = () => resolveConfig({ root, viteNodeContext, reactRouterConfigFile }); @@ -670,7 +717,7 @@ export async function resolveEntryFiles({ rootDirectory: string; reactRouterConfig: ResolvedReactRouterConfig; }) { - let { appDirectory } = reactRouterConfig; + let { appDirectory, clientEntryFile, serverEntryFile } = reactRouterConfig; let defaultsDirectory = path.resolve( path.dirname(require.resolve("@react-router/dev/package.json")), @@ -679,18 +726,17 @@ export async function resolveEntryFiles({ "defaults" ); - let userEntryClientFile = findEntry(appDirectory, "entry.client"); - let userEntryServerFile = findEntry(appDirectory, "entry.server"); + let entryClientFilePath = findEntry(appDirectory, "entry.client", clientEntryFile); + let entryServerFilePath = findEntry(appDirectory, "entry.server", serverEntryFile); - let entryServerFile: string; - let entryClientFile = userEntryClientFile || "entry.client.tsx"; + if (!entryClientFilePath) { + entryClientFilePath = path.resolve(defaultsDirectory, "entry.client.tsx"); + } - let pkgJson = await PackageJson.load(rootDirectory); - let deps = pkgJson.content.dependencies ?? {}; + if (!entryServerFilePath) { + let pkgJson = await PackageJson.load(rootDirectory); + let deps = pkgJson.content.dependencies ?? {}; - if (userEntryServerFile) { - entryServerFile = userEntryServerFile; - } else { if (!deps["@react-router/node"]) { throw new Error( `Could not determine server runtime. Please install @react-router/node, or provide a custom entry.server.tsx/jsx file in your app directory.` @@ -719,17 +765,9 @@ export async function resolveEntryFiles({ }); } - entryServerFile = `entry.server.node.tsx`; + entryServerFilePath = path.resolve(defaultsDirectory, "entry.server.node.tsx"); } - let entryClientFilePath = userEntryClientFile - ? path.resolve(reactRouterConfig.appDirectory, userEntryClientFile) - : path.resolve(defaultsDirectory, entryClientFile); - - let entryServerFilePath = userEntryServerFile - ? path.resolve(reactRouterConfig.appDirectory, userEntryServerFile) - : path.resolve(defaultsDirectory, entryServerFile); - return { entryClientFilePath, entryServerFilePath }; } @@ -738,12 +776,22 @@ const entryExts = [".js", ".jsx", ".ts", ".tsx"]; function findEntry( dir: string, basename: string, - options?: { absolute?: boolean } + customPath?: string ): string | undefined { + if (customPath) { + const file = path.resolve(dir, customPath); + if (!fs.existsSync(file)) { + throw new Error(`Could not find "${basename}" entry file at ${file}.`); + } + return file; + } + + // Try all supported extensions. for (let ext of entryExts) { - let file = path.resolve(dir, basename + ext); + const file = path.resolve(dir, basename + ext); + if (fs.existsSync(file)) { - return options?.absolute ?? false ? file : path.relative(dir, file); + return file; } }