From a0d15264c6c490f36c26549706c4b96cce387d44 Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Mon, 14 Oct 2024 11:41:54 +1100 Subject: [PATCH 01/22] Add support for `routes.ts` --- .changeset/popular-humans-attend.md | 5 + integration/vite-route-config-test.ts | 304 ++++++++++ .../remix-dev/__tests__/route-config-test.ts | 530 ++++++++++++++++++ packages/remix-dev/config.ts | 115 +++- packages/remix-dev/config/flat-routes.ts | 8 +- packages/remix-dev/config/routes.ts | 361 +++++++++++- packages/remix-dev/package.json | 2 + packages/remix-dev/rollup.config.js | 1 + packages/remix-dev/routes.ts | 10 + packages/remix-dev/vite/build.ts | 8 +- packages/remix-dev/vite/plugin.ts | 120 ++-- packages/remix-dev/vite/vite-node.ts | 57 ++ pnpm-lock.yaml | 23 +- 13 files changed, 1480 insertions(+), 64 deletions(-) create mode 100644 .changeset/popular-humans-attend.md create mode 100644 integration/vite-route-config-test.ts create mode 100644 packages/remix-dev/__tests__/route-config-test.ts create mode 100644 packages/remix-dev/routes.ts create mode 100644 packages/remix-dev/vite/vite-node.ts diff --git a/.changeset/popular-humans-attend.md b/.changeset/popular-humans-attend.md new file mode 100644 index 00000000000..af5c3b428cd --- /dev/null +++ b/.changeset/popular-humans-attend.md @@ -0,0 +1,5 @@ +--- +"@remix-run/dev": minor +--- + +Add support for `routes.ts` diff --git a/integration/vite-route-config-test.ts b/integration/vite-route-config-test.ts new file mode 100644 index 00000000000..7e0e2b31eec --- /dev/null +++ b/integration/vite-route-config-test.ts @@ -0,0 +1,304 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { expect } from "@playwright/test"; + +import { + type Files, + createProject, + viteBuild, + test, + viteConfig, + createEditor, +} from "./helpers/vite.js"; + +const js = String.raw; + +test.describe("route config", () => { + test("fails the build if routes option is used", async () => { + let cwd = await createProject({ + "vite.config.js": ` + import { vitePlugin as remix } from "@remix-run/dev"; + + export default { + plugins: [remix({ + routes: () => {}, + })] + } + `, + "app/routes.ts": `export default INVALID(`, + }); + let buildResult = viteBuild({ cwd }); + expect(buildResult.status).toBe(1); + expect(buildResult.stderr.toString()).toContain( + 'The "routes" config option is not supported when a "routes.ts" file is present. You should migrate these routes into "routes.ts".' + ); + }); + + test("fails the dev process if routes option is used", async ({ + viteDev, + }) => { + let files: Files = async ({ port }) => ({ + "vite.config.js": ` + import { vitePlugin as remix } from "@remix-run/dev"; + + export default { + ${await viteConfig.server({ port })} + plugins: [remix({ + routes: () => {}, + })] + } + `, + "app/routes.ts": `export default INVALID(`, + }); + let devError: Error | undefined; + try { + await viteDev(files); + } catch (error: any) { + devError = error; + } + expect(devError?.toString()).toContain( + 'The "routes" config option is not supported when a "routes.ts" file is present. You should migrate these routes into "routes.ts".' + ); + }); + + test("fails the build if route config is invalid", async () => { + let cwd = await createProject({ + "app/routes.ts": `export default INVALID(`, + }); + let buildResult = viteBuild({ cwd }); + expect(buildResult.status).toBe(1); + expect(buildResult.stderr.toString()).toContain( + 'Route config in "routes.ts" is invalid.' + ); + }); + + test("fails the dev process if route config is initially invalid", async ({ + viteDev, + }) => { + let files: Files = async ({ port }) => ({ + "vite.config.js": await viteConfig.basic({ port }), + "app/routes.ts": `export default INVALID(`, + }); + let devError: Error | undefined; + try { + await viteDev(files); + } catch (error: any) { + devError = error; + } + expect(devError?.toString()).toContain( + 'Route config in "routes.ts" is invalid.' + ); + }); + + test("supports correcting an invalid route config", async ({ + page, + viteDev, + }) => { + let files: Files = async ({ port }) => ({ + "vite.config.js": await viteConfig.basic({ port }), + "app/routes.ts": js` + import { type RouteConfig } from "@react-router/dev/routes"; + + export const routes: RouteConfig = [ + { + file: "test-route-1.tsx", + index: true, + }, + ]; + `, + "app/test-route-1.tsx": ` + export default () =>
Test route 1
+ `, + "app/test-route-2.tsx": ` + export default () =>
Test route 2
+ `, + }); + let { cwd, port } = await viteDev(files); + + await page.goto(`http://localhost:${port}/`, { waitUntil: "networkidle" }); + await expect(page.locator("[data-test-route]")).toHaveText("Test route 1"); + + let edit = createEditor(cwd); + + // Make config invalid + await edit("app/routes.ts", (contents) => contents + "INVALID"); + + // Ensure dev server is still running with old config + HMR + await edit("app/test-route-1.tsx", (contents) => + contents.replace("Test route 1", "Test route 1 updated") + ); + await expect(page.locator("[data-test-route]")).toHaveText( + "Test route 1 updated" + ); + + // Fix config with new route + await edit("app/routes.ts", (contents) => + contents.replace("INVALID", "").replace("test-route-1", "test-route-2") + ); + + await expect(async () => { + // Reload to pick up new route for current path + await page.reload(); + await expect(page.locator("[data-test-route]")).toHaveText( + "Test route 2" + ); + }).toPass(); + }); + + test("supports correcting an invalid route config module graph", async ({ + page, + viteDev, + }) => { + let files: Files = async ({ port }) => ({ + "vite.config.js": await viteConfig.basic({ port }), + "app/routes.ts": js` + export { routes } from "./actual-routes"; + `, + "app/actual-routes.ts": js` + import { type RouteConfig } from "@react-router/dev/routes"; + + export const routes: RouteConfig = [ + { + file: "test-route-1.tsx", + index: true, + }, + ]; + `, + "app/test-route-1.tsx": ` + export default () =>
Test route 1
+ `, + "app/test-route-2.tsx": ` + export default () =>
Test route 2
+ `, + }); + let { cwd, port } = await viteDev(files); + + await page.goto(`http://localhost:${port}/`, { waitUntil: "networkidle" }); + await expect(page.locator("[data-test-route]")).toHaveText("Test route 1"); + + let edit = createEditor(cwd); + + // Make config invalid + await edit("app/actual-routes.ts", (contents) => contents + "INVALID"); + + // Ensure dev server is still running with old config + HMR + await edit("app/test-route-1.tsx", (contents) => + contents.replace("Test route 1", "Test route 1 updated") + ); + await expect(page.locator("[data-test-route]")).toHaveText( + "Test route 1 updated" + ); + + // Fix config with new route + await edit("app/actual-routes.ts", (contents) => + contents.replace("INVALID", "").replace("test-route-1", "test-route-2") + ); + + await expect(async () => { + // Reload to pick up new route for current path + await page.reload(); + await expect(page.locator("[data-test-route]")).toHaveText( + "Test route 2" + ); + }).toPass(); + }); + + test("supports correcting a missing route config", async ({ + page, + viteDev, + }) => { + let files: Files = async ({ port }) => ({ + "vite.config.js": await viteConfig.basic({ port }), + "app/routes.ts": js` + import { type RouteConfig } from "@react-router/dev/routes"; + + export const routes: RouteConfig = [ + { + file: "test-route-1.tsx", + index: true, + }, + ]; + `, + "app/test-route-1.tsx": ` + export default () =>
Test route 1
+ `, + "app/test-route-2.tsx": ` + export default () =>
Test route 2
+ `, + "app/routes/_index.tsx": ` + export default () =>
FS route
+ `, + }); + let { cwd, port } = await viteDev(files); + + await page.goto(`http://localhost:${port}/`, { waitUntil: "networkidle" }); + await expect(page.locator("[data-test-route]")).toHaveText("Test route 1"); + + let edit = createEditor(cwd); + + let INVALID_FILENAME = "app/routes.ts.oops"; + + // Rename config to make it missing + await fs.rename( + path.join(cwd, "app/routes.ts"), + path.join(cwd, INVALID_FILENAME) + ); + + await expect(async () => { + // Reload to pick up classic FS routes + await page.reload(); + await expect(page.locator("[data-test-route]")).toHaveText("FS route"); + }).toPass(); + + // Ensure dev server falls back to FS routes + HMR + await edit("app/routes/_index.tsx", (contents) => + contents.replace("FS route", "FS route updated") + ); + await expect(page.locator("[data-test-route]")).toHaveText( + "FS route updated" + ); + + // Add new route + await edit(INVALID_FILENAME, (contents) => + contents.replace("test-route-1", "test-route-2") + ); + + // Rename config to bring it back + await fs.rename( + path.join(cwd, INVALID_FILENAME), + path.join(cwd, "app/routes.ts") + ); + + await expect(async () => { + // Reload to pick up new route for current path + await page.reload(); + await expect(page.locator("[data-test-route]")).toHaveText( + "Test route 2" + ); + }).toPass(); + }); + + test("supports absolute route file paths", async ({ page, viteDev }) => { + let files: Files = async ({ port }) => ({ + "vite.config.js": await viteConfig.basic({ port }), + "app/routes.ts": js` + import path from "node:path"; + import { type RouteConfig } from "@react-router/dev/routes"; + + export const routes: RouteConfig = [ + { + file: path.resolve(import.meta.dirname, "test-route.tsx"), + index: true, + }, + ]; + `, + "app/test-route.tsx": ` + export default () =>
Test route
+ `, + }); + let { port } = await viteDev(files); + + await page.goto(`http://localhost:${port}/`, { waitUntil: "networkidle" }); + await expect(page.locator("[data-test-route]")).toHaveText("Test route"); + }); +}); diff --git a/packages/remix-dev/__tests__/route-config-test.ts b/packages/remix-dev/__tests__/route-config-test.ts new file mode 100644 index 00000000000..4fcdddbb4a1 --- /dev/null +++ b/packages/remix-dev/__tests__/route-config-test.ts @@ -0,0 +1,530 @@ +import { + validateRouteConfig, + route, + layout, + index, + prefix, + relative, +} from "../config/routes"; + +describe("route config", () => { + describe("validateRouteConfig", () => { + it("validates a route config", () => { + expect( + validateRouteConfig({ + routeConfigFile: "routes.ts", + routeConfig: prefix("prefix", [ + route("parent", "parent.tsx", [route("child", "child.tsx")]), + ]), + }).valid + ).toBe(true); + }); + + it("is invalid when not an array", () => { + let result = validateRouteConfig({ + routeConfigFile: "routes.ts", + routeConfig: route("path", "file.tsx"), + }); + + expect(result.valid).toBe(false); + expect(!result.valid && result.message).toMatchInlineSnapshot( + `"Route config in "routes.ts" must be an array."` + ); + }); + + it("is invalid when route is a promise", () => { + let result = validateRouteConfig({ + routeConfigFile: "routes.ts", + /* @ts-expect-error */ + routeConfig: [route("parent", "parent.tsx", [Promise.resolve({})])], + }); + + expect(result.valid).toBe(false); + expect(!result.valid && result.message).toMatchInlineSnapshot(` + "Route config in "routes.ts" is invalid. + + Path: routes.0.children.0 + Invalid type: Expected object but received a promise. Did you forget to await?" + `); + }); + + it("is invalid when file is missing", () => { + let result = validateRouteConfig({ + routeConfigFile: "routes.ts", + /* @ts-expect-error */ + routeConfig: [route("parent", "parent.tsx", [{ id: "child" }])], + }); + + expect(result.valid).toBe(false); + expect(!result.valid && result.message).toMatchInlineSnapshot(` + "Route config in "routes.ts" is invalid. + + Path: routes.0.children.0.file + Invalid type: Expected string but received undefined" + `); + }); + + it("is invalid when property is wrong type", () => { + let result = validateRouteConfig({ + routeConfigFile: "routes.ts", + /* @ts-expect-error */ + routeConfig: [route("parent", "parent.tsx", [{ file: 123 }])], + }); + + expect(result.valid).toBe(false); + expect(!result.valid && result.message).toMatchInlineSnapshot(` + "Route config in "routes.ts" is invalid. + + Path: routes.0.children.0.file + Invalid type: Expected string but received 123" + `); + }); + + it("shows multiple error messages", () => { + let result = validateRouteConfig({ + routeConfigFile: "routes.ts", + routeConfig: [ + /* @ts-expect-error */ + route("parent", "parent.tsx", [ + { id: "child" }, + { file: 123 }, + Promise.resolve(), + ]), + ], + }); + + expect(result.valid).toBe(false); + expect(!result.valid && result.message).toMatchInlineSnapshot(` + "Route config in "routes.ts" is invalid. + + Path: routes.0.children.0.file + Invalid type: Expected string but received undefined + + Path: routes.0.children.1.file + Invalid type: Expected string but received 123 + + Path: routes.0.children.2 + Invalid type: Expected object but received a promise. Did you forget to await?" + `); + }); + }); + + describe("route helpers", () => { + describe("route", () => { + it("supports basic routes", () => { + expect(route("path", "file.tsx")).toMatchInlineSnapshot(` + { + "children": undefined, + "file": "file.tsx", + "path": "path", + } + `); + }); + + it("supports children", () => { + expect(route("parent", "parent.tsx", [route("child", "child.tsx")])) + .toMatchInlineSnapshot(` + { + "children": [ + { + "children": undefined, + "file": "child.tsx", + "path": "child", + }, + ], + "file": "parent.tsx", + "path": "parent", + } + `); + }); + + it("supports custom IDs", () => { + expect(route("path", "file.tsx", { id: "custom-id" })) + .toMatchInlineSnapshot(` + { + "children": undefined, + "file": "file.tsx", + "id": "custom-id", + "path": "path", + } + `); + }); + + it("supports custom IDs with children", () => { + expect( + route("parent", "parent.tsx", { id: "custom-id" }, [ + route("child", "child.tsx"), + ]) + ).toMatchInlineSnapshot(` + { + "children": [ + { + "children": undefined, + "file": "child.tsx", + "path": "child", + }, + ], + "file": "parent.tsx", + "id": "custom-id", + "path": "parent", + } + `); + }); + + it("supports case sensitive routes", () => { + expect(route("path", "file.tsx", { caseSensitive: true })) + .toMatchInlineSnapshot(` + { + "caseSensitive": true, + "children": undefined, + "file": "file.tsx", + "path": "path", + } + `); + }); + + it("supports pathless index", () => { + expect(route(null, "file.tsx", { index: true })).toMatchInlineSnapshot(` + { + "children": undefined, + "file": "file.tsx", + "index": true, + "path": undefined, + } + `); + }); + + it("ignores unsupported options", () => { + expect( + // @ts-expect-error unsupportedOption + route(null, "file.tsx", { + index: true, + unsupportedOption: 123, + }) + ).toMatchInlineSnapshot(` + { + "children": undefined, + "file": "file.tsx", + "index": true, + "path": undefined, + } + `); + }); + }); + + describe("index", () => { + it("supports basic routes", () => { + expect(index("file.tsx")).toMatchInlineSnapshot(` + { + "file": "file.tsx", + "index": true, + } + `); + }); + + it("supports custom IDs", () => { + expect(index("file.tsx", { id: "custom-id" })).toMatchInlineSnapshot(` + { + "file": "file.tsx", + "id": "custom-id", + "index": true, + } + `); + }); + + it("ignores unsupported options", () => { + expect( + index("file.tsx", { + id: "custom-id", + // @ts-expect-error + unsupportedOption: 123, + }) + ).toMatchInlineSnapshot(` + { + "file": "file.tsx", + "id": "custom-id", + "index": true, + } + `); + }); + }); + + describe("layout", () => { + it("supports basic routes", () => { + expect(layout("layout.tsx")).toMatchInlineSnapshot(` + { + "children": undefined, + "file": "layout.tsx", + } + `); + }); + + it("supports children", () => { + expect(layout("layout.tsx", [route("child", "child.tsx")])) + .toMatchInlineSnapshot(` + { + "children": [ + { + "children": undefined, + "file": "child.tsx", + "path": "child", + }, + ], + "file": "layout.tsx", + } + `); + }); + + it("supports custom IDs", () => { + expect(layout("layout.tsx", { id: "custom-id" })) + .toMatchInlineSnapshot(` + { + "children": undefined, + "file": "layout.tsx", + "id": "custom-id", + } + `); + }); + + it("supports custom IDs with children", () => { + expect( + layout("layout.tsx", { id: "custom-id" }, [ + route("child", "child.tsx"), + ]) + ).toMatchInlineSnapshot(` + { + "children": [ + { + "children": undefined, + "file": "child.tsx", + "path": "child", + }, + ], + "file": "layout.tsx", + "id": "custom-id", + } + `); + }); + }); + + describe("prefix", () => { + it("adds a prefix to routes", () => { + expect(prefix("prefix", [route("route", "routes/route.tsx")])) + .toMatchInlineSnapshot(` + [ + { + "children": undefined, + "file": "routes/route.tsx", + "path": "prefix/route", + }, + ] + `); + }); + + it("adds a prefix to routes with a blank path", () => { + expect(prefix("prefix", [route("", "routes/route.tsx")])) + .toMatchInlineSnapshot(` + [ + { + "children": undefined, + "file": "routes/route.tsx", + "path": "prefix", + }, + ] + `); + }); + + it("adds a prefix with a trailing slash to routes", () => { + expect(prefix("prefix/", [route("route", "routes/route.tsx")])) + .toMatchInlineSnapshot(` + [ + { + "children": undefined, + "file": "routes/route.tsx", + "path": "prefix/route", + }, + ] + `); + }); + + it("adds a prefix to routes with leading slash", () => { + expect(prefix("prefix", [route("/route", "routes/route.tsx")])) + .toMatchInlineSnapshot(` + [ + { + "children": undefined, + "file": "routes/route.tsx", + "path": "prefix/route", + }, + ] + `); + }); + + it("adds a prefix with a trailing slash to routes with leading slash", () => { + expect(prefix("prefix/", [route("/route", "routes/route.tsx")])) + .toMatchInlineSnapshot(` + [ + { + "children": undefined, + "file": "routes/route.tsx", + "path": "prefix/route", + }, + ] + `); + }); + + it("adds a prefix to index routes", () => { + expect(prefix("prefix", [index("routes/index.tsx")])) + .toMatchInlineSnapshot(` + [ + { + "children": undefined, + "file": "routes/index.tsx", + "index": true, + "path": "prefix", + }, + ] + `); + }); + + it("adds a prefix to children of layout routes", () => { + expect( + prefix("prefix", [ + layout("routes/layout.tsx", [route("route", "routes/route.tsx")]), + ]) + ).toMatchInlineSnapshot(` + [ + { + "children": [ + { + "children": undefined, + "file": "routes/route.tsx", + "path": "prefix/route", + }, + ], + "file": "routes/layout.tsx", + }, + ] + `); + }); + + it("adds a prefix to children of nested layout routes", () => { + expect( + prefix("prefix", [ + layout("routes/layout-1.tsx", [ + route("layout-1-child", "routes/layout-1-child.tsx"), + layout("routes/layout-2.tsx", [ + route("layout-2-child", "routes/layout-2-child.tsx"), + layout("routes/layout-3.tsx", [ + route("layout-3-child", "routes/layout-3-child.tsx"), + ]), + ]), + ]), + ]) + ).toMatchInlineSnapshot(` + [ + { + "children": [ + { + "children": undefined, + "file": "routes/layout-1-child.tsx", + "path": "prefix/layout-1-child", + }, + { + "children": [ + { + "children": undefined, + "file": "routes/layout-2-child.tsx", + "path": "prefix/layout-2-child", + }, + { + "children": [ + { + "children": undefined, + "file": "routes/layout-3-child.tsx", + "path": "prefix/layout-3-child", + }, + ], + "file": "routes/layout-3.tsx", + }, + ], + "file": "routes/layout-2.tsx", + }, + ], + "file": "routes/layout-1.tsx", + }, + ] + `); + }); + }); + + describe("relative", () => { + it("supports relative routes", () => { + let { route } = relative("/path/to/dirname"); + expect( + route("parent", "nested/parent.tsx", [ + route("child", "nested/child.tsx", { id: "child" }), + ]) + ).toMatchInlineSnapshot(` + { + "children": [ + { + "children": undefined, + "file": "/path/to/dirname/nested/child.tsx", + "id": "child", + "path": "child", + }, + ], + "file": "/path/to/dirname/nested/parent.tsx", + "path": "parent", + } + `); + }); + + it("supports relative index routes", () => { + let { index } = relative("/path/to/dirname"); + expect([ + index("nested/without-options.tsx"), + index("nested/with-options.tsx", { id: "with-options" }), + ]).toMatchInlineSnapshot(` + [ + { + "file": "/path/to/dirname/nested/without-options.tsx", + "index": true, + }, + { + "file": "/path/to/dirname/nested/with-options.tsx", + "id": "with-options", + "index": true, + }, + ] + `); + }); + + it("supports relative layout routes", () => { + let { layout } = relative("/path/to/dirname"); + expect( + layout("nested/parent.tsx", [ + layout("nested/child.tsx", { id: "child" }), + ]) + ).toMatchInlineSnapshot(` + { + "children": [ + { + "children": undefined, + "file": "/path/to/dirname/nested/child.tsx", + "id": "child", + }, + ], + "file": "/path/to/dirname/nested/parent.tsx", + } + `); + }); + + it("provides passthrough for non-relative APIs", () => { + let { prefix: relativePrefix } = relative("/path/to/dirname"); + expect(relativePrefix).toBe(prefix); + }); + }); + }); +}); diff --git a/packages/remix-dev/config.ts b/packages/remix-dev/config.ts index 16065ff01af..99ec334f2be 100644 --- a/packages/remix-dev/config.ts +++ b/packages/remix-dev/config.ts @@ -1,12 +1,22 @@ +import type * as Vite from "vite"; import { execSync } from "node:child_process"; import path from "node:path"; import { pathToFileURL } from "node:url"; +import colors from "picocolors"; import fse from "fs-extra"; import PackageJson from "@npmcli/package-json"; import type { NodePolyfillsOptions as EsbuildPluginsNodeModulesPolyfillOptions } from "esbuild-plugins-node-modules-polyfill"; -import type { RouteManifest, DefineRoutesFunction } from "./config/routes"; -import { defineRoutes } from "./config/routes"; +import type * as ViteNode from "./vite/vite-node"; +import { + type RouteManifest, + type RouteConfig, + type DefineRoutesFunction, + setAppDirectory, + validateRouteConfig, + configRoutesToRouteManifest, + defineRoutes, +} from "./config/routes"; import { ServerMode, isValidServerMode } from "./config/serverModes"; import { serverBuildVirtualModule } from "./compiler/server/virtualModules"; import { flatRoutes } from "./config/flat-routes"; @@ -409,16 +419,27 @@ export async function readConfig( }); } +let isFirstLoad = true; +let lastValidRoutes: RouteManifest = {}; + export async function resolveConfig( appConfig: AppConfig, { rootDirectory, serverMode = ServerMode.Production, isSpaMode = false, + routeConfigChanged = false, + vite, + viteUserConfig, + routesViteNodeContext, }: { rootDirectory: string; serverMode?: ServerMode; isSpaMode?: boolean; + routeConfigChanged?: boolean; + vite?: typeof Vite; + viteUserConfig?: Vite.UserConfig; + routesViteNodeContext?: ViteNode.Context; } ): Promise { if (!isValidServerMode(serverMode)) { @@ -556,10 +577,90 @@ export async function resolveConfig( root: { path: "", id: "root", file: rootRouteFile }, }; - if (fse.existsSync(path.resolve(appDirectory, "routes"))) { - let fileRoutes = flatRoutes(appDirectory, appConfig.ignoredRouteFiles); - for (let route of Object.values(fileRoutes)) { - routes[route.id] = { ...route, parentId: route.parentId || "root" }; + setAppDirectory(appDirectory); + let routeConfigFile = findEntry(appDirectory, "routes"); + if (routesViteNodeContext && vite && routeConfigFile) { + class FriendlyError extends Error {} + + let logger = vite.createLogger(viteUserConfig?.logLevel, { + prefix: "[remix]", + }); + + try { + if (appConfig.routes) { + throw new FriendlyError( + 'The "routes" config option is not supported when a "routes.ts" file is present. You should migrate these routes into "routes.ts".' + ); + } + + let routeConfigExport: RouteConfig = ( + await routesViteNodeContext.runner.executeFile( + path.join(appDirectory, routeConfigFile) + ) + ).routes; + + let routeConfig = await routeConfigExport; + + let result = validateRouteConfig({ + routeConfigFile, + routeConfig, + }); + + if (!result.valid) { + throw new FriendlyError(result.message); + } + + routes = { ...routes, ...configRoutesToRouteManifest(routeConfig) }; + + lastValidRoutes = routes; + + if (routeConfigChanged) { + logger.info(colors.green("Route config changed."), { + clear: true, + timestamp: true, + }); + } + } catch (error: any) { + logger.error( + error instanceof FriendlyError + ? colors.red(error.message) + : [ + colors.red(`Route config in "${routeConfigFile}" is invalid.`), + "", + error.loc?.file && error.loc?.column && error.frame + ? [ + path.relative(appDirectory, error.loc.file) + + ":" + + error.loc.line + + ":" + + error.loc.column, + error.frame.trim?.(), + ] + : error.stack, + ] + .flat() + .join("\n") + "\n", + { + error, + clear: !isFirstLoad, + timestamp: !isFirstLoad, + } + ); + + // Bail if this is the first time loading config, otherwise keep the dev server running + if (isFirstLoad) { + process.exit(1); + } + + // Keep dev server running with the last valid routes to allow for correction + routes = lastValidRoutes; + } + } else { + if (fse.existsSync(path.resolve(appDirectory, "routes"))) { + let fileRoutes = flatRoutes(appDirectory, appConfig.ignoredRouteFiles); + for (let route of Object.values(fileRoutes)) { + routes[route.id] = { ...route, parentId: route.parentId || "root" }; + } } } if (appConfig.routes) { @@ -646,6 +747,8 @@ export async function resolveConfig( } } + isFirstLoad = false; + return { appDirectory, cacheDirectory, diff --git a/packages/remix-dev/config/flat-routes.ts b/packages/remix-dev/config/flat-routes.ts index 27518daada9..0aed11cd8f6 100644 --- a/packages/remix-dev/config/flat-routes.ts +++ b/packages/remix-dev/config/flat-routes.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import path from "node:path"; import { makeRe } from "minimatch"; -import type { ConfigRoute, RouteManifest } from "./routes"; +import type { RouteManifestEntry, RouteManifest } from "./routes"; import { normalizeSlashes } from "./routes"; import { findConfig } from "../config"; @@ -130,10 +130,10 @@ export function flatRoutesUniversal( routes: string[], prefix: string = "routes" ): RouteManifest { - let urlConflicts = new Map(); + let urlConflicts = new Map(); let routeManifest: RouteManifest = {}; let prefixLookup = new PrefixLookupTrie(); - let uniqueRoutes = new Map(); + let uniqueRoutes = new Map(); let routeIdConflicts = new Map(); // id -> file @@ -193,7 +193,7 @@ export function flatRoutesUniversal( } // path creation - let parentChildrenMap = new Map(); + let parentChildrenMap = new Map(); for (let [routeId] of sortedRouteIds) { let config = routeManifest[routeId]; if (!config.parentId) continue; diff --git a/packages/remix-dev/config/routes.ts b/packages/remix-dev/config/routes.ts index c793e3bade6..be5068e50e7 100644 --- a/packages/remix-dev/config/routes.ts +++ b/packages/remix-dev/config/routes.ts @@ -1,10 +1,25 @@ -import * as path from "node:path"; +import { resolve, win32 } from "node:path"; +import * as v from "valibot"; +import pick from "lodash/pick"; + +import invariant from "../invariant"; + +let appDirectory: string; + +export function setAppDirectory(directory: string) { + appDirectory = directory; +} /** - * A route that was created using `defineRoutes` or created conventionally from - * looking at the files on the filesystem. + * Provides the absolute path to the app directory, for use within `routes.ts`. + * This is designed to support resolving file system routes. */ -export interface ConfigRoute { +export function getAppDirectory() { + invariant(appDirectory); + return appDirectory; +} + +export interface RouteManifestEntry { /** * The path this route uses to match on the URL pathname. */ @@ -40,7 +55,335 @@ export interface ConfigRoute { } export interface RouteManifest { - [routeId: string]: ConfigRoute; + [routeId: string]: RouteManifestEntry; +} + +/** + * Configuration for an individual route, for use within `routes.ts`. As a + * convenience, route config entries can be created with the {@link route}, + * {@link index} and {@link layout} helper functions. + */ +export interface RouteConfigEntry { + /** + * The unique id for this route. + */ + id?: string; + + /** + * The path this route uses to match on the URL pathname. + */ + path?: string; + + /** + * Should be `true` if it is an index route. This disallows child routes. + */ + index?: boolean; + + /** + * Should be `true` if the `path` is case-sensitive. Defaults to `false`. + */ + caseSensitive?: boolean; + + /** + * The path to the entry point for this route, relative to + * `config.appDirectory`. + */ + file: string; + + /** + * The child routes. + */ + children?: RouteConfigEntry[]; +} + +export const routeConfigEntrySchema: v.BaseSchema< + RouteConfigEntry, + any, + v.BaseIssue +> = v.pipe( + v.custom((value) => { + return !( + typeof value === "object" && + value !== null && + "then" in value && + "catch" in value + ); + }, "Invalid type: Expected object but received a promise. Did you forget to await?"), + v.object({ + id: v.optional(v.string()), + path: v.optional(v.string()), + index: v.optional(v.boolean()), + caseSensitive: v.optional(v.boolean()), + file: v.string(), + children: v.optional(v.array(v.lazy(() => routeConfigEntrySchema))), + }) +); + +export const resolvedRouteConfigSchema = v.array(routeConfigEntrySchema); +type ResolvedRouteConfig = v.InferInput; + +/** + * Route config to be exported via the `routes` export within `routes.ts`. + */ +export type RouteConfig = ResolvedRouteConfig | Promise; + +export function validateRouteConfig({ + routeConfigFile, + routeConfig, +}: { + routeConfigFile: string; + routeConfig: unknown; +}): { valid: false; message: string } | { valid: true } { + if (!routeConfig) { + return { + valid: false, + message: `No "routes" export defined in "${routeConfigFile}.`, + }; + } + + if (!Array.isArray(routeConfig)) { + return { + valid: false, + message: `Route config in "${routeConfigFile}" must be an array.`, + }; + } + + let { issues } = v.safeParse(resolvedRouteConfigSchema, routeConfig); + + if (issues?.length) { + let { root, nested } = v.flatten(issues); + return { + valid: false, + message: [ + `Route config in "${routeConfigFile}" is invalid.`, + root ? `${root}` : [], + nested + ? Object.entries(nested).map( + ([path, message]) => `Path: routes.${path}\n${message}` + ) + : [], + ] + .flat() + .join("\n\n"), + }; + } + + return { valid: true }; +} + +const createConfigRouteOptionKeys = [ + "id", + "index", + "caseSensitive", +] as const satisfies ReadonlyArray; +type CreateRouteOptions = Pick< + RouteConfigEntry, + typeof createConfigRouteOptionKeys[number] +>; +/** + * Helper function for creating a route config entry, for use within + * `routes.ts`. + */ +function route( + path: string | null | undefined, + file: string, + children?: RouteConfigEntry[] +): RouteConfigEntry; +function route( + path: string | null | undefined, + file: string, + options: CreateRouteOptions, + children?: RouteConfigEntry[] +): RouteConfigEntry; +function route( + path: string | null | undefined, + file: string, + optionsOrChildren: CreateRouteOptions | RouteConfigEntry[] | undefined, + children?: RouteConfigEntry[] +): RouteConfigEntry { + let options: CreateRouteOptions = {}; + + if (Array.isArray(optionsOrChildren) || !optionsOrChildren) { + children = optionsOrChildren; + } else { + options = optionsOrChildren; + } + + return { + file, + children, + path: path ?? undefined, + ...pick(options, createConfigRouteOptionKeys), + }; +} + +const createIndexOptionKeys = ["id"] as const satisfies ReadonlyArray< + keyof RouteConfigEntry +>; +type CreateIndexOptions = Pick< + RouteConfigEntry, + typeof createIndexOptionKeys[number] +>; +/** + * Helper function for creating a route config entry for an index route, for use + * within `routes.ts`. + */ +function index(file: string, options?: CreateIndexOptions): RouteConfigEntry { + return { + file, + index: true, + ...pick(options, createIndexOptionKeys), + }; +} + +const createLayoutOptionKeys = ["id"] as const satisfies ReadonlyArray< + keyof RouteConfigEntry +>; +type CreateLayoutOptions = Pick< + RouteConfigEntry, + typeof createLayoutOptionKeys[number] +>; +/** + * Helper function for creating a route config entry for a layout route, for use + * within `routes.ts`. + */ +function layout(file: string, children?: RouteConfigEntry[]): RouteConfigEntry; +function layout( + file: string, + options: CreateLayoutOptions, + children?: RouteConfigEntry[] +): RouteConfigEntry; +function layout( + file: string, + optionsOrChildren: CreateLayoutOptions | RouteConfigEntry[] | undefined, + children?: RouteConfigEntry[] +): RouteConfigEntry { + let options: CreateLayoutOptions = {}; + + if (Array.isArray(optionsOrChildren) || !optionsOrChildren) { + children = optionsOrChildren; + } else { + options = optionsOrChildren; + } + + return { + file, + children, + ...pick(options, createLayoutOptionKeys), + }; +} + +/** + * Helper function for adding a path prefix to a set of routes without needing + * to introduce a parent route file, for use within `routes.ts`. + */ +function prefix( + prefixPath: string, + routes: RouteConfigEntry[] +): RouteConfigEntry[] { + return routes.map((route) => { + if (route.index || typeof route.path === "string") { + return { + ...route, + path: route.path ? joinRoutePaths(prefixPath, route.path) : prefixPath, + children: route.children, + }; + } else if (route.children) { + return { + ...route, + children: prefix(prefixPath, route.children), + }; + } + return route; + }); +} + +const helpers = { route, index, layout, prefix }; +export { route, index, layout, prefix }; +/** + * Creates a set of route config helpers that resolve file paths relative to the + * given directory, for use within `routes.ts`. This is designed to support + * splitting route config into multiple files within different directories. + */ +export function relative(directory: string): typeof helpers { + return { + /** + * Helper function for creating a route config entry, for use within + * `routes.ts`. Note that this helper has been scoped, meaning that file + * path will be resolved relative to the directory provided to the + * `relative` call that created this helper. + */ + route: (path, file, ...rest) => { + return route(path, resolve(directory, file), ...(rest as any)); + }, + /** + * Helper function for creating a route config entry for an index route, for + * use within `routes.ts`. Note that this helper has been scoped, meaning + * that file path will be resolved relative to the directory provided to the + * `relative` call that created this helper. + */ + index: (file, ...rest) => { + return index(resolve(directory, file), ...(rest as any)); + }, + /** + * Helper function for creating a route config entry for a layout route, for + * use within `routes.ts`. Note that this helper has been scoped, meaning + * that file path will be resolved relative to the directory provided to the + * `relative` call that created this helper. + */ + layout: (file, ...rest) => { + return layout(resolve(directory, file), ...(rest as any)); + }, + + // Passthrough of helper functions that don't need relative scoping so that + // a complete API is still provided. + prefix, + }; +} + +export function configRoutesToRouteManifest( + routes: RouteConfigEntry[], + rootId = "root" +): RouteManifest { + let routeManifest: RouteManifest = {}; + + function walk(route: RouteConfigEntry, parentId: string) { + let id = route.id || createRouteId(route.file); + let manifestItem: RouteManifestEntry = { + id, + parentId, + file: route.file, + path: route.path, + index: route.index, + caseSensitive: route.caseSensitive, + }; + + if (routeManifest.hasOwnProperty(id)) { + throw new Error( + `Unable to define routes with duplicate route id: "${id}"` + ); + } + routeManifest[id] = manifestItem; + + if (route.children) { + for (let child of route.children) { + walk(child, id); + } + } + } + + for (let route of routes) { + walk(route, rootId); + } + + return routeManifest; +} + +function joinRoutePaths(path1: string, path2: string): string { + return [ + path1.replace(/\/+$/, ""), // Remove trailing slashes + path2.replace(/^\/+/, ""), // Remove leading slashes + ].join("/"); } export interface DefineRouteOptions { @@ -115,7 +458,7 @@ export function defineRoutes( callback: (defineRoute: DefineRouteFunction) => void ): RouteManifest { let routes: RouteManifest = Object.create(null); - let parentRoutes: ConfigRoute[] = []; + let parentRoutes: RouteManifestEntry[] = []; let alreadyReturned = false; let defineRoute: DefineRouteFunction = ( @@ -143,7 +486,7 @@ export function defineRoutes( options = optionsOrChildren || {}; } - let route: ConfigRoute = { + let route: RouteManifestEntry = { path: path ? path : undefined, index: options.index ? true : undefined, caseSensitive: options.caseSensitive ? true : undefined, @@ -182,9 +525,9 @@ export function createRouteId(file: string) { } export function normalizeSlashes(file: string) { - return file.split(path.win32.sep).join("/"); + return file.split(win32.sep).join("/"); } -function stripFileExtension(file: string) { +export function stripFileExtension(file: string) { return file.replace(/\.[a-z0-9]+$/i, ""); } diff --git a/packages/remix-dev/package.json b/packages/remix-dev/package.json index 0c24f2f1cf5..e8f519c7d47 100644 --- a/packages/remix-dev/package.json +++ b/packages/remix-dev/package.json @@ -73,6 +73,8 @@ "set-cookie-parser": "^2.6.0", "tar-fs": "^2.1.1", "tsconfig-paths": "^4.0.0", + "valibot": "^0.41.0", + "vite-node": "^1.6.0", "ws": "^7.4.5" }, "devDependencies": { diff --git a/packages/remix-dev/rollup.config.js b/packages/remix-dev/rollup.config.js index b1d5fd10951..83d36c525ec 100644 --- a/packages/remix-dev/rollup.config.js +++ b/packages/remix-dev/rollup.config.js @@ -25,6 +25,7 @@ module.exports = function rollup() { }, input: [ `${sourceDir}/index.ts`, + `${sourceDir}/routes.ts`, // Since we're using a dynamic require for the Vite plugin, we // need to tell Rollup it's an entry point `${sourceDir}/vite/plugin.ts`, diff --git a/packages/remix-dev/routes.ts b/packages/remix-dev/routes.ts new file mode 100644 index 00000000000..c4ea5420ec9 --- /dev/null +++ b/packages/remix-dev/routes.ts @@ -0,0 +1,10 @@ +export type { RouteConfig, RouteConfigEntry } from "./config/routes"; + +export { + route, + index, + layout, + prefix, + relative, + getAppDirectory, +} from "./config/routes"; diff --git a/packages/remix-dev/vite/build.ts b/packages/remix-dev/vite/build.ts index 682f3cfefac..e42ad634d81 100644 --- a/packages/remix-dev/vite/build.ts +++ b/packages/remix-dev/vite/build.ts @@ -13,11 +13,11 @@ import { configRouteToBranchRoute, getServerBuildDirectory, } from "./plugin"; -import type { ConfigRoute, RouteManifest } from "../config/routes"; +import type { RouteManifestEntry, RouteManifest } from "../config/routes"; import invariant from "../invariant"; import { preloadViteEsm } from "./import-vite-esm-sync"; -function getAddressableRoutes(routes: RouteManifest): ConfigRoute[] { +function getAddressableRoutes(routes: RouteManifest): RouteManifestEntry[] { let nonAddressableIds = new Set(); for (let id in routes) { @@ -44,11 +44,11 @@ function getAddressableRoutes(routes: RouteManifest): ConfigRoute[] { } function getRouteBranch(routes: RouteManifest, routeId: string) { - let branch: ConfigRoute[] = []; + let branch: RouteManifestEntry[] = []; let currentRouteId: string | undefined = routeId; while (currentRouteId) { - let route: ConfigRoute = routes[currentRouteId]; + let route: RouteManifestEntry = routes[currentRouteId]; invariant(route, `Missing route for ${currentRouteId}`); branch.push(route); currentRouteId = route.parentId; diff --git a/packages/remix-dev/vite/plugin.ts b/packages/remix-dev/vite/plugin.ts index 53ce6400b57..e18bf788568 100644 --- a/packages/remix-dev/vite/plugin.ts +++ b/packages/remix-dev/vite/plugin.ts @@ -20,11 +20,11 @@ import pick from "lodash/pick"; import omit from "lodash/omit"; import colors from "picocolors"; -import { type ConfigRoute, type RouteManifest } from "../config/routes"; +import { type RouteManifestEntry, type RouteManifest } from "../config/routes"; import { type AppConfig as RemixEsbuildUserConfig, type RemixConfig as ResolvedRemixEsbuildConfig, - resolveConfig as resolveRemixEsbuildConfig, + resolveConfig as resolveCommonConfig, findConfig, } from "../config"; import { type Manifest as RemixManifest } from "../manifest"; @@ -40,6 +40,7 @@ import { resolveFileUrl } from "./resolve-file-url"; import { combineURLs } from "./combine-urls"; import { removeExports } from "./remove-exports"; import { importViteEsmSync, preloadViteEsm } from "./import-vite-esm-sync"; +import * as ViteNode from "./vite-node"; export async function resolveViteConfig({ configFile, @@ -143,11 +144,14 @@ const branchRouteProperties = [ "path", "file", "index", -] as const satisfies ReadonlyArray; -type BranchRoute = Pick; +] as const satisfies ReadonlyArray; +type BranchRoute = Pick< + RouteManifestEntry, + typeof branchRouteProperties[number] +>; export const configRouteToBranchRoute = ( - configRoute: ConfigRoute + configRoute: RouteManifestEntry ): BranchRoute => pick(configRoute, branchRouteProperties); export type ServerBundlesFunction = (args: { @@ -293,7 +297,7 @@ let hmrRuntimeId = VirtualModule.id("hmr-runtime"); let injectHmrRuntimeId = VirtualModule.id("inject-hmr-runtime"); const resolveRelativeRouteFilePath = ( - route: ConfigRoute, + route: RouteManifestEntry, remixConfig: ResolvedVitePluginConfig ) => { let vite = importViteEsmSync(); @@ -611,6 +615,28 @@ export const remixVitePlugin: RemixVitePlugin = (remixUserConfig = {}) => { let viteConfig: Vite.ResolvedConfig | undefined; let cssModulesManifest: Record = {}; let viteChildCompiler: Vite.ViteDevServer | null = null; + let routesViteNodeContext: ViteNode.Context | null = null; + + let ssrExternals = isInRemixMonorepo() + ? [ + // This is only needed within the Remix repo because these + // packages are linked to a directory outside of node_modules + // so Vite treats them as internal code by default. + "@remix-run/architect", + "@remix-run/cloudflare-pages", + "@remix-run/cloudflare-workers", + "@remix-run/cloudflare", + "@remix-run/css-bundle", + "@remix-run/deno", + "@remix-run/dev", + "@remix-run/express", + "@remix-run/netlify", + "@remix-run/node", + "@remix-run/react", + "@remix-run/serve", + "@remix-run/server-runtime", + ] + : undefined; // This is initialized by `updateRemixPluginContext` during Vite's `config` // hook, so most of the code can assume this defined without null check. @@ -619,7 +645,11 @@ export const remixVitePlugin: RemixVitePlugin = (remixUserConfig = {}) => { let ctx: RemixPluginContext; /** Mutates `ctx` as a side-effect */ - let updateRemixPluginContext = async (): Promise => { + let updateRemixPluginContext = async ({ + routeConfigChanged = false, + }: { + routeConfigChanged?: boolean; + } = {}): Promise => { let remixConfigPresets: VitePluginConfig[] = ( await Promise.all( (remixUserConfig.presets ?? []).map(async (preset) => { @@ -665,6 +695,7 @@ export const remixVitePlugin: RemixVitePlugin = (remixUserConfig = {}) => { let isSpaMode = !ssr; // Only select the Remix esbuild config options that the Vite plugin uses + invariant(routesViteNodeContext); let { appDirectory, entryClientFilePath, @@ -672,9 +703,16 @@ export const remixVitePlugin: RemixVitePlugin = (remixUserConfig = {}) => { future, routes, serverModuleFormat, - } = await resolveRemixEsbuildConfig( + } = await resolveCommonConfig( pick(resolvedRemixUserConfig, supportedRemixEsbuildConfigKeys), - { rootDirectory, isSpaMode } + { + rootDirectory, + isSpaMode, + vite: importViteEsmSync(), + routeConfigChanged, + viteUserConfig, + routesViteNodeContext, + } ); let buildDirectory = path.resolve( @@ -1008,6 +1046,17 @@ export const remixVitePlugin: RemixVitePlugin = (remixUserConfig = {}) => { viteConfigEnv = _viteConfigEnv; viteCommand = viteConfigEnv.command; + routesViteNodeContext = await ViteNode.createContext({ + root: viteUserConfig.root, + mode: viteConfigEnv.mode, + server: { + watch: viteCommand === "build" ? null : undefined, + }, + ssr: { + external: ssrExternals, + }, + }); + await updateRemixPluginContext(); Object.assign( @@ -1053,26 +1102,7 @@ export const remixVitePlugin: RemixVitePlugin = (remixUserConfig = {}) => { : "custom", ssr: { - external: isInRemixMonorepo() - ? [ - // This is only needed within the Remix repo because these - // packages are linked to a directory outside of node_modules - // so Vite treats them as internal code by default. - "@remix-run/architect", - "@remix-run/cloudflare-pages", - "@remix-run/cloudflare-workers", - "@remix-run/cloudflare", - "@remix-run/css-bundle", - "@remix-run/deno", - "@remix-run/dev", - "@remix-run/express", - "@remix-run/netlify", - "@remix-run/node", - "@remix-run/react", - "@remix-run/serve", - "@remix-run/server-runtime", - ] - : undefined, + external: ssrExternals, }, optimizeDeps: { entries: ctx.remixConfig.future.unstable_optimizeDeps @@ -1342,24 +1372,38 @@ export const remixVitePlugin: RemixVitePlugin = (remixUserConfig = {}) => { }); // Invalidate virtual modules and update cached plugin config via file watcher - viteDevServer.watcher.on("all", async (eventName, filepath) => { + viteDevServer.watcher.on("all", async (eventName, rawFilepath) => { let { normalizePath } = importViteEsmSync(); + let filepath = normalizePath(rawFilepath); let appFileAddedOrRemoved = (eventName === "add" || eventName === "unlink") && - normalizePath(filepath).startsWith( - normalizePath(ctx.remixConfig.appDirectory) - ); + filepath.startsWith(normalizePath(ctx.remixConfig.appDirectory)); invariant(viteConfig?.configFile); let viteConfigChanged = eventName === "change" && - normalizePath(filepath) === normalizePath(viteConfig.configFile); + filepath === normalizePath(viteConfig.configFile); + + let routeConfigChanged = Boolean( + routesViteNodeContext?.devServer?.moduleGraph.getModuleById( + filepath + ) + ); + + if (routeConfigChanged || appFileAddedOrRemoved) { + routesViteNodeContext?.devServer?.moduleGraph.invalidateAll(); + routesViteNodeContext?.runner?.moduleCache.clear(); + } - if (appFileAddedOrRemoved || viteConfigChanged) { + if ( + appFileAddedOrRemoved || + viteConfigChanged || + routeConfigChanged + ) { let lastRemixConfig = ctx.remixConfig; - await updateRemixPluginContext(); + await updateRemixPluginContext({ routeConfigChanged }); if (!isEqualJson(lastRemixConfig, ctx.remixConfig)) { invalidateVirtualModules(viteDevServer); @@ -1849,7 +1893,7 @@ if (import.meta.hot && !inWebWorker) { function getRoute( pluginConfig: ResolvedVitePluginConfig, file: string -): ConfigRoute | undefined { +): RouteManifestEntry | undefined { let vite = importViteEsmSync(); let routePath = vite.normalizePath( path.relative(pluginConfig.appDirectory, file) @@ -1863,7 +1907,7 @@ function getRoute( async function getRouteMetadata( ctx: RemixPluginContext, viteChildCompiler: Vite.ViteDevServer | null, - route: ConfigRoute, + route: RouteManifestEntry, readRouteFile?: () => string | Promise ) { let sourceExports = await getRouteModuleExports( diff --git a/packages/remix-dev/vite/vite-node.ts b/packages/remix-dev/vite/vite-node.ts new file mode 100644 index 00000000000..a7c25c26633 --- /dev/null +++ b/packages/remix-dev/vite/vite-node.ts @@ -0,0 +1,57 @@ +import { ViteNodeServer } from "vite-node/server"; +import { ViteNodeRunner } from "vite-node/client"; +import { installSourcemapsSupport } from "vite-node/source-map"; +import type * as Vite from "vite"; + +import { importViteEsmSync, preloadViteEsm } from "./import-vite-esm-sync"; + +export type Context = { + devServer: Vite.ViteDevServer; + server: ViteNodeServer; + runner: ViteNodeRunner; +}; + +export async function createContext( + viteConfig: Vite.InlineConfig = {} +): Promise { + await preloadViteEsm(); + let vite = importViteEsmSync(); + + let devServer = await vite.createServer( + vite.mergeConfig( + { + server: { + preTransformRequests: false, + hmr: false, + }, + optimizeDeps: { + noDiscovery: true, + }, + configFile: false, + envFile: false, + plugins: [], + }, + viteConfig + ) + ); + await devServer.pluginContainer.buildStart({}); + + let server = new ViteNodeServer(devServer); + + installSourcemapsSupport({ + getSourceMap: (source) => server.getSourceMap(source), + }); + + let runner = new ViteNodeRunner({ + root: devServer.config.root, + base: devServer.config.base, + fetchModule(id) { + return server.fetchModule(id); + }, + resolveId(id, importer) { + return server.resolveId(id, importer); + }, + }); + + return { devServer, server, runner }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c8a81af7576..e8f6f7a83f4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -996,6 +996,12 @@ importers: typescript: specifier: ^5.1.0 version: 5.1.6 + valibot: + specifier: ^0.41.0 + version: 0.41.0(typescript@5.1.6) + vite-node: + specifier: ^1.6.0 + version: 1.6.0(@types/node@18.17.1) ws: specifier: ^7.4.5 version: 7.5.9 @@ -5246,7 +5252,7 @@ packages: mlly: 1.5.0 outdent: 0.8.0 vite: 5.1.3(@types/node@18.17.1) - vite-node: 1.2.2(@types/node@18.17.1) + vite-node: 1.6.0(@types/node@18.17.1) transitivePeerDependencies: - '@types/node' - less @@ -14750,6 +14756,17 @@ packages: '@types/istanbul-lib-coverage': 2.0.3 convert-source-map: 1.8.0 + /valibot@0.41.0(typescript@5.1.6): + resolution: {integrity: sha512-igDBb8CTYr8YTQlOKgaN9nSS0Be7z+WRuaeYqGf3Cjz3aKmSnqEmYnkfVjzIuumGqfHpa3fLIvMEAfhrpqN8ng==} + peerDependencies: + typescript: '>=5' + peerDependenciesMeta: + typescript: + optional: true + dependencies: + typescript: 5.1.6 + dev: false + /validate-npm-package-license@3.0.4: resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} dependencies: @@ -14829,8 +14846,8 @@ packages: - supports-color dev: true - /vite-node@1.2.2(@types/node@18.17.1): - resolution: {integrity: sha512-1as4rDTgVWJO3n1uHmUYqq7nsFgINQ9u+mRcXpjeOMJUmviqNKjcZB7UfRZrlM7MjYXMKpuWp5oGkjaFLnjawg==} + /vite-node@1.6.0(@types/node@18.17.1): + resolution: {integrity: sha512-de6HJgzC+TFzOu0NTC4RAIsyf/DY/ibWDYQUcuEA84EMHhcefTUGkjFHKKEJhQN4A+6I0u++kr3l36ZF2d7XRw==} engines: {node: ^18.0.0 || >=20.0.0} hasBin: true dependencies: From 87bf880bd58a83b727d9cb52da6bbb1cacf0df2d Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Mon, 14 Oct 2024 13:21:21 +1100 Subject: [PATCH 02/22] Clean paths for relative snapshot tests --- .../remix-dev/__tests__/route-config-test.ts | 60 ++++++++++++------- 1 file changed, 39 insertions(+), 21 deletions(-) diff --git a/packages/remix-dev/__tests__/route-config-test.ts b/packages/remix-dev/__tests__/route-config-test.ts index 4fcdddbb4a1..31714575895 100644 --- a/packages/remix-dev/__tests__/route-config-test.ts +++ b/packages/remix-dev/__tests__/route-config-test.ts @@ -1,3 +1,6 @@ +import path from "node:path"; +import { normalizePath } from "vite"; + import { validateRouteConfig, route, @@ -7,6 +10,17 @@ import { relative, } from "../config/routes"; +function cleanPathsForSnapshot(obj: any): any { + return JSON.parse( + JSON.stringify(obj, (key, value) => { + if (typeof value === "string" && path.isAbsolute(value)) { + return normalizePath(value.replace(process.cwd(), "{{CWD}}")); + } + return value; + }) + ); +} + describe("route config", () => { describe("validateRouteConfig", () => { it("validates a route config", () => { @@ -460,40 +474,43 @@ describe("route config", () => { describe("relative", () => { it("supports relative routes", () => { - let { route } = relative("/path/to/dirname"); + let { route } = relative(path.join(process.cwd(), "/path/to/dirname")); expect( - route("parent", "nested/parent.tsx", [ - route("child", "nested/child.tsx", { id: "child" }), - ]) + cleanPathsForSnapshot( + route("parent", "nested/parent.tsx", [ + route("child", "nested/child.tsx", { id: "child" }), + ]) + ) ).toMatchInlineSnapshot(` { "children": [ { - "children": undefined, - "file": "/path/to/dirname/nested/child.tsx", + "file": "{{CWD}}/path/to/dirname/nested/child.tsx", "id": "child", "path": "child", }, ], - "file": "/path/to/dirname/nested/parent.tsx", + "file": "{{CWD}}/path/to/dirname/nested/parent.tsx", "path": "parent", } `); }); it("supports relative index routes", () => { - let { index } = relative("/path/to/dirname"); - expect([ - index("nested/without-options.tsx"), - index("nested/with-options.tsx", { id: "with-options" }), - ]).toMatchInlineSnapshot(` + let { index } = relative(path.join(process.cwd(), "/path/to/dirname")); + expect( + cleanPathsForSnapshot([ + index("nested/without-options.tsx"), + index("nested/with-options.tsx", { id: "with-options" }), + ]) + ).toMatchInlineSnapshot(` [ { - "file": "/path/to/dirname/nested/without-options.tsx", + "file": "{{CWD}}/path/to/dirname/nested/without-options.tsx", "index": true, }, { - "file": "/path/to/dirname/nested/with-options.tsx", + "file": "{{CWD}}/path/to/dirname/nested/with-options.tsx", "id": "with-options", "index": true, }, @@ -502,21 +519,22 @@ describe("route config", () => { }); it("supports relative layout routes", () => { - let { layout } = relative("/path/to/dirname"); + let { layout } = relative(path.join(process.cwd(), "/path/to/dirname")); expect( - layout("nested/parent.tsx", [ - layout("nested/child.tsx", { id: "child" }), - ]) + cleanPathsForSnapshot( + layout("nested/parent.tsx", [ + layout("nested/child.tsx", { id: "child" }), + ]) + ) ).toMatchInlineSnapshot(` { "children": [ { - "children": undefined, - "file": "/path/to/dirname/nested/child.tsx", + "file": "{{CWD}}/path/to/dirname/nested/child.tsx", "id": "child", }, ], - "file": "/path/to/dirname/nested/parent.tsx", + "file": "{{CWD}}/path/to/dirname/nested/parent.tsx", } `); }); From c2bb8afe96892e149c1b33212032cf58d236ba44 Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Mon, 14 Oct 2024 14:09:42 +1100 Subject: [PATCH 03/22] Force new page in Webkit integration tests --- integration/vite-route-config-test.ts | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/integration/vite-route-config-test.ts b/integration/vite-route-config-test.ts index 7e0e2b31eec..07cc15747f1 100644 --- a/integration/vite-route-config-test.ts +++ b/integration/vite-route-config-test.ts @@ -206,6 +206,8 @@ test.describe("route config", () => { test("supports correcting a missing route config", async ({ page, viteDev, + browserName, + context, }) => { let files: Files = async ({ port }) => ({ "vite.config.js": await viteConfig.basic({ port }), @@ -245,8 +247,15 @@ test.describe("route config", () => { ); await expect(async () => { - // Reload to pick up classic FS routes - await page.reload(); + // Force new page instance for webkit. + // Otherwise browser doesn't seem to fetch new manifest probably due to caching. + if (browserName === "webkit") { + page = await context.newPage(); + } + // Reload to pick up new route for current path + await page.goto(`http://localhost:${port}/`, { + waitUntil: "networkidle", + }); await expect(page.locator("[data-test-route]")).toHaveText("FS route"); }).toPass(); @@ -270,8 +279,15 @@ test.describe("route config", () => { ); await expect(async () => { + // Force new page instance for webkit. + // Otherwise browser doesn't seem to fetch new manifest probably due to caching. + if (browserName === "webkit") { + page = await context.newPage(); + } // Reload to pick up new route for current path - await page.reload(); + await page.goto(`http://localhost:${port}/`, { + waitUntil: "networkidle", + }); await expect(page.locator("[data-test-route]")).toHaveText( "Test route 2" ); From feb10d3f64bafe7e5c39f39e55262234861bcb85 Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Mon, 14 Oct 2024 15:39:03 +1100 Subject: [PATCH 04/22] Revert --- integration/vite-route-config-test.ts | 22 +++------------------- 1 file changed, 3 insertions(+), 19 deletions(-) diff --git a/integration/vite-route-config-test.ts b/integration/vite-route-config-test.ts index 07cc15747f1..7e0e2b31eec 100644 --- a/integration/vite-route-config-test.ts +++ b/integration/vite-route-config-test.ts @@ -206,8 +206,6 @@ test.describe("route config", () => { test("supports correcting a missing route config", async ({ page, viteDev, - browserName, - context, }) => { let files: Files = async ({ port }) => ({ "vite.config.js": await viteConfig.basic({ port }), @@ -247,15 +245,8 @@ test.describe("route config", () => { ); await expect(async () => { - // Force new page instance for webkit. - // Otherwise browser doesn't seem to fetch new manifest probably due to caching. - if (browserName === "webkit") { - page = await context.newPage(); - } - // Reload to pick up new route for current path - await page.goto(`http://localhost:${port}/`, { - waitUntil: "networkidle", - }); + // Reload to pick up classic FS routes + await page.reload(); await expect(page.locator("[data-test-route]")).toHaveText("FS route"); }).toPass(); @@ -279,15 +270,8 @@ test.describe("route config", () => { ); await expect(async () => { - // Force new page instance for webkit. - // Otherwise browser doesn't seem to fetch new manifest probably due to caching. - if (browserName === "webkit") { - page = await context.newPage(); - } // Reload to pick up new route for current path - await page.goto(`http://localhost:${port}/`, { - waitUntil: "networkidle", - }); + await page.reload(); await expect(page.locator("[data-test-route]")).toHaveText( "Test route 2" ); From 3a48dfe6d810a5265ea5dccd4e6d18c578b35e8a Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Mon, 14 Oct 2024 16:25:28 +1100 Subject: [PATCH 05/22] Fix integration test in Webkit --- integration/vite-route-config-test.ts | 70 ++++++++++++++++++++++----- 1 file changed, 57 insertions(+), 13 deletions(-) diff --git a/integration/vite-route-config-test.ts b/integration/vite-route-config-test.ts index 7e0e2b31eec..6efddd412d8 100644 --- a/integration/vite-route-config-test.ts +++ b/integration/vite-route-config-test.ts @@ -1,6 +1,6 @@ import fs from "node:fs/promises"; import path from "node:path"; -import { expect } from "@playwright/test"; +import { expect, type BrowserContext, type Page } from "@playwright/test"; import { type Files, @@ -13,6 +13,28 @@ import { const js = String.raw; +// This is a workaround for caching issues in WebKit +async function reloadPage({ + browserName, + page, + context, +}: { + browserName: string; + page: Page; + context: BrowserContext; +}): Promise { + if (browserName === "webkit") { + let newPage = await context.newPage(); + let url = page.url(); + await page.close(); + await newPage.goto(url, { waitUntil: "networkidle" }); + return newPage; + } + + await page.reload(); + return page; +} + test.describe("route config", () => { test("fails the build if routes option is used", async () => { let cwd = await createProject({ @@ -91,7 +113,9 @@ test.describe("route config", () => { }); test("supports correcting an invalid route config", async ({ + browserName, page, + context, viteDev, }) => { let files: Files = async ({ port }) => ({ @@ -107,10 +131,14 @@ test.describe("route config", () => { ]; `, "app/test-route-1.tsx": ` - export default () =>
Test route 1
+ export default function TestRoute1() { + return
Test route 1
+ } `, "app/test-route-2.tsx": ` - export default () =>
Test route 2
+ export default function TestRoute2() { + return
Test route 2
+ } `, }); let { cwd, port } = await viteDev(files); @@ -138,7 +166,7 @@ test.describe("route config", () => { await expect(async () => { // Reload to pick up new route for current path - await page.reload(); + page = await reloadPage({ browserName, page, context }); await expect(page.locator("[data-test-route]")).toHaveText( "Test route 2" ); @@ -147,6 +175,8 @@ test.describe("route config", () => { test("supports correcting an invalid route config module graph", async ({ page, + context, + browserName, viteDev, }) => { let files: Files = async ({ port }) => ({ @@ -165,10 +195,14 @@ test.describe("route config", () => { ]; `, "app/test-route-1.tsx": ` - export default () =>
Test route 1
+ export default function TestRoute1() { + return
Test route 1
+ } `, "app/test-route-2.tsx": ` - export default () =>
Test route 2
+ export default function TestRoute2() { + return
Test route 2
+ } `, }); let { cwd, port } = await viteDev(files); @@ -196,7 +230,7 @@ test.describe("route config", () => { await expect(async () => { // Reload to pick up new route for current path - await page.reload(); + page = await reloadPage({ browserName, page, context }); await expect(page.locator("[data-test-route]")).toHaveText( "Test route 2" ); @@ -204,7 +238,9 @@ test.describe("route config", () => { }); test("supports correcting a missing route config", async ({ + browserName, page, + context, viteDev, }) => { let files: Files = async ({ port }) => ({ @@ -220,13 +256,19 @@ test.describe("route config", () => { ]; `, "app/test-route-1.tsx": ` - export default () =>
Test route 1
+ export default function TestRoute1() { + return
Test route 1
+ } `, "app/test-route-2.tsx": ` - export default () =>
Test route 2
+ export default function TestRoute2() { + return
Test route 2
+ } `, "app/routes/_index.tsx": ` - export default () =>
FS route
+ export default function FsRoute() { + return
FS route
+ } `, }); let { cwd, port } = await viteDev(files); @@ -246,7 +288,7 @@ test.describe("route config", () => { await expect(async () => { // Reload to pick up classic FS routes - await page.reload(); + page = await reloadPage({ browserName, page, context }); await expect(page.locator("[data-test-route]")).toHaveText("FS route"); }).toPass(); @@ -271,7 +313,7 @@ test.describe("route config", () => { await expect(async () => { // Reload to pick up new route for current path - await page.reload(); + page = await reloadPage({ browserName, page, context }); await expect(page.locator("[data-test-route]")).toHaveText( "Test route 2" ); @@ -293,7 +335,9 @@ test.describe("route config", () => { ]; `, "app/test-route.tsx": ` - export default () =>
Test route
+ export default function TestRoute() { + return
Test route
+ } `, }); let { port } = await viteDev(files); From c743f72549b5785dca7fe834868b071ad1d53a41 Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Wed, 16 Oct 2024 15:35:21 +1100 Subject: [PATCH 06/22] Move routes.ts API to `route-config` package --- .../helpers/vite-template/package.json | 1 + integration/vite-route-config-test.ts | 32 +-- jest.config.js | 1 + .../__tests__/validateRouteConfig-test.ts | 141 +++++++++++++ packages/remix-dev/config.ts | 4 +- packages/remix-dev/config/routes.ts | 192 +----------------- packages/remix-dev/index.ts | 5 + packages/remix-dev/rollup.config.js | 1 - packages/remix-dev/routes.ts | 10 - packages/remix-route-config/README.md | 13 ++ .../__tests__/route-config-test.ts | 110 +--------- packages/remix-route-config/index.ts | 13 ++ packages/remix-route-config/jest.config.js | 6 + packages/remix-route-config/package.json | 53 +++++ packages/remix-route-config/rollup.config.js | 45 ++++ packages/remix-route-config/routes.ts | 191 +++++++++++++++++ packages/remix-route-config/tsconfig.json | 19 ++ pnpm-lock.yaml | 24 ++- pnpm-workspace.yaml | 1 + scripts/publish.js | 1 + 20 files changed, 533 insertions(+), 330 deletions(-) create mode 100644 packages/remix-dev/__tests__/validateRouteConfig-test.ts delete mode 100644 packages/remix-dev/routes.ts create mode 100644 packages/remix-route-config/README.md rename packages/{remix-dev => remix-route-config}/__tests__/route-config-test.ts (78%) create mode 100644 packages/remix-route-config/index.ts create mode 100644 packages/remix-route-config/jest.config.js create mode 100644 packages/remix-route-config/package.json create mode 100644 packages/remix-route-config/rollup.config.js create mode 100644 packages/remix-route-config/routes.ts create mode 100644 packages/remix-route-config/tsconfig.json diff --git a/integration/helpers/vite-template/package.json b/integration/helpers/vite-template/package.json index 3d9a59396e3..b2b9b38c7cd 100644 --- a/integration/helpers/vite-template/package.json +++ b/integration/helpers/vite-template/package.json @@ -26,6 +26,7 @@ "devDependencies": { "@remix-run/dev": "workspace:*", "@remix-run/eslint-config": "workspace:*", + "@remix-run/route-config": "workspace:*", "@types/react": "^18.2.20", "@types/react-dom": "^18.2.7", "eslint": "^8.38.0", diff --git a/integration/vite-route-config-test.ts b/integration/vite-route-config-test.ts index 6efddd412d8..d1ec6c3d5bd 100644 --- a/integration/vite-route-config-test.ts +++ b/integration/vite-route-config-test.ts @@ -47,7 +47,7 @@ test.describe("route config", () => { })] } `, - "app/routes.ts": `export default INVALID(`, + "app/routes.ts": `export const routes = [];`, }); let buildResult = viteBuild({ cwd }); expect(buildResult.status).toBe(1); @@ -70,7 +70,7 @@ test.describe("route config", () => { })] } `, - "app/routes.ts": `export default INVALID(`, + "app/routes.ts": `export const routes = [];`, }); let devError: Error | undefined; try { @@ -121,13 +121,10 @@ test.describe("route config", () => { let files: Files = async ({ port }) => ({ "vite.config.js": await viteConfig.basic({ port }), "app/routes.ts": js` - import { type RouteConfig } from "@react-router/dev/routes"; + import { type RouteConfig, index } from "@remix-run/route-config"; export const routes: RouteConfig = [ - { - file: "test-route-1.tsx", - index: true, - }, + index("test-route-1.tsx"), ]; `, "app/test-route-1.tsx": ` @@ -185,13 +182,10 @@ test.describe("route config", () => { export { routes } from "./actual-routes"; `, "app/actual-routes.ts": js` - import { type RouteConfig } from "@react-router/dev/routes"; + import { type RouteConfig, index } from "@remix-run/route-config"; export const routes: RouteConfig = [ - { - file: "test-route-1.tsx", - index: true, - }, + index("test-route-1.tsx"), ]; `, "app/test-route-1.tsx": ` @@ -246,13 +240,10 @@ test.describe("route config", () => { let files: Files = async ({ port }) => ({ "vite.config.js": await viteConfig.basic({ port }), "app/routes.ts": js` - import { type RouteConfig } from "@react-router/dev/routes"; + import { type RouteConfig, index } from "@remix-run/route-config"; export const routes: RouteConfig = [ - { - file: "test-route-1.tsx", - index: true, - }, + index("test-route-1.tsx"), ]; `, "app/test-route-1.tsx": ` @@ -325,13 +316,10 @@ test.describe("route config", () => { "vite.config.js": await viteConfig.basic({ port }), "app/routes.ts": js` import path from "node:path"; - import { type RouteConfig } from "@react-router/dev/routes"; + import { type RouteConfig, index } from "@remix-run/route-config"; export const routes: RouteConfig = [ - { - file: path.resolve(import.meta.dirname, "test-route.tsx"), - index: true, - }, + index(path.resolve(import.meta.dirname, "test-route.tsx")), ]; `, "app/test-route.tsx": ` diff --git a/jest.config.js b/jest.config.js index 5ffd3c56699..f2d8db3a278 100644 --- a/jest.config.js +++ b/jest.config.js @@ -19,6 +19,7 @@ module.exports = { "packages/remix-express", "packages/remix-node", "packages/remix-react", + "packages/remix-route-config", "packages/remix-serve", "packages/remix-server-runtime", "packages/remix-testing", diff --git a/packages/remix-dev/__tests__/validateRouteConfig-test.ts b/packages/remix-dev/__tests__/validateRouteConfig-test.ts new file mode 100644 index 00000000000..2bcf1440e6a --- /dev/null +++ b/packages/remix-dev/__tests__/validateRouteConfig-test.ts @@ -0,0 +1,141 @@ +import { validateRouteConfig } from "../config/routes"; + +describe("validateRouteConfig", () => { + it("validates a route config", () => { + expect( + validateRouteConfig({ + routeConfigFile: "routes.ts", + routeConfig: [ + { + path: "parent", + file: "parent.tsx", + children: [ + { + path: "child", + file: "child.tsx", + }, + ], + }, + ], + }).valid + ).toBe(true); + }); + + it("is invalid when not an array", () => { + let result = validateRouteConfig({ + routeConfigFile: "routes.ts", + routeConfig: { path: "path", file: "file.tsx" }, + }); + + expect(result.valid).toBe(false); + expect(!result.valid && result.message).toMatchInlineSnapshot( + `"Route config in "routes.ts" must be an array."` + ); + }); + + it("is invalid when route is a promise", () => { + let result = validateRouteConfig({ + routeConfigFile: "routes.ts", + routeConfig: [ + { + path: "parent", + file: "parent.tsx", + children: [Promise.resolve({})], + }, + ], + }); + + expect(result.valid).toBe(false); + expect(!result.valid && result.message).toMatchInlineSnapshot(` + "Route config in "routes.ts" is invalid. + + Path: routes.0.children.0 + Invalid type: Expected object but received a promise. Did you forget to await?" + `); + }); + + it("is invalid when file is missing", () => { + let result = validateRouteConfig({ + routeConfigFile: "routes.ts", + routeConfig: [ + { + path: "parent", + file: "parent.tsx", + children: [ + { + id: "child", + }, + ], + }, + ], + }); + + expect(result.valid).toBe(false); + expect(!result.valid && result.message).toMatchInlineSnapshot(` + "Route config in "routes.ts" is invalid. + + Path: routes.0.children.0.file + Invalid type: Expected string but received undefined" + `); + }); + + it("is invalid when property is wrong type", () => { + let result = validateRouteConfig({ + routeConfigFile: "routes.ts", + routeConfig: [ + { + path: "parent", + file: "parent.tsx", + children: [ + { + file: 123, + }, + ], + }, + ], + }); + + expect(result.valid).toBe(false); + expect(!result.valid && result.message).toMatchInlineSnapshot(` + "Route config in "routes.ts" is invalid. + + Path: routes.0.children.0.file + Invalid type: Expected string but received 123" + `); + }); + + it("shows multiple error messages", () => { + let result = validateRouteConfig({ + routeConfigFile: "routes.ts", + routeConfig: [ + { + path: "parent", + file: "parent.tsx", + children: [ + { + id: "child", + }, + { + file: 123, + }, + Promise.resolve(), + ], + }, + ], + }); + + expect(result.valid).toBe(false); + expect(!result.valid && result.message).toMatchInlineSnapshot(` + "Route config in "routes.ts" is invalid. + + Path: routes.0.children.0.file + Invalid type: Expected string but received undefined + + Path: routes.0.children.1.file + Invalid type: Expected string but received 123 + + Path: routes.0.children.2 + Invalid type: Expected object but received a promise. Did you forget to await?" + `); + }); +}); diff --git a/packages/remix-dev/config.ts b/packages/remix-dev/config.ts index 99ec334f2be..0d2a219cdbb 100644 --- a/packages/remix-dev/config.ts +++ b/packages/remix-dev/config.ts @@ -12,7 +12,7 @@ import { type RouteManifest, type RouteConfig, type DefineRoutesFunction, - setAppDirectory, + setRouteConfigAppDirectory, validateRouteConfig, configRoutesToRouteManifest, defineRoutes, @@ -577,7 +577,7 @@ export async function resolveConfig( root: { path: "", id: "root", file: rootRouteFile }, }; - setAppDirectory(appDirectory); + setRouteConfigAppDirectory(appDirectory); let routeConfigFile = findEntry(appDirectory, "routes"); if (routesViteNodeContext && vite && routeConfigFile) { class FriendlyError extends Error {} diff --git a/packages/remix-dev/config/routes.ts b/packages/remix-dev/config/routes.ts index be5068e50e7..fd4dd22cbbf 100644 --- a/packages/remix-dev/config/routes.ts +++ b/packages/remix-dev/config/routes.ts @@ -1,22 +1,21 @@ -import { resolve, win32 } from "node:path"; +import { win32 } from "node:path"; import * as v from "valibot"; -import pick from "lodash/pick"; import invariant from "../invariant"; -let appDirectory: string; +let routeConfigAppDirectory: string; -export function setAppDirectory(directory: string) { - appDirectory = directory; +export function setRouteConfigAppDirectory(directory: string) { + routeConfigAppDirectory = directory; } /** * Provides the absolute path to the app directory, for use within `routes.ts`. * This is designed to support resolving file system routes. */ -export function getAppDirectory() { - invariant(appDirectory); - return appDirectory; +export function getRouteConfigAppDirectory() { + invariant(routeConfigAppDirectory); + return routeConfigAppDirectory; } export interface RouteManifestEntry { @@ -171,176 +170,6 @@ export function validateRouteConfig({ return { valid: true }; } -const createConfigRouteOptionKeys = [ - "id", - "index", - "caseSensitive", -] as const satisfies ReadonlyArray; -type CreateRouteOptions = Pick< - RouteConfigEntry, - typeof createConfigRouteOptionKeys[number] ->; -/** - * Helper function for creating a route config entry, for use within - * `routes.ts`. - */ -function route( - path: string | null | undefined, - file: string, - children?: RouteConfigEntry[] -): RouteConfigEntry; -function route( - path: string | null | undefined, - file: string, - options: CreateRouteOptions, - children?: RouteConfigEntry[] -): RouteConfigEntry; -function route( - path: string | null | undefined, - file: string, - optionsOrChildren: CreateRouteOptions | RouteConfigEntry[] | undefined, - children?: RouteConfigEntry[] -): RouteConfigEntry { - let options: CreateRouteOptions = {}; - - if (Array.isArray(optionsOrChildren) || !optionsOrChildren) { - children = optionsOrChildren; - } else { - options = optionsOrChildren; - } - - return { - file, - children, - path: path ?? undefined, - ...pick(options, createConfigRouteOptionKeys), - }; -} - -const createIndexOptionKeys = ["id"] as const satisfies ReadonlyArray< - keyof RouteConfigEntry ->; -type CreateIndexOptions = Pick< - RouteConfigEntry, - typeof createIndexOptionKeys[number] ->; -/** - * Helper function for creating a route config entry for an index route, for use - * within `routes.ts`. - */ -function index(file: string, options?: CreateIndexOptions): RouteConfigEntry { - return { - file, - index: true, - ...pick(options, createIndexOptionKeys), - }; -} - -const createLayoutOptionKeys = ["id"] as const satisfies ReadonlyArray< - keyof RouteConfigEntry ->; -type CreateLayoutOptions = Pick< - RouteConfigEntry, - typeof createLayoutOptionKeys[number] ->; -/** - * Helper function for creating a route config entry for a layout route, for use - * within `routes.ts`. - */ -function layout(file: string, children?: RouteConfigEntry[]): RouteConfigEntry; -function layout( - file: string, - options: CreateLayoutOptions, - children?: RouteConfigEntry[] -): RouteConfigEntry; -function layout( - file: string, - optionsOrChildren: CreateLayoutOptions | RouteConfigEntry[] | undefined, - children?: RouteConfigEntry[] -): RouteConfigEntry { - let options: CreateLayoutOptions = {}; - - if (Array.isArray(optionsOrChildren) || !optionsOrChildren) { - children = optionsOrChildren; - } else { - options = optionsOrChildren; - } - - return { - file, - children, - ...pick(options, createLayoutOptionKeys), - }; -} - -/** - * Helper function for adding a path prefix to a set of routes without needing - * to introduce a parent route file, for use within `routes.ts`. - */ -function prefix( - prefixPath: string, - routes: RouteConfigEntry[] -): RouteConfigEntry[] { - return routes.map((route) => { - if (route.index || typeof route.path === "string") { - return { - ...route, - path: route.path ? joinRoutePaths(prefixPath, route.path) : prefixPath, - children: route.children, - }; - } else if (route.children) { - return { - ...route, - children: prefix(prefixPath, route.children), - }; - } - return route; - }); -} - -const helpers = { route, index, layout, prefix }; -export { route, index, layout, prefix }; -/** - * Creates a set of route config helpers that resolve file paths relative to the - * given directory, for use within `routes.ts`. This is designed to support - * splitting route config into multiple files within different directories. - */ -export function relative(directory: string): typeof helpers { - return { - /** - * Helper function for creating a route config entry, for use within - * `routes.ts`. Note that this helper has been scoped, meaning that file - * path will be resolved relative to the directory provided to the - * `relative` call that created this helper. - */ - route: (path, file, ...rest) => { - return route(path, resolve(directory, file), ...(rest as any)); - }, - /** - * Helper function for creating a route config entry for an index route, for - * use within `routes.ts`. Note that this helper has been scoped, meaning - * that file path will be resolved relative to the directory provided to the - * `relative` call that created this helper. - */ - index: (file, ...rest) => { - return index(resolve(directory, file), ...(rest as any)); - }, - /** - * Helper function for creating a route config entry for a layout route, for - * use within `routes.ts`. Note that this helper has been scoped, meaning - * that file path will be resolved relative to the directory provided to the - * `relative` call that created this helper. - */ - layout: (file, ...rest) => { - return layout(resolve(directory, file), ...(rest as any)); - }, - - // Passthrough of helper functions that don't need relative scoping so that - // a complete API is still provided. - prefix, - }; -} - export function configRoutesToRouteManifest( routes: RouteConfigEntry[], rootId = "root" @@ -379,13 +208,6 @@ export function configRoutesToRouteManifest( return routeManifest; } -function joinRoutePaths(path1: string, path2: string): string { - return [ - path1.replace(/\/+$/, ""), // Remove trailing slashes - path2.replace(/^\/+/, ""), // Remove leading slashes - ].join("/"); -} - export interface DefineRouteOptions { /** * Should be `true` if the route `path` is case-sensitive. Defaults to diff --git a/packages/remix-dev/index.ts b/packages/remix-dev/index.ts index 1c28706e7c2..38581e081f7 100644 --- a/packages/remix-dev/index.ts +++ b/packages/remix-dev/index.ts @@ -5,6 +5,11 @@ export type { AppConfig, RemixConfig as ResolvedRemixConfig } from "./config"; export * as cli from "./cli/index"; export type { Manifest as AssetsManifest } from "./manifest"; +export type { + RouteConfig as UNSAFE_RouteConfig, + RouteConfigEntry as UNSAFE_RouteConfigEntry, +} from "./config/routes"; +export { getRouteConfigAppDirectory as UNSAFE_getRouteConfigAppDirectory } from "./config/routes"; export { getDependenciesToBundle } from "./dependencies"; export type { BuildManifest, diff --git a/packages/remix-dev/rollup.config.js b/packages/remix-dev/rollup.config.js index 83d36c525ec..b1d5fd10951 100644 --- a/packages/remix-dev/rollup.config.js +++ b/packages/remix-dev/rollup.config.js @@ -25,7 +25,6 @@ module.exports = function rollup() { }, input: [ `${sourceDir}/index.ts`, - `${sourceDir}/routes.ts`, // Since we're using a dynamic require for the Vite plugin, we // need to tell Rollup it's an entry point `${sourceDir}/vite/plugin.ts`, diff --git a/packages/remix-dev/routes.ts b/packages/remix-dev/routes.ts deleted file mode 100644 index c4ea5420ec9..00000000000 --- a/packages/remix-dev/routes.ts +++ /dev/null @@ -1,10 +0,0 @@ -export type { RouteConfig, RouteConfigEntry } from "./config/routes"; - -export { - route, - index, - layout, - prefix, - relative, - getAppDirectory, -} from "./config/routes"; diff --git a/packages/remix-route-config/README.md b/packages/remix-route-config/README.md new file mode 100644 index 00000000000..40685a7476f --- /dev/null +++ b/packages/remix-route-config/README.md @@ -0,0 +1,13 @@ +# Welcome to Remix! + +[Remix](https://remix.run) is a web framework that helps you build better websites with React. + +To get started, open a new shell and run: + +```sh +npx create-remix@latest +``` + +Then follow the prompts you see in your terminal. + +For more information about Remix, [visit remix.run](https://remix.run)! diff --git a/packages/remix-dev/__tests__/route-config-test.ts b/packages/remix-route-config/__tests__/route-config-test.ts similarity index 78% rename from packages/remix-dev/__tests__/route-config-test.ts rename to packages/remix-route-config/__tests__/route-config-test.ts index 31714575895..9fea261db11 100644 --- a/packages/remix-dev/__tests__/route-config-test.ts +++ b/packages/remix-route-config/__tests__/route-config-test.ts @@ -1,14 +1,7 @@ import path from "node:path"; import { normalizePath } from "vite"; -import { - validateRouteConfig, - route, - layout, - index, - prefix, - relative, -} from "../config/routes"; +import { route, layout, index, prefix, relative } from "../routes"; function cleanPathsForSnapshot(obj: any): any { return JSON.parse( @@ -22,107 +15,6 @@ function cleanPathsForSnapshot(obj: any): any { } describe("route config", () => { - describe("validateRouteConfig", () => { - it("validates a route config", () => { - expect( - validateRouteConfig({ - routeConfigFile: "routes.ts", - routeConfig: prefix("prefix", [ - route("parent", "parent.tsx", [route("child", "child.tsx")]), - ]), - }).valid - ).toBe(true); - }); - - it("is invalid when not an array", () => { - let result = validateRouteConfig({ - routeConfigFile: "routes.ts", - routeConfig: route("path", "file.tsx"), - }); - - expect(result.valid).toBe(false); - expect(!result.valid && result.message).toMatchInlineSnapshot( - `"Route config in "routes.ts" must be an array."` - ); - }); - - it("is invalid when route is a promise", () => { - let result = validateRouteConfig({ - routeConfigFile: "routes.ts", - /* @ts-expect-error */ - routeConfig: [route("parent", "parent.tsx", [Promise.resolve({})])], - }); - - expect(result.valid).toBe(false); - expect(!result.valid && result.message).toMatchInlineSnapshot(` - "Route config in "routes.ts" is invalid. - - Path: routes.0.children.0 - Invalid type: Expected object but received a promise. Did you forget to await?" - `); - }); - - it("is invalid when file is missing", () => { - let result = validateRouteConfig({ - routeConfigFile: "routes.ts", - /* @ts-expect-error */ - routeConfig: [route("parent", "parent.tsx", [{ id: "child" }])], - }); - - expect(result.valid).toBe(false); - expect(!result.valid && result.message).toMatchInlineSnapshot(` - "Route config in "routes.ts" is invalid. - - Path: routes.0.children.0.file - Invalid type: Expected string but received undefined" - `); - }); - - it("is invalid when property is wrong type", () => { - let result = validateRouteConfig({ - routeConfigFile: "routes.ts", - /* @ts-expect-error */ - routeConfig: [route("parent", "parent.tsx", [{ file: 123 }])], - }); - - expect(result.valid).toBe(false); - expect(!result.valid && result.message).toMatchInlineSnapshot(` - "Route config in "routes.ts" is invalid. - - Path: routes.0.children.0.file - Invalid type: Expected string but received 123" - `); - }); - - it("shows multiple error messages", () => { - let result = validateRouteConfig({ - routeConfigFile: "routes.ts", - routeConfig: [ - /* @ts-expect-error */ - route("parent", "parent.tsx", [ - { id: "child" }, - { file: 123 }, - Promise.resolve(), - ]), - ], - }); - - expect(result.valid).toBe(false); - expect(!result.valid && result.message).toMatchInlineSnapshot(` - "Route config in "routes.ts" is invalid. - - Path: routes.0.children.0.file - Invalid type: Expected string but received undefined - - Path: routes.0.children.1.file - Invalid type: Expected string but received 123 - - Path: routes.0.children.2 - Invalid type: Expected object but received a promise. Did you forget to await?" - `); - }); - }); - describe("route helpers", () => { describe("route", () => { it("supports basic routes", () => { diff --git a/packages/remix-route-config/index.ts b/packages/remix-route-config/index.ts new file mode 100644 index 00000000000..3a82e4b4b70 --- /dev/null +++ b/packages/remix-route-config/index.ts @@ -0,0 +1,13 @@ +export type { + UNSAFE_RouteConfig as RouteConfig, + UNSAFE_RouteConfigEntry as RouteConfigEntry, +} from "@remix-run/dev"; + +export { + route, + index, + layout, + prefix, + relative, + getAppDirectory, +} from "./routes"; diff --git a/packages/remix-route-config/jest.config.js b/packages/remix-route-config/jest.config.js new file mode 100644 index 00000000000..620d75b4cca --- /dev/null +++ b/packages/remix-route-config/jest.config.js @@ -0,0 +1,6 @@ +/** @type {import('jest').Config} */ +module.exports = { + ...require("../../jest/jest.config.shared"), + displayName: "route-config", + setupFiles: [], +}; diff --git a/packages/remix-route-config/package.json b/packages/remix-route-config/package.json new file mode 100644 index 00000000000..574c8c042c0 --- /dev/null +++ b/packages/remix-route-config/package.json @@ -0,0 +1,53 @@ +{ + "name": "@remix-run/route-config", + "version": "2.13.1", + "description": "Config-based routing for Remix", + "bugs": { + "url": "https://github.com/remix-run/remix/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/remix-run/remix", + "directory": "packages/remix-fs-routes" + }, + "license": "MIT", + "main": "dist/index.js", + "typings": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./package.json": "./package.json" + }, + "scripts": { + "tsc": "tsc" + }, + "dependencies": { + "lodash": "^4.17.21" + }, + "devDependencies": { + "@remix-run/dev": "workspace:*", + "@types/lodash": "^4.14.182", + "typescript": "^5.1.6", + "vite": "5.1.8" + }, + "peerDependencies": { + "@remix-run/dev": "workspace:^", + "typescript": "^5.1.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + }, + "engines": { + "node": ">=18.0.0" + }, + "files": [ + "dist/", + "CHANGELOG.md", + "LICENSE.md", + "README.md" + ] +} diff --git a/packages/remix-route-config/rollup.config.js b/packages/remix-route-config/rollup.config.js new file mode 100644 index 00000000000..fde40570793 --- /dev/null +++ b/packages/remix-route-config/rollup.config.js @@ -0,0 +1,45 @@ +const path = require("node:path"); +const babel = require("@rollup/plugin-babel").default; +const nodeResolve = require("@rollup/plugin-node-resolve").default; +const copy = require("rollup-plugin-copy"); + +const { + copyToPlaygrounds, + createBanner, + getOutputDir, + isBareModuleId, +} = require("../../rollup.utils"); +const { name: packageName, version } = require("./package.json"); + +/** @returns {import("rollup").RollupOptions[]} */ +module.exports = function rollup() { + let sourceDir = "packages/remix-route-config"; + let outputDir = getOutputDir(packageName); + let outputDist = path.join(outputDir, "dist"); + + return [ + { + external: (id) => isBareModuleId(id), + input: `${sourceDir}/index.ts`, + output: { + banner: createBanner(packageName, version), + dir: outputDist, + format: "cjs", + preserveModules: true, + exports: "auto", + }, + plugins: [ + babel({ + babelHelpers: "bundled", + exclude: /node_modules/, + extensions: [".ts"], + }), + nodeResolve({ extensions: [".ts"] }), + copy({ + targets: [{ src: "LICENSE.md", dest: sourceDir }], + }), + copyToPlaygrounds(), + ], + }, + ]; +}; diff --git a/packages/remix-route-config/routes.ts b/packages/remix-route-config/routes.ts new file mode 100644 index 00000000000..d4a16790eb6 --- /dev/null +++ b/packages/remix-route-config/routes.ts @@ -0,0 +1,191 @@ +import { resolve } from "node:path"; +import pick from "lodash/pick"; +import { + type UNSAFE_RouteConfigEntry as RouteConfigEntry, + UNSAFE_getRouteConfigAppDirectory as getRouteConfigAppDirectory, +} from "@remix-run/dev"; + +/** + * Provides the absolute path to the app directory, for use within `routes.ts`. + * This is designed to support resolving file system routes. + */ +export function getAppDirectory() { + return getRouteConfigAppDirectory(); +} + +const createConfigRouteOptionKeys = [ + "id", + "index", + "caseSensitive", +] as const satisfies ReadonlyArray; +type CreateRouteOptions = Pick< + RouteConfigEntry, + typeof createConfigRouteOptionKeys[number] +>; +/** + * Helper function for creating a route config entry, for use within + * `routes.ts`. + */ +function route( + path: string | null | undefined, + file: string, + children?: RouteConfigEntry[] +): RouteConfigEntry; +function route( + path: string | null | undefined, + file: string, + options: CreateRouteOptions, + children?: RouteConfigEntry[] +): RouteConfigEntry; +function route( + path: string | null | undefined, + file: string, + optionsOrChildren: CreateRouteOptions | RouteConfigEntry[] | undefined, + children?: RouteConfigEntry[] +): RouteConfigEntry { + let options: CreateRouteOptions = {}; + + if (Array.isArray(optionsOrChildren) || !optionsOrChildren) { + children = optionsOrChildren; + } else { + options = optionsOrChildren; + } + + return { + file, + children, + path: path ?? undefined, + ...pick(options, createConfigRouteOptionKeys), + }; +} + +const createIndexOptionKeys = ["id"] as const satisfies ReadonlyArray< + keyof RouteConfigEntry +>; +type CreateIndexOptions = Pick< + RouteConfigEntry, + typeof createIndexOptionKeys[number] +>; +/** + * Helper function for creating a route config entry for an index route, for use + * within `routes.ts`. + */ +function index(file: string, options?: CreateIndexOptions): RouteConfigEntry { + return { + file, + index: true, + ...pick(options, createIndexOptionKeys), + }; +} + +const createLayoutOptionKeys = ["id"] as const satisfies ReadonlyArray< + keyof RouteConfigEntry +>; +type CreateLayoutOptions = Pick< + RouteConfigEntry, + typeof createLayoutOptionKeys[number] +>; +/** + * Helper function for creating a route config entry for a layout route, for use + * within `routes.ts`. + */ +function layout(file: string, children?: RouteConfigEntry[]): RouteConfigEntry; +function layout( + file: string, + options: CreateLayoutOptions, + children?: RouteConfigEntry[] +): RouteConfigEntry; +function layout( + file: string, + optionsOrChildren: CreateLayoutOptions | RouteConfigEntry[] | undefined, + children?: RouteConfigEntry[] +): RouteConfigEntry { + let options: CreateLayoutOptions = {}; + + if (Array.isArray(optionsOrChildren) || !optionsOrChildren) { + children = optionsOrChildren; + } else { + options = optionsOrChildren; + } + + return { + file, + children, + ...pick(options, createLayoutOptionKeys), + }; +} + +/** + * Helper function for adding a path prefix to a set of routes without needing + * to introduce a parent route file, for use within `routes.ts`. + */ +function prefix( + prefixPath: string, + routes: RouteConfigEntry[] +): RouteConfigEntry[] { + return routes.map((route) => { + if (route.index || typeof route.path === "string") { + return { + ...route, + path: route.path ? joinRoutePaths(prefixPath, route.path) : prefixPath, + children: route.children, + }; + } else if (route.children) { + return { + ...route, + children: prefix(prefixPath, route.children), + }; + } + return route; + }); +} + +const helpers = { route, index, layout, prefix }; +export { route, index, layout, prefix }; +/** + * Creates a set of route config helpers that resolve file paths relative to the + * given directory, for use within `routes.ts`. This is designed to support + * splitting route config into multiple files within different directories. + */ +export function relative(directory: string): typeof helpers { + return { + /** + * Helper function for creating a route config entry, for use within + * `routes.ts`. Note that this helper has been scoped, meaning that file + * path will be resolved relative to the directory provided to the + * `relative` call that created this helper. + */ + route: (path, file, ...rest) => { + return route(path, resolve(directory, file), ...(rest as any)); + }, + /** + * Helper function for creating a route config entry for an index route, for + * use within `routes.ts`. Note that this helper has been scoped, meaning + * that file path will be resolved relative to the directory provided to the + * `relative` call that created this helper. + */ + index: (file, ...rest) => { + return index(resolve(directory, file), ...(rest as any)); + }, + /** + * Helper function for creating a route config entry for a layout route, for + * use within `routes.ts`. Note that this helper has been scoped, meaning + * that file path will be resolved relative to the directory provided to the + * `relative` call that created this helper. + */ + layout: (file, ...rest) => { + return layout(resolve(directory, file), ...(rest as any)); + }, + + // Passthrough of helper functions that don't need relative scoping so that + // a complete API is still provided. + prefix, + }; +} + +function joinRoutePaths(path1: string, path2: string): string { + return [ + path1.replace(/\/+$/, ""), // Remove trailing slashes + path2.replace(/^\/+/, ""), // Remove leading slashes + ].join("/"); +} diff --git a/packages/remix-route-config/tsconfig.json b/packages/remix-route-config/tsconfig.json new file mode 100644 index 00000000000..2e85dccebf7 --- /dev/null +++ b/packages/remix-route-config/tsconfig.json @@ -0,0 +1,19 @@ +{ + "include": ["**/*.ts"], + "exclude": ["dist", "__tests__", "node_modules"], + "compilerOptions": { + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "target": "ES2022", + "module": "ES2022", + "skipLibCheck": true, + + "moduleResolution": "Bundler", + "allowSyntheticDefaultImports": true, + "strict": true, + "jsx": "react", + "declaration": true, + "emitDeclarationOnly": true, + "rootDir": ".", + "outDir": "./dist" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e7edf9e59c7..964a4a76c3a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -639,6 +639,9 @@ importers: '@remix-run/eslint-config': specifier: workspace:* version: link:../../../packages/remix-eslint-config + '@remix-run/route-config': + specifier: workspace:* + version: link:../../../packages/remix-route-config '@types/react': specifier: ^18.2.20 version: 18.2.20 @@ -1266,6 +1269,25 @@ importers: specifier: ^5.1.6 version: 5.1.6 + packages/remix-route-config: + dependencies: + lodash: + specifier: ^4.17.21 + version: 4.17.21 + devDependencies: + '@remix-run/dev': + specifier: workspace:* + version: link:../remix-dev + '@types/lodash': + specifier: ^4.14.182 + version: 4.14.182 + typescript: + specifier: ^5.1.6 + version: 5.1.6 + vite: + specifier: 5.1.8 + version: 5.1.8(@types/node@18.17.1) + packages/remix-serve: dependencies: '@remix-run/express': @@ -6911,7 +6933,7 @@ packages: object-keys: 1.1.1 object.assign: 4.1.5 regexp.prototype.flags: 1.5.2 - side-channel: 1.0.4 + side-channel: 1.0.6 which-boxed-primitive: 1.0.2 which-collection: 1.0.1 which-typed-array: 1.1.14 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 596042f9a74..799d60287e2 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -18,6 +18,7 @@ packages: - "packages/remix-express" - "packages/remix-node" - "packages/remix-react" + - "packages/remix-route-config" - "packages/remix-serve" - "packages/remix-server-runtime" - "packages/remix-testing" diff --git a/scripts/publish.js b/scripts/publish.js index a50cdcaabab..f891c055e60 100644 --- a/scripts/publish.js +++ b/scripts/publish.js @@ -62,6 +62,7 @@ async function run() { "serve", "css-bundle", "testing", + "route-config", ]) { publish(path.join(buildDir, "@remix-run", name), tag); } From 3c413ffc832daa5a24fb4c553760c7377c06c35c Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Wed, 16 Oct 2024 16:52:05 +1100 Subject: [PATCH 07/22] Add fs-routes package --- .../helpers/node-template/package.json | 2 + integration/vite-fs-routes-test.ts | 481 ++++++++++ jest.config.js | 1 + packages/remix-fs-routes/README.md | 13 + .../__tests__/flatRoutes-test.ts | 879 ++++++++++++++++++ .../routeManifestToRouteConfig-test.ts | 100 ++ packages/remix-fs-routes/flatRoutes.ts | 563 +++++++++++ packages/remix-fs-routes/index.ts | 44 + packages/remix-fs-routes/jest.config.js | 6 + packages/remix-fs-routes/manifest.ts | 53 ++ packages/remix-fs-routes/normalizeSlashes.ts | 5 + packages/remix-fs-routes/package.json | 51 + packages/remix-fs-routes/rollup.config.js | 45 + packages/remix-fs-routes/tsconfig.json | 19 + pnpm-lock.yaml | 19 + pnpm-workspace.yaml | 1 + scripts/publish.js | 1 + 17 files changed, 2283 insertions(+) create mode 100644 integration/vite-fs-routes-test.ts create mode 100644 packages/remix-fs-routes/README.md create mode 100644 packages/remix-fs-routes/__tests__/flatRoutes-test.ts create mode 100644 packages/remix-fs-routes/__tests__/routeManifestToRouteConfig-test.ts create mode 100644 packages/remix-fs-routes/flatRoutes.ts create mode 100644 packages/remix-fs-routes/index.ts create mode 100644 packages/remix-fs-routes/jest.config.js create mode 100644 packages/remix-fs-routes/manifest.ts create mode 100644 packages/remix-fs-routes/normalizeSlashes.ts create mode 100644 packages/remix-fs-routes/package.json create mode 100644 packages/remix-fs-routes/rollup.config.js create mode 100644 packages/remix-fs-routes/tsconfig.json diff --git a/integration/helpers/node-template/package.json b/integration/helpers/node-template/package.json index f57ba9e27d5..1d26acc8cfb 100644 --- a/integration/helpers/node-template/package.json +++ b/integration/helpers/node-template/package.json @@ -23,6 +23,8 @@ }, "devDependencies": { "@remix-run/dev": "workspace:*", + "@remix-run/route-config": "workspace:*", + "@remix-run/fs-routes": "workspace:*", "@vanilla-extract/css": "^1.10.0", "@vanilla-extract/vite-plugin": "^3.9.2", "@types/react": "^18.2.20", diff --git a/integration/vite-fs-routes-test.ts b/integration/vite-fs-routes-test.ts new file mode 100644 index 00000000000..e3709d99967 --- /dev/null +++ b/integration/vite-fs-routes-test.ts @@ -0,0 +1,481 @@ +import { PassThrough } from "node:stream"; +import { test, expect } from "@playwright/test"; + +import { PlaywrightFixture } from "./helpers/playwright-fixture.js"; +import type { Fixture, AppFixture } from "./helpers/create-fixture.js"; +import { createFixtureProject } from "./helpers/create-fixture.js"; +import { + createAppFixture, + createFixture, + js, +} from "./helpers/create-fixture.js"; + +let fixture: Fixture; +let appFixture: AppFixture; + +test.describe("fs-routes", () => { + test.beforeAll(async () => { + fixture = await createFixture({ + compiler: "vite", + files: { + "vite.config.js": js` + import { defineConfig } from "vite"; + import { vitePlugin as remix } from "@remix-run/dev"; + + export default defineConfig({ + plugins: [remix()], + }); + `, + "app/routes.ts": js` + import { type RouteConfig } from "@remix-run/route-config"; + import { flatRoutes } from "@remix-run/fs-routes"; + + export const routes: RouteConfig = flatRoutes({ + rootDirectory: "fs-routes", + ignoredRouteFiles: ["**/ignored-route.*"], + }); + `, + "app/root.tsx": js` + import { Links, Meta, Outlet, Scripts } from "@remix-run/react"; + + export default function Root() { + return ( + + + + + + +
+

Root

+ +
+ + + + ); + } + `, + + "app/fs-routes/_index.tsx": js` + export default function () { + return

Index

; + } + `, + + "app/fs-routes/folder/route.tsx": js` + export default function () { + return

Folder (Route.jsx)

; + } + `, + + "app/fs-routes/folder2/index.tsx": js` + export default function () { + return

Folder (Index.jsx)

; + } + `, + + "app/fs-routes/flat.file.tsx": js` + export default function () { + return

Flat File

; + } + `, + + "app/fs-routes/.dotfile": ` + DOTFILE SHOULD BE IGNORED + `, + + "app/fs-routes/.route-with-unescaped-leading-dot.tsx": js` + throw new Error("This file should be ignored as a route"); + `, + + "app/fs-routes/[.]route-with-escaped-leading-dot.tsx": js` + export default function () { + return

Route With Escaped Leading Dot

; + } + `, + + "app/fs-routes/dashboard/route.tsx": js` + import { Outlet } from "@remix-run/react"; + + export default function () { + return ( + <> +

Dashboard Layout

+ + + ) + } + `, + + "app/fs-routes/dashboard._index/route.tsx": js` + export default function () { + return

Dashboard Index

; + } + `, + + [`app/fs-routes/ignored-route.jsx`]: js` + export default function () { + return

i should 404

; + } + `, + }, + }); + + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test.describe("without JavaScript", () => { + test.use({ javaScriptEnabled: false }); + runTests(); + }); + + test.describe("with JavaScript", () => { + test.use({ javaScriptEnabled: true }); + runTests(); + }); + + function runTests() { + test("renders matching routes (index)", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + expect(await app.getHtml("#content")).toBe(`
+

Root

+

Index

+
`); + }); + + test("renders matching routes (folder route.jsx)", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/folder"); + expect(await app.getHtml("#content")).toBe(`
+

Root

+

Folder (Route.jsx)

+
`); + }); + + test("renders matching routes (folder index.jsx)", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/folder2"); + expect(await app.getHtml("#content")).toBe(`
+

Root

+

Folder (Index.jsx)

+
`); + }); + + test("renders matching routes (flat file)", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/flat/file"); + expect(await app.getHtml("#content")).toBe(`
+

Root

+

Flat File

+
`); + }); + + test("renders matching routes (route with escaped leading dot)", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/.route-with-escaped-leading-dot"); + expect(await app.getHtml("#content")).toBe(`
+

Root

+

Route With Escaped Leading Dot

+
`); + }); + + test("renders matching routes (nested)", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/dashboard"); + expect(await app.getHtml("#content")).toBe(`
+

Root

+

Dashboard Layout

+

Dashboard Index

+
`); + }); + } + + test("allows ignoredRouteFiles to be configured", async () => { + let response = await fixture.requestDocument("/ignored-route"); + + expect(response.status).toBe(404); + }); +}); + +test.describe("emits warnings for route conflicts", async () => { + let buildStdio = new PassThrough(); + let buildOutput: string; + + let originalConsoleLog = console.log; + let originalConsoleWarn = console.warn; + let originalConsoleError = console.error; + + test.beforeAll(async () => { + console.log = () => {}; + console.warn = () => {}; + console.error = () => {}; + await createFixtureProject({ + compiler: "vite", + buildStdio, + files: { + "vite.config.js": js` + import { defineConfig } from "vite"; + import { vitePlugin as remix } from "@remix-run/dev"; + + export default defineConfig({ + plugins: [remix()], + }); + `, + "app/routes.ts": js` + import { type RouteConfig } from "@remix-run/route-config"; + import { flatRoutes } from "@remix-run/fs-routes"; + + export const routes: RouteConfig = flatRoutes({ + rootDirectory: "fs-routes", + }); + `, + "fs-routes/_dashboard._index.tsx": js` + export default function () { + return

routes/_dashboard._index

; + } + `, + "app/fs-routes/_index.tsx": js` + export default function () { + return

routes._index

; + } + `, + "app/fs-routes/_landing._index.tsx": js` + export default function () { + return

routes/_landing._index

; + } + `, + }, + }); + + let chunks: Buffer[] = []; + buildOutput = await new Promise((resolve, reject) => { + buildStdio.on("data", (chunk) => chunks.push(Buffer.from(chunk))); + buildStdio.on("error", (err) => reject(err)); + buildStdio.on("end", () => + resolve(Buffer.concat(chunks).toString("utf8")) + ); + }); + }); + + test.afterAll(() => { + console.log = originalConsoleLog; + console.warn = originalConsoleWarn; + console.error = originalConsoleError; + }); + + test("warns about conflicting routes", () => { + console.log(buildOutput); + expect(buildOutput).toContain(`⚠️ Route Path Collision: "/"`); + }); +}); + +test.describe("", () => { + let buildStdio = new PassThrough(); + let buildOutput: string; + + let originalConsoleLog = console.log; + let originalConsoleWarn = console.warn; + let originalConsoleError = console.error; + + test.beforeAll(async () => { + console.log = () => {}; + console.warn = () => {}; + console.error = () => {}; + await createFixtureProject({ + compiler: "vite", + buildStdio, + files: { + "vite.config.js": js` + import { defineConfig } from "vite"; + import { vitePlugin as remix } from "@remix-run/dev"; + + export default defineConfig({ + plugins: [remix()], + }); + `, + "app/routes.ts": js` + import { type RouteConfig } from "@remix-run/route-config"; + import { flatRoutes } from "@remix-run/fs-routes"; + + export const routes: RouteConfig = flatRoutes({ + rootDirectory: "fs-routes", + }); + `, + "app/fs-routes/_index/route.tsx": js``, + "app/fs-routes/_index/utils.ts": js``, + }, + }); + + let chunks: Buffer[] = []; + buildOutput = await new Promise((resolve, reject) => { + buildStdio.on("data", (chunk) => chunks.push(Buffer.from(chunk))); + buildStdio.on("error", (err) => reject(err)); + buildStdio.on("end", () => + resolve(Buffer.concat(chunks).toString("utf8")) + ); + }); + }); + + test.afterAll(() => { + console.log = originalConsoleLog; + console.warn = originalConsoleWarn; + console.error = originalConsoleError; + }); + + test("doesn't emit a warning for nested index files with co-located files", () => { + expect(buildOutput).not.toContain(`Route Path Collision`); + }); +}); + +test.describe("pathless routes and route collisions", () => { + test.beforeAll(async () => { + fixture = await createFixture({ + compiler: "vite", + files: { + "vite.config.js": js` + import { defineConfig } from "vite"; + import { vitePlugin as remix } from "@remix-run/dev"; + + export default defineConfig({ + plugins: [remix()], + }); + `, + "app/routes.ts": js` + import { type RouteConfig } from "@remix-run/route-config"; + import { flatRoutes } from "@remix-run/fs-routes"; + + export const routes: RouteConfig = flatRoutes({ + rootDirectory: "fs-routes", + }); + `, + "app/root.tsx": js` + import { Link, Outlet, Scripts, useMatches } from "@remix-run/react"; + + export default function App() { + let matches = 'Number of matches: ' + useMatches().length; + return ( + + + +

{matches}

+ + + + + ); + } + `, + "app/fs-routes/nested._index.tsx": js` + export default function Index() { + return

Index

; + } + `, + "app/fs-routes/nested._pathless.tsx": js` + import { Outlet } from "@remix-run/react"; + + export default function Layout() { + return ( + <> +
Pathless Layout
+ + + ); + } + `, + "app/fs-routes/nested._pathless.foo.tsx": js` + export default function Foo() { + return

Foo

; + } + `, + "app/fs-routes/nested._pathless2.tsx": js` + import { Outlet } from "@remix-run/react"; + + export default function Layout() { + return ( + <> +
Pathless 2 Layout
+ + + ); + } + `, + "app/fs-routes/nested._pathless2.bar.tsx": js` + export default function Bar() { + return

Bar

; + } + `, + }, + }); + + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(async () => appFixture.close()); + + test.describe("with JavaScript", () => { + runTests(); + }); + + test.describe("without JavaScript", () => { + test.use({ javaScriptEnabled: false }); + runTests(); + }); + + /** + * Routes for this test look like this, for reference for the matches assertions: + * + * + * + * + * + * + * + * + * + * + */ + + function runTests() { + test("displays index page and not pathless layout page", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/nested"); + expect(await app.getHtml()).toMatch("Index"); + expect(await app.getHtml()).not.toMatch("Pathless Layout"); + expect(await app.getHtml()).toMatch("Number of matches: 2"); + }); + + test("displays page inside of pathless layout", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/nested/foo"); + expect(await app.getHtml()).not.toMatch("Index"); + expect(await app.getHtml()).toMatch("Pathless Layout"); + expect(await app.getHtml()).toMatch("Foo"); + expect(await app.getHtml()).toMatch("Number of matches: 3"); + }); + + // This also asserts that we support multiple sibling pathless route layouts + test("displays page inside of second pathless layout", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/nested/bar"); + expect(await app.getHtml()).not.toMatch("Index"); + expect(await app.getHtml()).toMatch("Pathless 2 Layout"); + expect(await app.getHtml()).toMatch("Bar"); + expect(await app.getHtml()).toMatch("Number of matches: 3"); + }); + } +}); diff --git a/jest.config.js b/jest.config.js index f2d8db3a278..2b9642fbc50 100644 --- a/jest.config.js +++ b/jest.config.js @@ -17,6 +17,7 @@ module.exports = { "packages/remix-dev", "packages/remix-eslint-config", "packages/remix-express", + "packages/remix-fs-routes", "packages/remix-node", "packages/remix-react", "packages/remix-route-config", diff --git a/packages/remix-fs-routes/README.md b/packages/remix-fs-routes/README.md new file mode 100644 index 00000000000..40685a7476f --- /dev/null +++ b/packages/remix-fs-routes/README.md @@ -0,0 +1,13 @@ +# Welcome to Remix! + +[Remix](https://remix.run) is a web framework that helps you build better websites with React. + +To get started, open a new shell and run: + +```sh +npx create-remix@latest +``` + +Then follow the prompts you see in your terminal. + +For more information about Remix, [visit remix.run](https://remix.run)! diff --git a/packages/remix-fs-routes/__tests__/flatRoutes-test.ts b/packages/remix-fs-routes/__tests__/flatRoutes-test.ts new file mode 100644 index 00000000000..a668b88c542 --- /dev/null +++ b/packages/remix-fs-routes/__tests__/flatRoutes-test.ts @@ -0,0 +1,879 @@ +import path from "node:path"; + +import type { RouteManifestEntry } from "../manifest"; +import { + flatRoutesUniversal, + getRoutePathConflictErrorMessage, + getRouteIdConflictErrorMessage, + getRouteSegments, +} from "../flatRoutes"; +import { normalizeSlashes } from "../normalizeSlashes"; + +let APP_DIR = path.join("test", "root", "app"); + +describe("flatRoutes", () => { + describe("creates proper route paths", () => { + let tests: [string, string | undefined][] = [ + ["routes.$", "routes/*"], + ["routes.sub.$", "routes/sub/*"], + ["routes.$slug", "routes/:slug"], + ["routes.sub.$slug", "routes/sub/:slug"], + ["$", "*"], + ["flat.$", "flat/*"], + ["$slug", ":slug"], + ["nested/index", "nested"], + ["nested.$", "*"], + ["nested.$slug", ":slug"], + ["nested._layout.$param", ":param"], + + ["flat.$slug", "flat/:slug"], + ["flat.sub", "flat/sub"], + ["flat._index", "flat"], + ["_index", undefined], + ["_layout/index", undefined], + ["_layout.test", "test"], + ["_layout.$param", ":param"], + ["$slug[.]json", ":slug.json"], + ["sub.[sitemap.xml]", "sub/sitemap.xml"], + ["posts.$slug.[image.jpg]", "posts/:slug/image.jpg"], + ["sub.[[]", "sub/["], + ["sub.]", "sub/]"], + ["sub.[[]]", "sub/[]"], + ["beef]", "beef]"], + ["[index]", "index"], + ["test.inde[x]", "test/index"], + ["[i]ndex.[[].[[]]", "index/[/[]"], + + // Optional segment routes + ["(routes).$", "routes?/*"], + ["(routes).(sub).$", "routes?/sub?/*"], + ["(routes).($slug)", "routes?/:slug?"], + ["(routes).sub.($slug)", "routes?/sub/:slug?"], + ["(nested).$", "nested?/*"], + ["(flat).$", "flat?/*"], + ["($slug)", ":slug?"], + ["(nested).($slug)", "nested?/:slug?"], + ["(flat).($slug)", "flat?/:slug?"], + ["flat.(sub)", "flat/sub?"], + ["_layout.(test)", "test?"], + ["_layout.($user)", ":user?"], + ["(nested)._layout.($param)", "nested?/:param?"], + ["($slug[.]json)", ":slug.json?"], + ["(sub).([sitemap.xml])", "sub?/sitemap.xml?"], + ["(sub).[(sitemap.xml)]", "sub?/(sitemap.xml)"], + ["(posts).($slug).([image.jpg])", "posts?/:slug?/image.jpg?"], + [ + "($[$dollabills]).([.]lol).(what).([$]).($up)", + ":$dollabills?/.lol?/what?/$?/:up?", + ], + ["(sub).(])", "sub?/]?"], + ["(sub).([[]])", "sub?/[]?"], + ["(sub).([[])", "sub?/[?"], + ["(beef])", "beef]?"], + ["([index])", "index?"], + ["(test).(inde[x])", "test?/index?"], + ["([i]ndex).([[]).([[]])", "index?/[?/[]?"], + + // Opting out of parent layout + ["user_.projects.$id.roadmap", "user/projects/:id/roadmap"], + ["app.projects_.$id.roadmap", "app/projects/:id/roadmap"], + ["shop_.projects_.$id.roadmap", "shop/projects/:id/roadmap"], + ]; + + let manifest = flatRoutesUniversal( + APP_DIR, + tests.map((t) => path.join(APP_DIR, "routes", t[0] + ".tsx")) + ); + + for (let [input, expected] of tests) { + it(`"${input}" -> "${expected}"`, () => { + if (input.endsWith("/route") || input.endsWith("/index")) { + input = input.replace(/\/(route|index)$/, ""); + } + let routeInfo = manifest[path.posix.join("routes", input)]; + expect(routeInfo.path).toBe(expected); + }); + } + + let invalidSlashFiles = [ + "($[$dollabills]).([.]lol)[/](what)/([$]).$", + "$[$dollabills].[.]lol[/]what/[$].$", + ]; + + for (let invalid of invalidSlashFiles) { + test("should error when using `/` in a route segment", () => { + let regex = new RegExp( + /Route segment (".*?") for (".*?") cannot contain "\/"/ + ); + expect(() => getRouteSegments(invalid)).toThrow(regex); + }); + } + + let invalidSplatFiles: string[] = [ + "routes/about.[*].tsx", + "routes/about.*.tsx", + "routes/about.[.[.*].].tsx", + ]; + + for (let invalid of invalidSplatFiles) { + test("should error when using `*` in a route segment", () => { + let regex = new RegExp( + /Route segment (".*?") for (".*?") cannot contain "\*"/ + ); + expect(() => getRouteSegments(invalid)).toThrow(regex); + }); + } + + let invalidParamFiles: string[] = [ + "routes/about.[:name].tsx", + "routes/about.:name.tsx", + ]; + + for (let invalid of invalidParamFiles) { + test("should error when using `:` in a route segment", () => { + let regex = new RegExp( + /Route segment (".*?") for (".*?") cannot contain ":"/ + ); + expect(() => getRouteSegments(invalid)).toThrow(regex); + }); + } + }); + + describe("should return the correct route hierarchy", () => { + // we'll add file manually before running the tests + let testFiles: [string, Omit][] = [ + [ + "routes/_auth.tsx", + { + id: "routes/_auth", + parentId: "root", + path: undefined, + }, + ], + [ + "routes/_auth.forgot-password.tsx", + { + id: "routes/_auth.forgot-password", + parentId: "routes/_auth", + path: "forgot-password", + }, + ], + [ + "routes/_auth.login.tsx", + { + id: "routes/_auth.login", + parentId: "routes/_auth", + path: "login", + }, + ], + [ + "routes/_auth.reset-password.tsx", + { + id: "routes/_auth.reset-password", + parentId: "routes/_auth", + path: "reset-password", + }, + ], + [ + "routes/_auth.signup.tsx", + { + id: "routes/_auth.signup", + parentId: "routes/_auth", + path: "signup", + }, + ], + [ + "routes/_landing/index.tsx", + { + id: "routes/_landing", + parentId: "root", + path: undefined, + }, + ], + [ + "routes/_landing._index/index.tsx", + { + id: "routes/_landing._index", + parentId: "routes/_landing", + path: undefined, + index: true, + }, + ], + [ + "routes/_landing.index.tsx", + { + id: "routes/_landing.index", + parentId: "routes/_landing", + path: "index", + }, + ], + [ + "routes/_about.tsx", + { + id: "routes/_about", + parentId: "root", + path: undefined, + }, + ], + [ + "routes/_about.faq.tsx", + { + id: "routes/_about.faq", + parentId: "routes/_about", + path: "faq", + }, + ], + [ + "routes/_about.$splat.tsx", + { + id: "routes/_about.$splat", + parentId: "routes/_about", + path: ":splat", + }, + ], + [ + "routes/app.tsx", + { + id: "routes/app", + parentId: "root", + path: "app", + }, + ], + [ + "routes/app.calendar.$day.tsx", + { + id: "routes/app.calendar.$day", + parentId: "routes/app", + path: "calendar/:day", + }, + ], + [ + "routes/app.calendar._index.tsx", + { + id: "routes/app.calendar._index", + index: true, + parentId: "routes/app", + path: "calendar", + }, + ], + [ + "routes/app.projects.tsx", + { + id: "routes/app.projects", + parentId: "routes/app", + path: "projects", + }, + ], + [ + "routes/app.projects.$id.tsx", + { + id: "routes/app.projects.$id", + parentId: "routes/app.projects", + path: ":id", + }, + ], + [ + "routes/app._pathless.tsx", + { + id: "routes/app._pathless", + parentId: "routes/app", + path: undefined, + }, + ], + [ + "routes/app._pathless._index.tsx", + { + id: "routes/app._pathless._index", + parentId: "routes/app._pathless", + index: true, + path: undefined, + }, + ], + [ + "routes/app._pathless.child.tsx", + { + id: "routes/app._pathless.child", + parentId: "routes/app._pathless", + path: "child", + }, + ], + [ + "routes/folder/route.tsx", + { + id: "routes/folder", + parentId: "root", + path: "folder", + }, + ], + [ + "routes/[route].tsx", + { + id: "routes/[route]", + parentId: "root", + path: "route", + }, + ], + + // Opt out of parent layout + [ + "routes/app_.projects.$id.roadmap[.pdf].tsx", + { + id: "routes/app_.projects.$id.roadmap[.pdf]", + parentId: "root", + path: "app/projects/:id/roadmap.pdf", + }, + ], + [ + "routes/app_.projects.$id.roadmap.tsx", + { + id: "routes/app_.projects.$id.roadmap", + parentId: "root", + path: "app/projects/:id/roadmap", + }, + ], + + [ + "routes/app.skip.tsx", + { + id: "routes/app.skip", + parentId: "routes/app", + path: "skip", + }, + ], + [ + "routes/app.skip_.layout.tsx", + { + id: "routes/app.skip_.layout", + index: undefined, + parentId: "routes/app", + path: "skip/layout", + }, + ], + + [ + "routes/app_.skipall_._index.tsx", + { + id: "routes/app_.skipall_._index", + index: true, + parentId: "root", + path: "app/skipall", + }, + ], + + // Escaping route segments + [ + "routes/_about.[$splat].tsx", + { + id: "routes/_about.[$splat]", + parentId: "routes/_about", + path: "$splat", + }, + ], + [ + "routes/_about.[[].tsx", + { + id: "routes/_about.[[]", + parentId: "routes/_about", + path: "[", + }, + ], + [ + "routes/_about.[]].tsx", + { + id: "routes/_about.[]]", + parentId: "routes/_about", + path: "]", + }, + ], + [ + "routes/_about.[.].tsx", + { + id: "routes/_about.[.]", + parentId: "routes/_about", + path: ".", + }, + ], + + // Optional route segments + [ + "routes/(nested)._layout.($slug).tsx", + { + id: "routes/(nested)._layout.($slug)", + parentId: "root", + path: "nested?/:slug?", + }, + ], + [ + "routes/(routes).$.tsx", + { + id: "routes/(routes).$", + parentId: "root", + path: "routes?/*", + }, + ], + [ + "routes/(routes).(sub).$.tsx", + { + id: "routes/(routes).(sub).$", + parentId: "root", + path: "routes?/sub?/*", + }, + ], + [ + "routes/(routes).($slug).tsx", + { + id: "routes/(routes).($slug)", + parentId: "root", + path: "routes?/:slug?", + }, + ], + [ + "routes/(routes).sub.($slug).tsx", + { + id: "routes/(routes).sub.($slug)", + parentId: "root", + path: "routes?/sub/:slug?", + }, + ], + [ + "routes/(nested).$.tsx", + { + id: "routes/(nested).$", + parentId: "root", + path: "nested?/*", + }, + ], + [ + "routes/(flat).$.tsx", + { + id: "routes/(flat).$", + parentId: "root", + path: "flat?/*", + }, + ], + [ + "routes/(flat).($slug).tsx", + { + id: "routes/(flat).($slug)", + parentId: "root", + path: "flat?/:slug?", + }, + ], + [ + "routes/flat.(sub).tsx", + { + id: "routes/flat.(sub)", + parentId: "root", + path: "flat/sub?", + }, + ], + [ + "routes/_layout.tsx", + { + id: "routes/_layout", + parentId: "root", + path: undefined, + }, + ], + [ + "routes/_layout.(test).tsx", + { + id: "routes/_layout.(test)", + parentId: "routes/_layout", + path: "test?", + }, + ], + [ + "routes/_layout.($slug).tsx", + { + id: "routes/_layout.($slug)", + parentId: "routes/_layout", + path: ":slug?", + }, + ], + + // Optional + escaped route segments + [ + "routes/([_index]).tsx", + { + id: "routes/([_index])", + parentId: "root", + path: "_index?", + }, + ], + [ + "routes/(_[i]ndex).([[]).([[]]).tsx", + { + id: "routes/(_[i]ndex).([[]).([[]])", + parentId: "root", + path: "_index?/[?/[]?", + }, + ], + [ + "routes/(sub).([[]).tsx", + { + id: "routes/(sub).([[])", + parentId: "root", + path: "sub?/[?", + }, + ], + [ + "routes/(sub).(]).tsx", + { + id: "routes/(sub).(])", + parentId: "root", + path: "sub?/]?", + }, + ], + [ + "routes/(sub).([[]]).tsx", + { + id: "routes/(sub).([[]])", + parentId: "root", + path: "sub?/[]?", + }, + ], + [ + "routes/(beef]).tsx", + { + id: "routes/(beef])", + parentId: "root", + path: "beef]?", + }, + ], + [ + "routes/(test).(inde[x]).tsx", + { + id: "routes/(test).(inde[x])", + parentId: "root", + path: "test?/index?", + }, + ], + [ + "routes/($[$dollabills]).([.]lol).(what).([$]).($up).tsx", + { + id: "routes/($[$dollabills]).([.]lol).(what).([$]).($up)", + parentId: "root", + path: ":$dollabills?/.lol?/what?/$?/:up?", + }, + ], + [ + "routes/(posts).($slug).([image.jpg]).tsx", + { + id: "routes/(posts).($slug).([image.jpg])", + parentId: "root", + path: "posts?/:slug?/image.jpg?", + }, + ], + [ + "routes/(sub).([sitemap.xml]).tsx", + { + id: "routes/(sub).([sitemap.xml])", + parentId: "root", + path: "sub?/sitemap.xml?", + }, + ], + [ + "routes/(sub).[(sitemap.xml)].tsx", + { + id: "routes/(sub).[(sitemap.xml)]", + parentId: "root", + path: "sub?/(sitemap.xml)", + }, + ], + [ + "routes/($slug[.]json).tsx", + { + id: "routes/($slug[.]json)", + parentId: "root", + path: ":slug.json?", + }, + ], + + [ + "routes/[]otherstuff].tsx", + { + id: "routes/[]otherstuff]", + parentId: "root", + path: "otherstuff]", + }, + ], + [ + "routes/brand.tsx", + { + id: "routes/brand", + parentId: "root", + path: "brand", + }, + ], + [ + "routes/brand._index.tsx", + { + id: "routes/brand._index", + parentId: "routes/brand", + index: true, + }, + ], + [ + "routes/$.tsx", + { + id: "routes/$", + parentId: "root", + path: "*", + }, + ], + ]; + + let files: [string, RouteManifestEntry][] = testFiles.map( + ([file, route]) => { + return [file, { ...route, file }]; + } + ); + + let routeManifest = flatRoutesUniversal( + APP_DIR, + files.map(([file]) => path.join(APP_DIR, file)) + ); + let routes = Object.values(routeManifest); + + test("route per file", () => { + expect(routes).toHaveLength(files.length); + }); + + for (let [file, route] of files) { + test(`hierarchy for ${file} - ${route.path}`, () => { + expect(routes).toContainEqual(route); + }); + } + }); + + describe("doesn't warn when there's not a route collision", () => { + let consoleError = jest + .spyOn(global.console, "error") + .mockImplementation(() => {}); + + afterEach(consoleError.mockReset); + + test("same number of segments and the same dynamic segment index", () => { + let testFiles = [ + path.join(APP_DIR, "routes", "_user.$username.tsx"), + path.join(APP_DIR, "routes", "sneakers.$sneakerId.tsx"), + ]; + + let routeManifest = flatRoutesUniversal(APP_DIR, testFiles); + + let routes = Object.values(routeManifest); + + expect(routes).toHaveLength(testFiles.length); + expect(consoleError).not.toHaveBeenCalled(); + }); + }); + + describe("warns when there's a route collision", () => { + let consoleError = jest + .spyOn(global.console, "error") + .mockImplementation(() => {}); + + afterEach(consoleError.mockReset); + + test("index files", () => { + let testFiles = [ + path.join("routes", "_dashboard._index.tsx"), + path.join("routes", "_landing._index.tsx"), + path.join("routes", "_index.tsx"), + ]; + + // route manifest uses the full path + let fullPaths = testFiles.map((file) => path.join(APP_DIR, file)); + + // this is for the expected error message, + // which uses the relative path from the app directory internally + let normalizedTestFiles = testFiles.map((file) => normalizeSlashes(file)); + + let routeManifest = flatRoutesUniversal(APP_DIR, fullPaths); + + let routes = Object.values(routeManifest); + + expect(routes).toHaveLength(1); + expect(consoleError).toHaveBeenCalledWith( + getRoutePathConflictErrorMessage("/", normalizedTestFiles) + ); + }); + + test("folder/route.tsx matching folder.tsx", () => { + let testFiles = [ + path.join("routes", "dashboard", "route.tsx"), + path.join("routes", "dashboard.tsx"), + ]; + + // route manifest uses the full path + let fullPaths = testFiles.map((file) => path.join(APP_DIR, file)); + + // this is for the expected error message, + // which uses the relative path from the app directory internally + let normalizedTestFiles = testFiles.map((file) => normalizeSlashes(file)); + + let routeManifest = flatRoutesUniversal(APP_DIR, fullPaths); + + let routes = Object.values(routeManifest); + + expect(routes).toHaveLength(1); + expect(consoleError).toHaveBeenCalledWith( + getRouteIdConflictErrorMessage( + path.posix.join("routes", "dashboard"), + normalizedTestFiles + ) + ); + }); + + test("pathless layouts should not collide", () => { + let testFiles = [ + path.join(APP_DIR, "routes", "_a.tsx"), + path.join(APP_DIR, "routes", "_a._index.tsx"), + path.join(APP_DIR, "routes", "_a.a.tsx"), + path.join(APP_DIR, "routes", "_b.tsx"), + path.join(APP_DIR, "routes", "_b.b.tsx"), + ]; + + let routeManifest = flatRoutesUniversal(APP_DIR, testFiles); + + let routes = Object.values(routeManifest); + + expect(consoleError).not.toHaveBeenCalled(); + expect(routes).toHaveLength(5); + + // When using folders and route.tsx files + testFiles = [ + path.join(APP_DIR, "routes", "_a", "route.tsx"), + path.join(APP_DIR, "routes", "_a._index", "route.tsx"), + path.join(APP_DIR, "routes", "_a.a", "route.tsx"), + path.join(APP_DIR, "routes", "_b", "route.tsx"), + path.join(APP_DIR, "routes", "_b.b", "route.tsx"), + ]; + + routeManifest = flatRoutesUniversal(APP_DIR, testFiles); + + routes = Object.values(routeManifest); + + expect(consoleError).not.toHaveBeenCalled(); + expect(routes).toHaveLength(5); + }); + + test("nested pathless layouts should not collide", () => { + let testFiles = [ + path.join(APP_DIR, "routes", "nested._a.tsx"), + path.join(APP_DIR, "routes", "nested._a._index.tsx"), + path.join(APP_DIR, "routes", "nested._a.a.tsx"), + path.join(APP_DIR, "routes", "nested._b.tsx"), + path.join(APP_DIR, "routes", "nested._b.b.tsx"), + ]; + + let routeManifest = flatRoutesUniversal(APP_DIR, testFiles); + + let routes = Object.values(routeManifest); + + expect(consoleError).not.toHaveBeenCalled(); + expect(routes).toHaveLength(5); + + // When using folders and route.tsx files + testFiles = [ + path.join(APP_DIR, "routes", "nested._a", "route.tsx"), + path.join(APP_DIR, "routes", "nested._a._index", "route.tsx"), + path.join(APP_DIR, "routes", "nested._a.a", "route.tsx"), + path.join(APP_DIR, "routes", "nested._b", "route.tsx"), + path.join(APP_DIR, "routes", "nested._b.b", "route.tsx"), + ]; + + routeManifest = flatRoutesUniversal(APP_DIR, testFiles); + + routes = Object.values(routeManifest); + + expect(consoleError).not.toHaveBeenCalled(); + expect(routes).toHaveLength(5); + }); + + test("legit collisions without nested pathless layouts should collide (paths)", () => { + let testFiles = [ + path.join(APP_DIR, "routes", "nested._a.tsx"), + path.join(APP_DIR, "routes", "nested._a.a.tsx"), + path.join(APP_DIR, "routes", "nested._b.tsx"), + path.join(APP_DIR, "routes", "nested._b.a.tsx"), + ]; + + let routeManifest = flatRoutesUniversal(APP_DIR, testFiles); + + let routes = Object.values(routeManifest); + + expect(consoleError).toHaveBeenCalledWith( + getRoutePathConflictErrorMessage("/nested/a", [ + "routes/nested._a.a.tsx", + "routes/nested._b.a.tsx", + ]) + ); + expect(routes).toHaveLength(3); + + // When using folders and route.tsx files + consoleError.mockClear(); + testFiles = [ + path.join(APP_DIR, "routes", "nested._a", "route.tsx"), + path.join(APP_DIR, "routes", "nested._a.a", "route.tsx"), + path.join(APP_DIR, "routes", "nested._b", "route.tsx"), + path.join(APP_DIR, "routes", "nested._b.a", "route.tsx"), + ]; + + routeManifest = flatRoutesUniversal(APP_DIR, testFiles); + + routes = Object.values(routeManifest); + + expect(consoleError).toHaveBeenCalledWith( + getRoutePathConflictErrorMessage("/nested/a", [ + "routes/nested._a.a/route.tsx", + "routes/nested._b.a/route.tsx", + ]) + ); + expect(routes).toHaveLength(3); + }); + + test("legit collisions without nested pathless layouts should collide (index routes)", () => { + let testFiles = [ + path.join(APP_DIR, "routes", "nested._a.tsx"), + path.join(APP_DIR, "routes", "nested._a._index.tsx"), + path.join(APP_DIR, "routes", "nested._b.tsx"), + path.join(APP_DIR, "routes", "nested._b._index.tsx"), + ]; + + let routeManifest = flatRoutesUniversal(APP_DIR, testFiles); + + let routes = Object.values(routeManifest); + + expect(consoleError).toHaveBeenCalledWith( + getRoutePathConflictErrorMessage("/nested", [ + "routes/nested._a._index.tsx", + "routes/nested._b._index.tsx", + ]) + ); + expect(routes).toHaveLength(3); + + // When using folders and route.tsx files + consoleError.mockClear(); + testFiles = [ + path.join(APP_DIR, "routes", "nested._a", "route.tsx"), + path.join(APP_DIR, "routes", "nested._a._index", "route.tsx"), + path.join(APP_DIR, "routes", "nested._b", "route.tsx"), + path.join(APP_DIR, "routes", "nested._b._index", "route.tsx"), + ]; + + routeManifest = flatRoutesUniversal(APP_DIR, testFiles); + + routes = Object.values(routeManifest); + + expect(consoleError).toHaveBeenCalledWith( + getRoutePathConflictErrorMessage("/nested", [ + "routes/nested._a._index/route.tsx", + "routes/nested._b._index/route.tsx", + ]) + ); + expect(routes).toHaveLength(3); + }); + }); +}); diff --git a/packages/remix-fs-routes/__tests__/routeManifestToRouteConfig-test.ts b/packages/remix-fs-routes/__tests__/routeManifestToRouteConfig-test.ts new file mode 100644 index 00000000000..d4e9be71597 --- /dev/null +++ b/packages/remix-fs-routes/__tests__/routeManifestToRouteConfig-test.ts @@ -0,0 +1,100 @@ +import { route } from "@remix-run/route-config"; + +import { routeManifestToRouteConfig } from "../manifest"; + +const clean = (obj: any) => cleanUndefined(cleanIds(obj)); + +const cleanUndefined = (obj: any) => JSON.parse(JSON.stringify(obj)); + +const cleanIds = (obj: any) => + JSON.parse( + JSON.stringify(obj, function replacer(key, value) { + return key === "id" ? undefined : value; + }) + ); + +describe("routeManifestToRouteConfig", () => { + test("creates route config", () => { + let routeManifestConfig = routeManifestToRouteConfig({ + "routes/home": { + id: "routes/home", + parentId: "root", + path: "/", + file: "routes/home.js", + }, + "routes/inbox": { + id: "routes/inbox", + parentId: "root", + path: "inbox", + file: "routes/inbox.js", + }, + "routes/inbox/index": { + id: "routes/inbox/index", + parentId: "routes/inbox", + path: "/", + file: "routes/inbox/index.js", + index: true, + }, + "routes/inbox/$messageId": { + id: "routes/inbox/$messageId", + parentId: "routes/inbox", + path: ":messageId", + file: "routes/inbox/$messageId.js", + caseSensitive: true, + }, + }); + let routeConfig = [ + route("/", "routes/home.js"), + route("inbox", "routes/inbox.js", [ + route("/", "routes/inbox/index.js", { index: true }), + route(":messageId", "routes/inbox/$messageId.js", { + caseSensitive: true, + }), + ]), + ]; + + expect(clean(routeManifestConfig)).toEqual(clean(routeConfig)); + + expect(cleanUndefined(routeManifestConfig)).toMatchInlineSnapshot(` + [ + { + "file": "routes/home.js", + "id": "routes/home", + "path": "/", + }, + { + "children": [ + { + "file": "routes/inbox/index.js", + "id": "routes/inbox/index", + "index": true, + "path": "/", + }, + { + "caseSensitive": true, + "file": "routes/inbox/$messageId.js", + "id": "routes/inbox/$messageId", + "path": ":messageId", + }, + ], + "file": "routes/inbox.js", + "id": "routes/inbox", + "path": "inbox", + }, + ] + `); + }); + + test("creates route config with IDs", () => { + let routeConfig = routeManifestToRouteConfig({ + home: { + path: "/", + id: "home", + parentId: "root", + file: "routes/home.js", + }, + }); + + expect(routeConfig[0].id).toEqual("home"); + }); +}); diff --git a/packages/remix-fs-routes/flatRoutes.ts b/packages/remix-fs-routes/flatRoutes.ts new file mode 100644 index 00000000000..4195f735a4c --- /dev/null +++ b/packages/remix-fs-routes/flatRoutes.ts @@ -0,0 +1,563 @@ +import fs from "node:fs"; +import path from "node:path"; +import { makeRe } from "minimatch"; + +import type { RouteManifest, RouteManifestEntry } from "./manifest"; +import { normalizeSlashes } from "./normalizeSlashes"; + +export const routeModuleExts = [".js", ".jsx", ".ts", ".tsx", ".md", ".mdx"]; + +export let paramPrefixChar = "$" as const; +export let escapeStart = "[" as const; +export let escapeEnd = "]" as const; + +export let optionalStart = "(" as const; +export let optionalEnd = ")" as const; + +const PrefixLookupTrieEndSymbol = Symbol("PrefixLookupTrieEndSymbol"); +type PrefixLookupNode = { + [key: string]: PrefixLookupNode; +} & Record; + +class PrefixLookupTrie { + root: PrefixLookupNode = { + [PrefixLookupTrieEndSymbol]: false, + }; + + add(value: string) { + if (!value) throw new Error("Cannot add empty string to PrefixLookupTrie"); + + let node = this.root; + for (let char of value) { + if (!node[char]) { + node[char] = { + [PrefixLookupTrieEndSymbol]: false, + }; + } + node = node[char]; + } + node[PrefixLookupTrieEndSymbol] = true; + } + + findAndRemove( + prefix: string, + filter: (nodeValue: string) => boolean + ): string[] { + let node = this.root; + for (let char of prefix) { + if (!node[char]) return []; + node = node[char]; + } + + return this.#findAndRemoveRecursive([], node, prefix, filter); + } + + #findAndRemoveRecursive( + values: string[], + node: PrefixLookupNode, + prefix: string, + filter: (nodeValue: string) => boolean + ): string[] { + for (let char of Object.keys(node)) { + this.#findAndRemoveRecursive(values, node[char], prefix + char, filter); + } + + if (node[PrefixLookupTrieEndSymbol] && filter(prefix)) { + node[PrefixLookupTrieEndSymbol] = false; + values.push(prefix); + } + + return values; + } +} + +export function flatRoutes( + appDirectory: string, + ignoredFilePatterns: string[] = [], + prefix = "routes" +) { + let ignoredFileRegex = Array.from(new Set(["**/.*", ...ignoredFilePatterns])) + .map((re) => makeRe(re)) + .filter((re: any): re is RegExp => !!re); + let routesDir = path.join(appDirectory, prefix); + + let rootRoute = findFile(appDirectory, "root", routeModuleExts); + + if (!rootRoute) { + throw new Error( + `Could not find a root route module in the app directory: ${appDirectory}` + ); + } + + if (!fs.existsSync(rootRoute)) { + throw new Error( + `Could not find the routes directory: ${routesDir}. Did you forget to create it?` + ); + } + + // Only read the routes directory + let entries = fs.readdirSync(routesDir, { + withFileTypes: true, + encoding: "utf-8", + }); + + let routes: string[] = []; + for (let entry of entries) { + let filepath = normalizeSlashes(path.join(routesDir, entry.name)); + + let route: string | null = null; + // If it's a directory, don't recurse into it, instead just look for a route module + if (entry.isDirectory()) { + route = findRouteModuleForFolder( + appDirectory, + filepath, + ignoredFileRegex + ); + } else if (entry.isFile()) { + route = findRouteModuleForFile(appDirectory, filepath, ignoredFileRegex); + } + + if (route) routes.push(route); + } + + let routeManifest = flatRoutesUniversal(appDirectory, routes, prefix); + return routeManifest; +} + +export function flatRoutesUniversal( + appDirectory: string, + routes: string[], + prefix: string = "routes" +): RouteManifest { + let urlConflicts = new Map(); + let routeManifest: RouteManifest = {}; + let prefixLookup = new PrefixLookupTrie(); + let uniqueRoutes = new Map(); + let routeIdConflicts = new Map(); + + // id -> file + let routeIds = new Map(); + + for (let file of routes) { + let normalizedFile = normalizeSlashes(file); + let routeExt = path.extname(normalizedFile); + let routeDir = path.dirname(normalizedFile); + let normalizedApp = normalizeSlashes(appDirectory); + let routeId = + routeDir === path.posix.join(normalizedApp, prefix) + ? path.posix + .relative(normalizedApp, normalizedFile) + .slice(0, -routeExt.length) + : path.posix.relative(normalizedApp, routeDir); + + let conflict = routeIds.get(routeId); + if (conflict) { + let currentConflicts = routeIdConflicts.get(routeId); + if (!currentConflicts) { + currentConflicts = [path.posix.relative(normalizedApp, conflict)]; + } + currentConflicts.push(path.posix.relative(normalizedApp, normalizedFile)); + routeIdConflicts.set(routeId, currentConflicts); + continue; + } + + routeIds.set(routeId, normalizedFile); + } + + let sortedRouteIds = Array.from(routeIds).sort( + ([a], [b]) => b.length - a.length + ); + + for (let [routeId, file] of sortedRouteIds) { + let index = routeId.endsWith("_index"); + let [segments, raw] = getRouteSegments(routeId.slice(prefix.length + 1)); + let pathname = createRoutePath(segments, raw, index); + + routeManifest[routeId] = { + file: file.slice(appDirectory.length + 1), + id: routeId, + path: pathname, + }; + if (index) routeManifest[routeId].index = true; + let childRouteIds = prefixLookup.findAndRemove(routeId, (value) => { + return [".", "/"].includes(value.slice(routeId.length).charAt(0)); + }); + prefixLookup.add(routeId); + + if (childRouteIds.length > 0) { + for (let childRouteId of childRouteIds) { + routeManifest[childRouteId].parentId = routeId; + } + } + } + + // path creation + let parentChildrenMap = new Map(); + for (let [routeId] of sortedRouteIds) { + let config = routeManifest[routeId]; + if (!config.parentId) continue; + let existingChildren = parentChildrenMap.get(config.parentId) || []; + existingChildren.push(config); + parentChildrenMap.set(config.parentId, existingChildren); + } + + for (let [routeId] of sortedRouteIds) { + let config = routeManifest[routeId]; + let originalPathname = config.path || ""; + let pathname = config.path; + let parentConfig = config.parentId ? routeManifest[config.parentId] : null; + if (parentConfig?.path && pathname) { + pathname = pathname + .slice(parentConfig.path.length) + .replace(/^\//, "") + .replace(/\/$/, ""); + } + + if (!config.parentId) config.parentId = "root"; + config.path = pathname || undefined; + + /** + * We do not try to detect path collisions for pathless layout route + * files because, by definition, they create the potential for route + * collisions _at that level in the tree_. + * + * Consider example where a user may want multiple pathless layout routes + * for different subfolders + * + * routes/ + * account.tsx + * account._private.tsx + * account._private.orders.tsx + * account._private.profile.tsx + * account._public.tsx + * account._public.login.tsx + * account._public.perks.tsx + * + * In order to support both a public and private layout for `/account/*` + * URLs, we are creating a mutually exclusive set of URLs beneath 2 + * separate pathless layout routes. In this case, the route paths for + * both account._public.tsx and account._private.tsx is the same + * (/account), but we're again not expecting to match at that level. + * + * By only ignoring this check when the final portion of the filename is + * pathless, we will still detect path collisions such as: + * + * routes/parent._pathless.foo.tsx + * routes/parent._pathless2.foo.tsx + * + * and + * + * routes/parent._pathless/index.tsx + * routes/parent._pathless2/index.tsx + */ + let lastRouteSegment = config.id + .replace(new RegExp(`^${prefix}/`), "") + .split(".") + .pop(); + let isPathlessLayoutRoute = + lastRouteSegment && + lastRouteSegment.startsWith("_") && + lastRouteSegment !== "_index"; + if (isPathlessLayoutRoute) { + continue; + } + + let conflictRouteId = originalPathname + (config.index ? "?index" : ""); + let conflict = uniqueRoutes.get(conflictRouteId); + uniqueRoutes.set(conflictRouteId, config); + + if (conflict && (originalPathname || config.index)) { + let currentConflicts = urlConflicts.get(originalPathname); + if (!currentConflicts) currentConflicts = [conflict]; + currentConflicts.push(config); + urlConflicts.set(originalPathname, currentConflicts); + continue; + } + } + + if (routeIdConflicts.size > 0) { + for (let [routeId, files] of routeIdConflicts.entries()) { + console.error(getRouteIdConflictErrorMessage(routeId, files)); + } + } + + // report conflicts + if (urlConflicts.size > 0) { + for (let [path, routes] of urlConflicts.entries()) { + // delete all but the first route from the manifest + for (let i = 1; i < routes.length; i++) { + delete routeManifest[routes[i].id]; + } + let files = routes.map((r) => r.file); + console.error(getRoutePathConflictErrorMessage(path, files)); + } + } + + return routeManifest; +} + +function findRouteModuleForFile( + appDirectory: string, + filepath: string, + ignoredFileRegex: RegExp[] +): string | null { + let relativePath = normalizeSlashes(path.relative(appDirectory, filepath)); + let isIgnored = ignoredFileRegex.some((regex) => regex.test(relativePath)); + if (isIgnored) return null; + return filepath; +} + +function findRouteModuleForFolder( + appDirectory: string, + filepath: string, + ignoredFileRegex: RegExp[] +): string | null { + let relativePath = path.relative(appDirectory, filepath); + let isIgnored = ignoredFileRegex.some((regex) => regex.test(relativePath)); + if (isIgnored) return null; + + let routeRouteModule = findFile(filepath, "route", routeModuleExts); + let routeIndexModule = findFile(filepath, "index", routeModuleExts); + + // if both a route and index module exist, throw a conflict error + // preferring the route module over the index module + if (routeRouteModule && routeIndexModule) { + let [segments, raw] = getRouteSegments( + path.relative(appDirectory, filepath) + ); + let routePath = createRoutePath(segments, raw, false); + console.error( + getRoutePathConflictErrorMessage(routePath || "/", [ + routeRouteModule, + routeIndexModule, + ]) + ); + } + + return routeRouteModule || routeIndexModule || null; +} + +type State = + | // normal path segment normal character concatenation until we hit a special character or the end of the segment (i.e. `/`, `.`, '\') + "NORMAL" + // we hit a `[` and are now in an escape sequence until we hit a `]` - take characters literally and skip isSegmentSeparator checks + | "ESCAPE" + // we hit a `(` and are now in an optional segment until we hit a `)` or an escape sequence + | "OPTIONAL" + // we previously were in a opt fional segment and hit a `[` and are now in an escape sequence until we hit a `]` - take characters literally and skip isSegmentSeparator checks - afterwards go back to OPTIONAL state + | "OPTIONAL_ESCAPE"; + +export function getRouteSegments(routeId: string): [string[], string[]] { + let routeSegments: string[] = []; + let rawRouteSegments: string[] = []; + let index = 0; + let routeSegment = ""; + let rawRouteSegment = ""; + let state: State = "NORMAL"; + + let pushRouteSegment = (segment: string, rawSegment: string) => { + if (!segment) return; + + let notSupportedInRR = (segment: string, char: string) => { + throw new Error( + `Route segment "${segment}" for "${routeId}" cannot contain "${char}".\n` + + `If this is something you need, upvote this proposal for React Router https://github.com/remix-run/react-router/discussions/9822.` + ); + }; + + if (rawSegment.includes("*")) { + return notSupportedInRR(rawSegment, "*"); + } + + if (rawSegment.includes(":")) { + return notSupportedInRR(rawSegment, ":"); + } + + if (rawSegment.includes("/")) { + return notSupportedInRR(segment, "/"); + } + + routeSegments.push(segment); + rawRouteSegments.push(rawSegment); + }; + + while (index < routeId.length) { + let char = routeId[index]; + index++; //advance to next char + + switch (state) { + case "NORMAL": { + if (isSegmentSeparator(char)) { + pushRouteSegment(routeSegment, rawRouteSegment); + routeSegment = ""; + rawRouteSegment = ""; + state = "NORMAL"; + break; + } + if (char === escapeStart) { + state = "ESCAPE"; + rawRouteSegment += char; + break; + } + if (char === optionalStart) { + state = "OPTIONAL"; + rawRouteSegment += char; + break; + } + if (!routeSegment && char === paramPrefixChar) { + if (index === routeId.length) { + routeSegment += "*"; + rawRouteSegment += char; + } else { + routeSegment += ":"; + rawRouteSegment += char; + } + break; + } + + routeSegment += char; + rawRouteSegment += char; + break; + } + case "ESCAPE": { + if (char === escapeEnd) { + state = "NORMAL"; + rawRouteSegment += char; + break; + } + + routeSegment += char; + rawRouteSegment += char; + break; + } + case "OPTIONAL": { + if (char === optionalEnd) { + routeSegment += "?"; + rawRouteSegment += char; + state = "NORMAL"; + break; + } + + if (char === escapeStart) { + state = "OPTIONAL_ESCAPE"; + rawRouteSegment += char; + break; + } + + if (!routeSegment && char === paramPrefixChar) { + if (index === routeId.length) { + routeSegment += "*"; + rawRouteSegment += char; + } else { + routeSegment += ":"; + rawRouteSegment += char; + } + break; + } + + routeSegment += char; + rawRouteSegment += char; + break; + } + case "OPTIONAL_ESCAPE": { + if (char === escapeEnd) { + state = "OPTIONAL"; + rawRouteSegment += char; + break; + } + + routeSegment += char; + rawRouteSegment += char; + break; + } + } + } + + // process remaining segment + pushRouteSegment(routeSegment, rawRouteSegment); + return [routeSegments, rawRouteSegments]; +} + +export function createRoutePath( + routeSegments: string[], + rawRouteSegments: string[], + isIndex?: boolean +) { + let result: string[] = []; + + if (isIndex) { + routeSegments = routeSegments.slice(0, -1); + } + + for (let index = 0; index < routeSegments.length; index++) { + let segment = routeSegments[index]; + let rawSegment = rawRouteSegments[index]; + + // skip pathless layout segments + if (segment.startsWith("_") && rawSegment.startsWith("_")) { + continue; + } + + // remove trailing slash + if (segment.endsWith("_") && rawSegment.endsWith("_")) { + segment = segment.slice(0, -1); + } + + result.push(segment); + } + + return result.length ? result.join("/") : undefined; +} + +export function getRoutePathConflictErrorMessage( + pathname: string, + routes: string[] +) { + let [taken, ...others] = routes; + + if (!pathname.startsWith("/")) { + pathname = "/" + pathname; + } + + return ( + `⚠️ Route Path Collision: "${pathname}"\n\n` + + `The following routes all define the same URL, only the first one will be used\n\n` + + `🟢 ${taken}\n` + + others.map((route) => `⭕️️ ${route}`).join("\n") + + "\n" + ); +} + +export function getRouteIdConflictErrorMessage( + routeId: string, + files: string[] +) { + let [taken, ...others] = files; + + return ( + `⚠️ Route ID Collision: "${routeId}"\n\n` + + `The following routes all define the same Route ID, only the first one will be used\n\n` + + `🟢 ${taken}\n` + + others.map((route) => `⭕️️ ${route}`).join("\n") + + "\n" + ); +} + +export function isSegmentSeparator(checkChar: string | undefined) { + if (!checkChar) return false; + return ["/", ".", path.win32.sep].includes(checkChar); +} + +function findFile( + dir: string, + basename: string, + extensions: string[] +): string | undefined { + for (let ext of extensions) { + let name = basename + ext; + let file = path.join(dir, name); + if (fs.existsSync(file)) return file; + } + + return undefined; +} diff --git a/packages/remix-fs-routes/index.ts b/packages/remix-fs-routes/index.ts new file mode 100644 index 00000000000..a0ccc39fefd --- /dev/null +++ b/packages/remix-fs-routes/index.ts @@ -0,0 +1,44 @@ +import fs from "node:fs"; +import path from "node:path"; +import { + type RouteConfigEntry, + getAppDirectory, +} from "@remix-run/route-config"; + +import { routeManifestToRouteConfig } from "./manifest"; +import { flatRoutes as flatRoutesImpl } from "./flatRoutes"; +import { normalizeSlashes } from "./normalizeSlashes"; + +/** + * Creates route config from the file system that matches [Remix's default file + * conventions](https://remix.run/docs/en/v2/file-conventions/routes), for + * use within `routes.ts`. + */ +export async function flatRoutes( + options: { + /** + * An array of [minimatch](https://www.npmjs.com/package/minimatch) globs that match files to ignore. + * Defaults to `[]`. + */ + ignoredRouteFiles?: string[]; + + /** + * The directory containing file system routes, relative to the app directory. + * Defaults to `"./routes"`. + */ + rootDirectory?: string; + } = {} +): Promise { + let { ignoredRouteFiles = [], rootDirectory: userRootDirectory = "routes" } = + options; + let appDirectory = getAppDirectory(); + let rootDirectory = path.resolve(appDirectory, userRootDirectory); + let relativeRootDirectory = path.relative(appDirectory, rootDirectory); + let prefix = normalizeSlashes(relativeRootDirectory); + + let routes = fs.existsSync(rootDirectory) + ? flatRoutesImpl(appDirectory, ignoredRouteFiles, prefix) + : {}; + + return routeManifestToRouteConfig(routes); +} diff --git a/packages/remix-fs-routes/jest.config.js b/packages/remix-fs-routes/jest.config.js new file mode 100644 index 00000000000..47d93e75154 --- /dev/null +++ b/packages/remix-fs-routes/jest.config.js @@ -0,0 +1,6 @@ +/** @type {import('jest').Config} */ +module.exports = { + ...require("../../jest/jest.config.shared"), + displayName: "fs-routes", + setupFiles: [], +}; diff --git a/packages/remix-fs-routes/manifest.ts b/packages/remix-fs-routes/manifest.ts new file mode 100644 index 00000000000..3b9ea7ae1d8 --- /dev/null +++ b/packages/remix-fs-routes/manifest.ts @@ -0,0 +1,53 @@ +import type { RouteConfigEntry } from "@remix-run/route-config"; + +export interface RouteManifestEntry { + path?: string; + index?: boolean; + caseSensitive?: boolean; + id: string; + parentId?: string; + file: string; +} + +export interface RouteManifest { + [routeId: string]: RouteManifestEntry; +} + +export function routeManifestToRouteConfig( + routeManifest: RouteManifest, + rootId = "root" +): RouteConfigEntry[] { + let routeConfigById: { + [id: string]: Omit & + Required>; + } = {}; + + for (let id in routeManifest) { + let route = routeManifest[id]; + routeConfigById[id] = { + id: route.id, + file: route.file, + path: route.path, + index: route.index, + caseSensitive: route.caseSensitive, + }; + } + + let routeConfig: RouteConfigEntry[] = []; + + for (let id in routeConfigById) { + let route = routeConfigById[id]; + let parentId = routeManifest[route.id].parentId; + if (parentId === rootId) { + routeConfig.push(route); + } else { + let parentRoute = parentId && routeConfigById[parentId]; + if (parentRoute) { + parentRoute.children = parentRoute.children || []; + parentRoute.children.push(route); + } + } + } + + return routeConfig; +} diff --git a/packages/remix-fs-routes/normalizeSlashes.ts b/packages/remix-fs-routes/normalizeSlashes.ts new file mode 100644 index 00000000000..3d16e5041e8 --- /dev/null +++ b/packages/remix-fs-routes/normalizeSlashes.ts @@ -0,0 +1,5 @@ +import path from "node:path"; + +export function normalizeSlashes(file: string) { + return file.split(path.win32.sep).join("/"); +} diff --git a/packages/remix-fs-routes/package.json b/packages/remix-fs-routes/package.json new file mode 100644 index 00000000000..5f803ade36b --- /dev/null +++ b/packages/remix-fs-routes/package.json @@ -0,0 +1,51 @@ +{ + "name": "@remix-run/fs-routes", + "version": "2.13.1", + "description": "Config-based file system routing conventions for Remix", + "bugs": { + "url": "https://github.com/remix-run/remix/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/remix-run/remix", + "directory": "packages/remix-fs-routes" + }, + "license": "MIT", + "main": "dist/index.js", + "typings": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./package.json": "./package.json" + }, + "scripts": { + "tsc": "tsc" + }, + "dependencies": { + "minimatch": "^9.0.0" + }, + "devDependencies": { + "@remix-run/route-config": "workspace:*", + "typescript": "^5.1.6" + }, + "peerDependencies": { + "@remix-run/route-config": "workspace:*", + "typescript": "^5.1.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + }, + "engines": { + "node": ">=18.0.0" + }, + "files": [ + "dist/", + "CHANGELOG.md", + "LICENSE.md", + "README.md" + ] +} diff --git a/packages/remix-fs-routes/rollup.config.js b/packages/remix-fs-routes/rollup.config.js new file mode 100644 index 00000000000..b9293d2cdcf --- /dev/null +++ b/packages/remix-fs-routes/rollup.config.js @@ -0,0 +1,45 @@ +const path = require("node:path"); +const babel = require("@rollup/plugin-babel").default; +const nodeResolve = require("@rollup/plugin-node-resolve").default; +const copy = require("rollup-plugin-copy"); + +const { + copyToPlaygrounds, + createBanner, + getOutputDir, + isBareModuleId, +} = require("../../rollup.utils"); +const { name: packageName, version } = require("./package.json"); + +/** @returns {import("rollup").RollupOptions[]} */ +module.exports = function rollup() { + let sourceDir = "packages/remix-fs-routes"; + let outputDir = getOutputDir(packageName); + let outputDist = path.join(outputDir, "dist"); + + return [ + { + external: (id) => isBareModuleId(id), + input: `${sourceDir}/index.ts`, + output: { + banner: createBanner(packageName, version), + dir: outputDist, + format: "cjs", + preserveModules: true, + exports: "auto", + }, + plugins: [ + babel({ + babelHelpers: "bundled", + exclude: /node_modules/, + extensions: [".ts"], + }), + nodeResolve({ extensions: [".ts"] }), + copy({ + targets: [{ src: "LICENSE.md", dest: sourceDir }], + }), + copyToPlaygrounds(), + ], + }, + ]; +}; diff --git a/packages/remix-fs-routes/tsconfig.json b/packages/remix-fs-routes/tsconfig.json new file mode 100644 index 00000000000..d8bcb86a4b9 --- /dev/null +++ b/packages/remix-fs-routes/tsconfig.json @@ -0,0 +1,19 @@ +{ + "include": ["**/*.ts"], + "exclude": ["dist", "__tests__", "node_modules"], + "compilerOptions": { + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "target": "ES2022", + "module": "ES2022", + "skipLibCheck": true, + + "moduleResolution": "Bundler", + "allowSyntheticDefaultImports": true, + "strict": true, + "jsx": "react", + "declaration": true, + "emitDeclarationOnly": true, + "rootDir": ".", + "outDir": "../../build/node_modules/@remix-run/fs-routes/dist" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 964a4a76c3a..502368f25bd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -520,6 +520,12 @@ importers: '@remix-run/dev': specifier: workspace:* version: link:../../../packages/remix-dev + '@remix-run/fs-routes': + specifier: workspace:* + version: link:../../../packages/remix-fs-routes + '@remix-run/route-config': + specifier: workspace:* + version: link:../../../packages/remix-route-config '@types/react': specifier: ^18.2.20 version: 18.2.20 @@ -1189,6 +1195,19 @@ importers: specifier: ^5.1.6 version: 5.1.6 + packages/remix-fs-routes: + dependencies: + minimatch: + specifier: ^9.0.0 + version: 9.0.3 + devDependencies: + '@remix-run/route-config': + specifier: workspace:* + version: link:../remix-route-config + typescript: + specifier: ^5.1.6 + version: 5.1.6 + packages/remix-node: dependencies: '@remix-run/server-runtime': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 799d60287e2..8671741fd4c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -16,6 +16,7 @@ packages: - "packages/remix-dev" - "packages/remix-eslint-config" - "packages/remix-express" + - "packages/remix-fs-routes" - "packages/remix-node" - "packages/remix-react" - "packages/remix-route-config" diff --git a/scripts/publish.js b/scripts/publish.js index f891c055e60..5300a854af8 100644 --- a/scripts/publish.js +++ b/scripts/publish.js @@ -60,6 +60,7 @@ async function run() { "express", // publish express before serve "react", "serve", + "fs-routes", "css-bundle", "testing", "route-config", From 7601e6733802cdb0da86ca189f008a6233559edd Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Thu, 17 Oct 2024 12:14:52 +1100 Subject: [PATCH 08/22] Add routes-option-adapter package --- .../helpers/node-template/package.json | 3 +- integration/vite-fs-routes-test.ts | 38 +++++++++++-- .../routeManifestToRouteConfig-test.ts | 33 ++++++++---- packages/remix-dev/config/routes.ts | 39 ++++++++++++++ packages/remix-dev/index.ts | 9 +++- packages/remix-fs-routes/flatRoutes.ts | 5 +- packages/remix-fs-routes/index.ts | 2 +- packages/remix-fs-routes/manifest.ts | 53 ------------------- packages/remix-fs-routes/package.json | 4 +- packages/remix-route-config/package.json | 4 +- .../remix-routes-option-adapter/README.md | 13 +++++ packages/remix-routes-option-adapter/index.ts | 24 +++++++++ .../remix-routes-option-adapter/package.json | 53 +++++++++++++++++++ .../rollup.config.js | 45 ++++++++++++++++ .../remix-routes-option-adapter/tsconfig.json | 19 +++++++ pnpm-lock.yaml | 22 ++++++++ pnpm-workspace.yaml | 1 + scripts/publish.js | 1 + 18 files changed, 292 insertions(+), 76 deletions(-) rename packages/{remix-fs-routes => remix-dev}/__tests__/routeManifestToRouteConfig-test.ts (80%) delete mode 100644 packages/remix-fs-routes/manifest.ts create mode 100644 packages/remix-routes-option-adapter/README.md create mode 100644 packages/remix-routes-option-adapter/index.ts create mode 100644 packages/remix-routes-option-adapter/package.json create mode 100644 packages/remix-routes-option-adapter/rollup.config.js create mode 100644 packages/remix-routes-option-adapter/tsconfig.json diff --git a/integration/helpers/node-template/package.json b/integration/helpers/node-template/package.json index 1d26acc8cfb..2f477337507 100644 --- a/integration/helpers/node-template/package.json +++ b/integration/helpers/node-template/package.json @@ -23,8 +23,9 @@ }, "devDependencies": { "@remix-run/dev": "workspace:*", - "@remix-run/route-config": "workspace:*", "@remix-run/fs-routes": "workspace:*", + "@remix-run/route-config": "workspace:*", + "@remix-run/routes-option-adapter": "workspace:*", "@vanilla-extract/css": "^1.10.0", "@vanilla-extract/vite-plugin": "^3.9.2", "@types/react": "^18.2.20", diff --git a/integration/vite-fs-routes-test.ts b/integration/vite-fs-routes-test.ts index e3709d99967..5e9f9d44545 100644 --- a/integration/vite-fs-routes-test.ts +++ b/integration/vite-fs-routes-test.ts @@ -29,11 +29,22 @@ test.describe("fs-routes", () => { "app/routes.ts": js` import { type RouteConfig } from "@remix-run/route-config"; import { flatRoutes } from "@remix-run/fs-routes"; - - export const routes: RouteConfig = flatRoutes({ - rootDirectory: "fs-routes", - ignoredRouteFiles: ["**/ignored-route.*"], - }); + import { routesOptionAdapter } from "@remix-run/routes-option-adapter"; + + export const routes: RouteConfig = [ + ...await flatRoutes({ + rootDirectory: "fs-routes", + ignoredRouteFiles: ["**/ignored-route.*"], + }), + + // Ensure back compat layer works + ...await routesOptionAdapter(async (defineRoutes) => { + // Ensure async routes work + return defineRoutes((route) => { + route("/routes/option/adapter/route", "routes-option-adapter-route.tsx") + }); + }) + ]; `, "app/root.tsx": js` import { Links, Meta, Outlet, Scripts } from "@remix-run/react"; @@ -81,6 +92,12 @@ test.describe("fs-routes", () => { } `, + "app/routes-option-adapter-route.tsx": js` + export default function () { + return

Routes Option Adapter Route

; + } + `, + "app/fs-routes/.dotfile": ` DOTFILE SHOULD BE IGNORED `, @@ -176,6 +193,17 @@ test.describe("fs-routes", () => { `); }); + test("renders matching routes (routes option adapter)", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/routes/option/adapter/route"); + expect(await app.getHtml("#content")).toBe(`
+

Root

+

Routes Option Adapter Route

+
`); + }); + test("renders matching routes (route with escaped leading dot)", async ({ page, }) => { diff --git a/packages/remix-fs-routes/__tests__/routeManifestToRouteConfig-test.ts b/packages/remix-dev/__tests__/routeManifestToRouteConfig-test.ts similarity index 80% rename from packages/remix-fs-routes/__tests__/routeManifestToRouteConfig-test.ts rename to packages/remix-dev/__tests__/routeManifestToRouteConfig-test.ts index d4e9be71597..a236c022ada 100644 --- a/packages/remix-fs-routes/__tests__/routeManifestToRouteConfig-test.ts +++ b/packages/remix-dev/__tests__/routeManifestToRouteConfig-test.ts @@ -1,6 +1,4 @@ -import { route } from "@remix-run/route-config"; - -import { routeManifestToRouteConfig } from "../manifest"; +import { type RouteConfig, routeManifestToRouteConfig } from "../config/routes"; const clean = (obj: any) => cleanUndefined(cleanIds(obj)); @@ -43,14 +41,27 @@ describe("routeManifestToRouteConfig", () => { caseSensitive: true, }, }); - let routeConfig = [ - route("/", "routes/home.js"), - route("inbox", "routes/inbox.js", [ - route("/", "routes/inbox/index.js", { index: true }), - route(":messageId", "routes/inbox/$messageId.js", { - caseSensitive: true, - }), - ]), + let routeConfig: RouteConfig = [ + { + path: "/", + file: "routes/home.js", + }, + { + path: "inbox", + file: "routes/inbox.js", + children: [ + { + path: "/", + file: "routes/inbox/index.js", + index: true, + }, + { + path: ":messageId", + file: "routes/inbox/$messageId.js", + caseSensitive: true, + }, + ], + }, ]; expect(clean(routeManifestConfig)).toEqual(clean(routeConfig)); diff --git a/packages/remix-dev/config/routes.ts b/packages/remix-dev/config/routes.ts index fd4dd22cbbf..3e2b919517c 100644 --- a/packages/remix-dev/config/routes.ts +++ b/packages/remix-dev/config/routes.ts @@ -57,6 +57,45 @@ export interface RouteManifest { [routeId: string]: RouteManifestEntry; } +export function routeManifestToRouteConfig( + routeManifest: RouteManifest, + rootId = "root" +): RouteConfigEntry[] { + let routeConfigById: { + [id: string]: Omit & + Required>; + } = {}; + + for (let id in routeManifest) { + let route = routeManifest[id]; + routeConfigById[id] = { + id: route.id, + file: route.file, + path: route.path, + index: route.index, + caseSensitive: route.caseSensitive, + }; + } + + let routeConfig: RouteConfigEntry[] = []; + + for (let id in routeConfigById) { + let route = routeConfigById[id]; + let parentId = routeManifest[route.id].parentId; + if (parentId === rootId) { + routeConfig.push(route); + } else { + let parentRoute = parentId && routeConfigById[parentId]; + if (parentRoute) { + parentRoute.children = parentRoute.children || []; + parentRoute.children.push(route); + } + } + } + + return routeConfig; +} + /** * Configuration for an individual route, for use within `routes.ts`. As a * convenience, route config entries can be created with the {@link route}, diff --git a/packages/remix-dev/index.ts b/packages/remix-dev/index.ts index 38581e081f7..baabf348966 100644 --- a/packages/remix-dev/index.ts +++ b/packages/remix-dev/index.ts @@ -6,10 +6,17 @@ export * as cli from "./cli/index"; export type { Manifest as AssetsManifest } from "./manifest"; export type { + DefineRoutesFunction as UNSAFE_DefineRoutesFunction, + RouteManifest as UNSAFE_RouteManifest, + RouteManifestEntry as UNSAFE_RouteManifestEntry, RouteConfig as UNSAFE_RouteConfig, RouteConfigEntry as UNSAFE_RouteConfigEntry, } from "./config/routes"; -export { getRouteConfigAppDirectory as UNSAFE_getRouteConfigAppDirectory } from "./config/routes"; +export { + defineRoutes as UNSAFE_defineRoutes, + routeManifestToRouteConfig as UNSAFE_routeManifestToRouteConfig, + getRouteConfigAppDirectory as UNSAFE_getRouteConfigAppDirectory, +} from "./config/routes"; export { getDependenciesToBundle } from "./dependencies"; export type { BuildManifest, diff --git a/packages/remix-fs-routes/flatRoutes.ts b/packages/remix-fs-routes/flatRoutes.ts index 4195f735a4c..9d48763b9dc 100644 --- a/packages/remix-fs-routes/flatRoutes.ts +++ b/packages/remix-fs-routes/flatRoutes.ts @@ -1,8 +1,11 @@ import fs from "node:fs"; import path from "node:path"; import { makeRe } from "minimatch"; +import type { + UNSAFE_RouteManifest as RouteManifest, + UNSAFE_RouteManifestEntry as RouteManifestEntry, +} from "@remix-run/dev"; -import type { RouteManifest, RouteManifestEntry } from "./manifest"; import { normalizeSlashes } from "./normalizeSlashes"; export const routeModuleExts = [".js", ".jsx", ".ts", ".tsx", ".md", ".mdx"]; diff --git a/packages/remix-fs-routes/index.ts b/packages/remix-fs-routes/index.ts index a0ccc39fefd..faf5ea44c63 100644 --- a/packages/remix-fs-routes/index.ts +++ b/packages/remix-fs-routes/index.ts @@ -1,11 +1,11 @@ import fs from "node:fs"; import path from "node:path"; +import { UNSAFE_routeManifestToRouteConfig as routeManifestToRouteConfig } from "@remix-run/dev"; import { type RouteConfigEntry, getAppDirectory, } from "@remix-run/route-config"; -import { routeManifestToRouteConfig } from "./manifest"; import { flatRoutes as flatRoutesImpl } from "./flatRoutes"; import { normalizeSlashes } from "./normalizeSlashes"; diff --git a/packages/remix-fs-routes/manifest.ts b/packages/remix-fs-routes/manifest.ts deleted file mode 100644 index 3b9ea7ae1d8..00000000000 --- a/packages/remix-fs-routes/manifest.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { RouteConfigEntry } from "@remix-run/route-config"; - -export interface RouteManifestEntry { - path?: string; - index?: boolean; - caseSensitive?: boolean; - id: string; - parentId?: string; - file: string; -} - -export interface RouteManifest { - [routeId: string]: RouteManifestEntry; -} - -export function routeManifestToRouteConfig( - routeManifest: RouteManifest, - rootId = "root" -): RouteConfigEntry[] { - let routeConfigById: { - [id: string]: Omit & - Required>; - } = {}; - - for (let id in routeManifest) { - let route = routeManifest[id]; - routeConfigById[id] = { - id: route.id, - file: route.file, - path: route.path, - index: route.index, - caseSensitive: route.caseSensitive, - }; - } - - let routeConfig: RouteConfigEntry[] = []; - - for (let id in routeConfigById) { - let route = routeConfigById[id]; - let parentId = routeManifest[route.id].parentId; - if (parentId === rootId) { - routeConfig.push(route); - } else { - let parentRoute = parentId && routeConfigById[parentId]; - if (parentRoute) { - parentRoute.children = parentRoute.children || []; - parentRoute.children.push(route); - } - } - } - - return routeConfig; -} diff --git a/packages/remix-fs-routes/package.json b/packages/remix-fs-routes/package.json index 5f803ade36b..75b82093267 100644 --- a/packages/remix-fs-routes/package.json +++ b/packages/remix-fs-routes/package.json @@ -1,7 +1,7 @@ { "name": "@remix-run/fs-routes", "version": "2.13.1", - "description": "Config-based file system routing conventions for Remix", + "description": "Config-based file system routing conventions, for use within routes.ts", "bugs": { "url": "https://github.com/remix-run/remix/issues" }, @@ -27,10 +27,12 @@ "minimatch": "^9.0.0" }, "devDependencies": { + "@remix-run/dev": "workspace:*", "@remix-run/route-config": "workspace:*", "typescript": "^5.1.6" }, "peerDependencies": { + "@remix-run/dev": "workspace:*", "@remix-run/route-config": "workspace:*", "typescript": "^5.1.0" }, diff --git a/packages/remix-route-config/package.json b/packages/remix-route-config/package.json index 574c8c042c0..701968b67e9 100644 --- a/packages/remix-route-config/package.json +++ b/packages/remix-route-config/package.json @@ -1,14 +1,14 @@ { "name": "@remix-run/route-config", "version": "2.13.1", - "description": "Config-based routing for Remix", + "description": "Config-based routing utilities, for use within routes.ts", "bugs": { "url": "https://github.com/remix-run/remix/issues" }, "repository": { "type": "git", "url": "https://github.com/remix-run/remix", - "directory": "packages/remix-fs-routes" + "directory": "packages/remix-route-config" }, "license": "MIT", "main": "dist/index.js", diff --git a/packages/remix-routes-option-adapter/README.md b/packages/remix-routes-option-adapter/README.md new file mode 100644 index 00000000000..40685a7476f --- /dev/null +++ b/packages/remix-routes-option-adapter/README.md @@ -0,0 +1,13 @@ +# Welcome to Remix! + +[Remix](https://remix.run) is a web framework that helps you build better websites with React. + +To get started, open a new shell and run: + +```sh +npx create-remix@latest +``` + +Then follow the prompts you see in your terminal. + +For more information about Remix, [visit remix.run](https://remix.run)! diff --git a/packages/remix-routes-option-adapter/index.ts b/packages/remix-routes-option-adapter/index.ts new file mode 100644 index 00000000000..bc16aa18fa8 --- /dev/null +++ b/packages/remix-routes-option-adapter/index.ts @@ -0,0 +1,24 @@ +import { + type UNSAFE_DefineRoutesFunction as DefineRoutesFunction, + UNSAFE_defineRoutes as defineRoutes, + UNSAFE_routeManifestToRouteConfig as routeManifestToRouteConfig, +} from "@remix-run/dev"; +import { type RouteConfigEntry } from "@remix-run/route-config"; + +export type { DefineRoutesFunction }; + +/** + * Adapter for [Remix's `routes` config + * option](https://remix.run/docs/en/v2/file-conventions/vite-config#routes), + * for use within `routes.ts`. + */ +export async function routesOptionAdapter( + routes: ( + defineRoutes: DefineRoutesFunction + ) => + | ReturnType + | Promise> +): Promise { + let routeManifest = await routes(defineRoutes); + return routeManifestToRouteConfig(routeManifest); +} diff --git a/packages/remix-routes-option-adapter/package.json b/packages/remix-routes-option-adapter/package.json new file mode 100644 index 00000000000..529b43c5531 --- /dev/null +++ b/packages/remix-routes-option-adapter/package.json @@ -0,0 +1,53 @@ +{ + "name": "@remix-run/routes-option-adapter", + "version": "2.13.1", + "description": "Adapter for Remix's \"routes\" config option, for use within routes.ts", + "bugs": { + "url": "https://github.com/remix-run/remix/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/remix-run/remix", + "directory": "packages/remix-routes-option-adapter" + }, + "license": "MIT", + "main": "dist/index.js", + "typings": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./package.json": "./package.json" + }, + "scripts": { + "tsc": "tsc" + }, + "dependencies": { + "minimatch": "^9.0.0" + }, + "devDependencies": { + "@remix-run/dev": "workspace:*", + "@remix-run/route-config": "workspace:*", + "typescript": "^5.1.6" + }, + "peerDependencies": { + "@remix-run/dev": "workspace:*", + "@remix-run/route-config": "workspace:*", + "typescript": "^5.1.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + }, + "engines": { + "node": ">=18.0.0" + }, + "files": [ + "dist/", + "CHANGELOG.md", + "LICENSE.md", + "README.md" + ] +} diff --git a/packages/remix-routes-option-adapter/rollup.config.js b/packages/remix-routes-option-adapter/rollup.config.js new file mode 100644 index 00000000000..59e7189afe3 --- /dev/null +++ b/packages/remix-routes-option-adapter/rollup.config.js @@ -0,0 +1,45 @@ +const path = require("node:path"); +const babel = require("@rollup/plugin-babel").default; +const nodeResolve = require("@rollup/plugin-node-resolve").default; +const copy = require("rollup-plugin-copy"); + +const { + copyToPlaygrounds, + createBanner, + getOutputDir, + isBareModuleId, +} = require("../../rollup.utils"); +const { name: packageName, version } = require("./package.json"); + +/** @returns {import("rollup").RollupOptions[]} */ +module.exports = function rollup() { + let sourceDir = "packages/remix-routes-option-adapter"; + let outputDir = getOutputDir(packageName); + let outputDist = path.join(outputDir, "dist"); + + return [ + { + external: (id) => isBareModuleId(id), + input: `${sourceDir}/index.ts`, + output: { + banner: createBanner(packageName, version), + dir: outputDist, + format: "cjs", + preserveModules: true, + exports: "auto", + }, + plugins: [ + babel({ + babelHelpers: "bundled", + exclude: /node_modules/, + extensions: [".ts"], + }), + nodeResolve({ extensions: [".ts"] }), + copy({ + targets: [{ src: "LICENSE.md", dest: sourceDir }], + }), + copyToPlaygrounds(), + ], + }, + ]; +}; diff --git a/packages/remix-routes-option-adapter/tsconfig.json b/packages/remix-routes-option-adapter/tsconfig.json new file mode 100644 index 00000000000..897cf7dd585 --- /dev/null +++ b/packages/remix-routes-option-adapter/tsconfig.json @@ -0,0 +1,19 @@ +{ + "include": ["**/*.ts"], + "exclude": ["dist", "__tests__", "node_modules"], + "compilerOptions": { + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "target": "ES2022", + "module": "ES2022", + "skipLibCheck": true, + + "moduleResolution": "Bundler", + "allowSyntheticDefaultImports": true, + "strict": true, + "jsx": "react", + "declaration": true, + "emitDeclarationOnly": true, + "rootDir": ".", + "outDir": "../../build/node_modules/@remix-run/routes-option-adapter/dist" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 502368f25bd..3ee9624be80 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -526,6 +526,9 @@ importers: '@remix-run/route-config': specifier: workspace:* version: link:../../../packages/remix-route-config + '@remix-run/routes-option-adapter': + specifier: workspace:* + version: link:../../../packages/remix-routes-option-adapter '@types/react': specifier: ^18.2.20 version: 18.2.20 @@ -1201,6 +1204,9 @@ importers: specifier: ^9.0.0 version: 9.0.3 devDependencies: + '@remix-run/dev': + specifier: workspace:* + version: link:../remix-dev '@remix-run/route-config': specifier: workspace:* version: link:../remix-route-config @@ -1307,6 +1313,22 @@ importers: specifier: 5.1.8 version: 5.1.8(@types/node@18.17.1) + packages/remix-routes-option-adapter: + dependencies: + minimatch: + specifier: ^9.0.0 + version: 9.0.3 + devDependencies: + '@remix-run/dev': + specifier: workspace:* + version: link:../remix-dev + '@remix-run/route-config': + specifier: workspace:* + version: link:../remix-route-config + typescript: + specifier: ^5.1.6 + version: 5.1.6 + packages/remix-serve: dependencies: '@remix-run/express': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 8671741fd4c..e8c91c3e82c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -20,6 +20,7 @@ packages: - "packages/remix-node" - "packages/remix-react" - "packages/remix-route-config" + - "packages/remix-routes-option-adapter" - "packages/remix-serve" - "packages/remix-server-runtime" - "packages/remix-testing" diff --git a/scripts/publish.js b/scripts/publish.js index 5300a854af8..37fa8f7f37b 100644 --- a/scripts/publish.js +++ b/scripts/publish.js @@ -64,6 +64,7 @@ async function run() { "css-bundle", "testing", "route-config", + "routes-option-adapter", ]) { publish(path.join(buildDir, "@remix-run", name), tag); } From b755f79113a8ae1e7985550f0cc6937dabf2d538 Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Thu, 17 Oct 2024 14:55:35 +1100 Subject: [PATCH 09/22] Dedupe flat routes implementation --- jest.config.js | 1 - packages/remix-dev/index.ts | 1 + .../__tests__/flatRoutes-test.ts | 879 ------------------ packages/remix-fs-routes/flatRoutes.ts | 566 ----------- packages/remix-fs-routes/index.ts | 6 +- packages/remix-fs-routes/jest.config.js | 6 - packages/remix-fs-routes/package.json | 3 - pnpm-lock.yaml | 4 - 8 files changed, 5 insertions(+), 1461 deletions(-) delete mode 100644 packages/remix-fs-routes/__tests__/flatRoutes-test.ts delete mode 100644 packages/remix-fs-routes/flatRoutes.ts delete mode 100644 packages/remix-fs-routes/jest.config.js diff --git a/jest.config.js b/jest.config.js index 2b9642fbc50..f2d8db3a278 100644 --- a/jest.config.js +++ b/jest.config.js @@ -17,7 +17,6 @@ module.exports = { "packages/remix-dev", "packages/remix-eslint-config", "packages/remix-express", - "packages/remix-fs-routes", "packages/remix-node", "packages/remix-react", "packages/remix-route-config", diff --git a/packages/remix-dev/index.ts b/packages/remix-dev/index.ts index baabf348966..61590b494c6 100644 --- a/packages/remix-dev/index.ts +++ b/packages/remix-dev/index.ts @@ -17,6 +17,7 @@ export { routeManifestToRouteConfig as UNSAFE_routeManifestToRouteConfig, getRouteConfigAppDirectory as UNSAFE_getRouteConfigAppDirectory, } from "./config/routes"; +export { flatRoutes as UNSAFE_flatRoutes } from "./config/flat-routes"; export { getDependenciesToBundle } from "./dependencies"; export type { BuildManifest, diff --git a/packages/remix-fs-routes/__tests__/flatRoutes-test.ts b/packages/remix-fs-routes/__tests__/flatRoutes-test.ts deleted file mode 100644 index a668b88c542..00000000000 --- a/packages/remix-fs-routes/__tests__/flatRoutes-test.ts +++ /dev/null @@ -1,879 +0,0 @@ -import path from "node:path"; - -import type { RouteManifestEntry } from "../manifest"; -import { - flatRoutesUniversal, - getRoutePathConflictErrorMessage, - getRouteIdConflictErrorMessage, - getRouteSegments, -} from "../flatRoutes"; -import { normalizeSlashes } from "../normalizeSlashes"; - -let APP_DIR = path.join("test", "root", "app"); - -describe("flatRoutes", () => { - describe("creates proper route paths", () => { - let tests: [string, string | undefined][] = [ - ["routes.$", "routes/*"], - ["routes.sub.$", "routes/sub/*"], - ["routes.$slug", "routes/:slug"], - ["routes.sub.$slug", "routes/sub/:slug"], - ["$", "*"], - ["flat.$", "flat/*"], - ["$slug", ":slug"], - ["nested/index", "nested"], - ["nested.$", "*"], - ["nested.$slug", ":slug"], - ["nested._layout.$param", ":param"], - - ["flat.$slug", "flat/:slug"], - ["flat.sub", "flat/sub"], - ["flat._index", "flat"], - ["_index", undefined], - ["_layout/index", undefined], - ["_layout.test", "test"], - ["_layout.$param", ":param"], - ["$slug[.]json", ":slug.json"], - ["sub.[sitemap.xml]", "sub/sitemap.xml"], - ["posts.$slug.[image.jpg]", "posts/:slug/image.jpg"], - ["sub.[[]", "sub/["], - ["sub.]", "sub/]"], - ["sub.[[]]", "sub/[]"], - ["beef]", "beef]"], - ["[index]", "index"], - ["test.inde[x]", "test/index"], - ["[i]ndex.[[].[[]]", "index/[/[]"], - - // Optional segment routes - ["(routes).$", "routes?/*"], - ["(routes).(sub).$", "routes?/sub?/*"], - ["(routes).($slug)", "routes?/:slug?"], - ["(routes).sub.($slug)", "routes?/sub/:slug?"], - ["(nested).$", "nested?/*"], - ["(flat).$", "flat?/*"], - ["($slug)", ":slug?"], - ["(nested).($slug)", "nested?/:slug?"], - ["(flat).($slug)", "flat?/:slug?"], - ["flat.(sub)", "flat/sub?"], - ["_layout.(test)", "test?"], - ["_layout.($user)", ":user?"], - ["(nested)._layout.($param)", "nested?/:param?"], - ["($slug[.]json)", ":slug.json?"], - ["(sub).([sitemap.xml])", "sub?/sitemap.xml?"], - ["(sub).[(sitemap.xml)]", "sub?/(sitemap.xml)"], - ["(posts).($slug).([image.jpg])", "posts?/:slug?/image.jpg?"], - [ - "($[$dollabills]).([.]lol).(what).([$]).($up)", - ":$dollabills?/.lol?/what?/$?/:up?", - ], - ["(sub).(])", "sub?/]?"], - ["(sub).([[]])", "sub?/[]?"], - ["(sub).([[])", "sub?/[?"], - ["(beef])", "beef]?"], - ["([index])", "index?"], - ["(test).(inde[x])", "test?/index?"], - ["([i]ndex).([[]).([[]])", "index?/[?/[]?"], - - // Opting out of parent layout - ["user_.projects.$id.roadmap", "user/projects/:id/roadmap"], - ["app.projects_.$id.roadmap", "app/projects/:id/roadmap"], - ["shop_.projects_.$id.roadmap", "shop/projects/:id/roadmap"], - ]; - - let manifest = flatRoutesUniversal( - APP_DIR, - tests.map((t) => path.join(APP_DIR, "routes", t[0] + ".tsx")) - ); - - for (let [input, expected] of tests) { - it(`"${input}" -> "${expected}"`, () => { - if (input.endsWith("/route") || input.endsWith("/index")) { - input = input.replace(/\/(route|index)$/, ""); - } - let routeInfo = manifest[path.posix.join("routes", input)]; - expect(routeInfo.path).toBe(expected); - }); - } - - let invalidSlashFiles = [ - "($[$dollabills]).([.]lol)[/](what)/([$]).$", - "$[$dollabills].[.]lol[/]what/[$].$", - ]; - - for (let invalid of invalidSlashFiles) { - test("should error when using `/` in a route segment", () => { - let regex = new RegExp( - /Route segment (".*?") for (".*?") cannot contain "\/"/ - ); - expect(() => getRouteSegments(invalid)).toThrow(regex); - }); - } - - let invalidSplatFiles: string[] = [ - "routes/about.[*].tsx", - "routes/about.*.tsx", - "routes/about.[.[.*].].tsx", - ]; - - for (let invalid of invalidSplatFiles) { - test("should error when using `*` in a route segment", () => { - let regex = new RegExp( - /Route segment (".*?") for (".*?") cannot contain "\*"/ - ); - expect(() => getRouteSegments(invalid)).toThrow(regex); - }); - } - - let invalidParamFiles: string[] = [ - "routes/about.[:name].tsx", - "routes/about.:name.tsx", - ]; - - for (let invalid of invalidParamFiles) { - test("should error when using `:` in a route segment", () => { - let regex = new RegExp( - /Route segment (".*?") for (".*?") cannot contain ":"/ - ); - expect(() => getRouteSegments(invalid)).toThrow(regex); - }); - } - }); - - describe("should return the correct route hierarchy", () => { - // we'll add file manually before running the tests - let testFiles: [string, Omit][] = [ - [ - "routes/_auth.tsx", - { - id: "routes/_auth", - parentId: "root", - path: undefined, - }, - ], - [ - "routes/_auth.forgot-password.tsx", - { - id: "routes/_auth.forgot-password", - parentId: "routes/_auth", - path: "forgot-password", - }, - ], - [ - "routes/_auth.login.tsx", - { - id: "routes/_auth.login", - parentId: "routes/_auth", - path: "login", - }, - ], - [ - "routes/_auth.reset-password.tsx", - { - id: "routes/_auth.reset-password", - parentId: "routes/_auth", - path: "reset-password", - }, - ], - [ - "routes/_auth.signup.tsx", - { - id: "routes/_auth.signup", - parentId: "routes/_auth", - path: "signup", - }, - ], - [ - "routes/_landing/index.tsx", - { - id: "routes/_landing", - parentId: "root", - path: undefined, - }, - ], - [ - "routes/_landing._index/index.tsx", - { - id: "routes/_landing._index", - parentId: "routes/_landing", - path: undefined, - index: true, - }, - ], - [ - "routes/_landing.index.tsx", - { - id: "routes/_landing.index", - parentId: "routes/_landing", - path: "index", - }, - ], - [ - "routes/_about.tsx", - { - id: "routes/_about", - parentId: "root", - path: undefined, - }, - ], - [ - "routes/_about.faq.tsx", - { - id: "routes/_about.faq", - parentId: "routes/_about", - path: "faq", - }, - ], - [ - "routes/_about.$splat.tsx", - { - id: "routes/_about.$splat", - parentId: "routes/_about", - path: ":splat", - }, - ], - [ - "routes/app.tsx", - { - id: "routes/app", - parentId: "root", - path: "app", - }, - ], - [ - "routes/app.calendar.$day.tsx", - { - id: "routes/app.calendar.$day", - parentId: "routes/app", - path: "calendar/:day", - }, - ], - [ - "routes/app.calendar._index.tsx", - { - id: "routes/app.calendar._index", - index: true, - parentId: "routes/app", - path: "calendar", - }, - ], - [ - "routes/app.projects.tsx", - { - id: "routes/app.projects", - parentId: "routes/app", - path: "projects", - }, - ], - [ - "routes/app.projects.$id.tsx", - { - id: "routes/app.projects.$id", - parentId: "routes/app.projects", - path: ":id", - }, - ], - [ - "routes/app._pathless.tsx", - { - id: "routes/app._pathless", - parentId: "routes/app", - path: undefined, - }, - ], - [ - "routes/app._pathless._index.tsx", - { - id: "routes/app._pathless._index", - parentId: "routes/app._pathless", - index: true, - path: undefined, - }, - ], - [ - "routes/app._pathless.child.tsx", - { - id: "routes/app._pathless.child", - parentId: "routes/app._pathless", - path: "child", - }, - ], - [ - "routes/folder/route.tsx", - { - id: "routes/folder", - parentId: "root", - path: "folder", - }, - ], - [ - "routes/[route].tsx", - { - id: "routes/[route]", - parentId: "root", - path: "route", - }, - ], - - // Opt out of parent layout - [ - "routes/app_.projects.$id.roadmap[.pdf].tsx", - { - id: "routes/app_.projects.$id.roadmap[.pdf]", - parentId: "root", - path: "app/projects/:id/roadmap.pdf", - }, - ], - [ - "routes/app_.projects.$id.roadmap.tsx", - { - id: "routes/app_.projects.$id.roadmap", - parentId: "root", - path: "app/projects/:id/roadmap", - }, - ], - - [ - "routes/app.skip.tsx", - { - id: "routes/app.skip", - parentId: "routes/app", - path: "skip", - }, - ], - [ - "routes/app.skip_.layout.tsx", - { - id: "routes/app.skip_.layout", - index: undefined, - parentId: "routes/app", - path: "skip/layout", - }, - ], - - [ - "routes/app_.skipall_._index.tsx", - { - id: "routes/app_.skipall_._index", - index: true, - parentId: "root", - path: "app/skipall", - }, - ], - - // Escaping route segments - [ - "routes/_about.[$splat].tsx", - { - id: "routes/_about.[$splat]", - parentId: "routes/_about", - path: "$splat", - }, - ], - [ - "routes/_about.[[].tsx", - { - id: "routes/_about.[[]", - parentId: "routes/_about", - path: "[", - }, - ], - [ - "routes/_about.[]].tsx", - { - id: "routes/_about.[]]", - parentId: "routes/_about", - path: "]", - }, - ], - [ - "routes/_about.[.].tsx", - { - id: "routes/_about.[.]", - parentId: "routes/_about", - path: ".", - }, - ], - - // Optional route segments - [ - "routes/(nested)._layout.($slug).tsx", - { - id: "routes/(nested)._layout.($slug)", - parentId: "root", - path: "nested?/:slug?", - }, - ], - [ - "routes/(routes).$.tsx", - { - id: "routes/(routes).$", - parentId: "root", - path: "routes?/*", - }, - ], - [ - "routes/(routes).(sub).$.tsx", - { - id: "routes/(routes).(sub).$", - parentId: "root", - path: "routes?/sub?/*", - }, - ], - [ - "routes/(routes).($slug).tsx", - { - id: "routes/(routes).($slug)", - parentId: "root", - path: "routes?/:slug?", - }, - ], - [ - "routes/(routes).sub.($slug).tsx", - { - id: "routes/(routes).sub.($slug)", - parentId: "root", - path: "routes?/sub/:slug?", - }, - ], - [ - "routes/(nested).$.tsx", - { - id: "routes/(nested).$", - parentId: "root", - path: "nested?/*", - }, - ], - [ - "routes/(flat).$.tsx", - { - id: "routes/(flat).$", - parentId: "root", - path: "flat?/*", - }, - ], - [ - "routes/(flat).($slug).tsx", - { - id: "routes/(flat).($slug)", - parentId: "root", - path: "flat?/:slug?", - }, - ], - [ - "routes/flat.(sub).tsx", - { - id: "routes/flat.(sub)", - parentId: "root", - path: "flat/sub?", - }, - ], - [ - "routes/_layout.tsx", - { - id: "routes/_layout", - parentId: "root", - path: undefined, - }, - ], - [ - "routes/_layout.(test).tsx", - { - id: "routes/_layout.(test)", - parentId: "routes/_layout", - path: "test?", - }, - ], - [ - "routes/_layout.($slug).tsx", - { - id: "routes/_layout.($slug)", - parentId: "routes/_layout", - path: ":slug?", - }, - ], - - // Optional + escaped route segments - [ - "routes/([_index]).tsx", - { - id: "routes/([_index])", - parentId: "root", - path: "_index?", - }, - ], - [ - "routes/(_[i]ndex).([[]).([[]]).tsx", - { - id: "routes/(_[i]ndex).([[]).([[]])", - parentId: "root", - path: "_index?/[?/[]?", - }, - ], - [ - "routes/(sub).([[]).tsx", - { - id: "routes/(sub).([[])", - parentId: "root", - path: "sub?/[?", - }, - ], - [ - "routes/(sub).(]).tsx", - { - id: "routes/(sub).(])", - parentId: "root", - path: "sub?/]?", - }, - ], - [ - "routes/(sub).([[]]).tsx", - { - id: "routes/(sub).([[]])", - parentId: "root", - path: "sub?/[]?", - }, - ], - [ - "routes/(beef]).tsx", - { - id: "routes/(beef])", - parentId: "root", - path: "beef]?", - }, - ], - [ - "routes/(test).(inde[x]).tsx", - { - id: "routes/(test).(inde[x])", - parentId: "root", - path: "test?/index?", - }, - ], - [ - "routes/($[$dollabills]).([.]lol).(what).([$]).($up).tsx", - { - id: "routes/($[$dollabills]).([.]lol).(what).([$]).($up)", - parentId: "root", - path: ":$dollabills?/.lol?/what?/$?/:up?", - }, - ], - [ - "routes/(posts).($slug).([image.jpg]).tsx", - { - id: "routes/(posts).($slug).([image.jpg])", - parentId: "root", - path: "posts?/:slug?/image.jpg?", - }, - ], - [ - "routes/(sub).([sitemap.xml]).tsx", - { - id: "routes/(sub).([sitemap.xml])", - parentId: "root", - path: "sub?/sitemap.xml?", - }, - ], - [ - "routes/(sub).[(sitemap.xml)].tsx", - { - id: "routes/(sub).[(sitemap.xml)]", - parentId: "root", - path: "sub?/(sitemap.xml)", - }, - ], - [ - "routes/($slug[.]json).tsx", - { - id: "routes/($slug[.]json)", - parentId: "root", - path: ":slug.json?", - }, - ], - - [ - "routes/[]otherstuff].tsx", - { - id: "routes/[]otherstuff]", - parentId: "root", - path: "otherstuff]", - }, - ], - [ - "routes/brand.tsx", - { - id: "routes/brand", - parentId: "root", - path: "brand", - }, - ], - [ - "routes/brand._index.tsx", - { - id: "routes/brand._index", - parentId: "routes/brand", - index: true, - }, - ], - [ - "routes/$.tsx", - { - id: "routes/$", - parentId: "root", - path: "*", - }, - ], - ]; - - let files: [string, RouteManifestEntry][] = testFiles.map( - ([file, route]) => { - return [file, { ...route, file }]; - } - ); - - let routeManifest = flatRoutesUniversal( - APP_DIR, - files.map(([file]) => path.join(APP_DIR, file)) - ); - let routes = Object.values(routeManifest); - - test("route per file", () => { - expect(routes).toHaveLength(files.length); - }); - - for (let [file, route] of files) { - test(`hierarchy for ${file} - ${route.path}`, () => { - expect(routes).toContainEqual(route); - }); - } - }); - - describe("doesn't warn when there's not a route collision", () => { - let consoleError = jest - .spyOn(global.console, "error") - .mockImplementation(() => {}); - - afterEach(consoleError.mockReset); - - test("same number of segments and the same dynamic segment index", () => { - let testFiles = [ - path.join(APP_DIR, "routes", "_user.$username.tsx"), - path.join(APP_DIR, "routes", "sneakers.$sneakerId.tsx"), - ]; - - let routeManifest = flatRoutesUniversal(APP_DIR, testFiles); - - let routes = Object.values(routeManifest); - - expect(routes).toHaveLength(testFiles.length); - expect(consoleError).not.toHaveBeenCalled(); - }); - }); - - describe("warns when there's a route collision", () => { - let consoleError = jest - .spyOn(global.console, "error") - .mockImplementation(() => {}); - - afterEach(consoleError.mockReset); - - test("index files", () => { - let testFiles = [ - path.join("routes", "_dashboard._index.tsx"), - path.join("routes", "_landing._index.tsx"), - path.join("routes", "_index.tsx"), - ]; - - // route manifest uses the full path - let fullPaths = testFiles.map((file) => path.join(APP_DIR, file)); - - // this is for the expected error message, - // which uses the relative path from the app directory internally - let normalizedTestFiles = testFiles.map((file) => normalizeSlashes(file)); - - let routeManifest = flatRoutesUniversal(APP_DIR, fullPaths); - - let routes = Object.values(routeManifest); - - expect(routes).toHaveLength(1); - expect(consoleError).toHaveBeenCalledWith( - getRoutePathConflictErrorMessage("/", normalizedTestFiles) - ); - }); - - test("folder/route.tsx matching folder.tsx", () => { - let testFiles = [ - path.join("routes", "dashboard", "route.tsx"), - path.join("routes", "dashboard.tsx"), - ]; - - // route manifest uses the full path - let fullPaths = testFiles.map((file) => path.join(APP_DIR, file)); - - // this is for the expected error message, - // which uses the relative path from the app directory internally - let normalizedTestFiles = testFiles.map((file) => normalizeSlashes(file)); - - let routeManifest = flatRoutesUniversal(APP_DIR, fullPaths); - - let routes = Object.values(routeManifest); - - expect(routes).toHaveLength(1); - expect(consoleError).toHaveBeenCalledWith( - getRouteIdConflictErrorMessage( - path.posix.join("routes", "dashboard"), - normalizedTestFiles - ) - ); - }); - - test("pathless layouts should not collide", () => { - let testFiles = [ - path.join(APP_DIR, "routes", "_a.tsx"), - path.join(APP_DIR, "routes", "_a._index.tsx"), - path.join(APP_DIR, "routes", "_a.a.tsx"), - path.join(APP_DIR, "routes", "_b.tsx"), - path.join(APP_DIR, "routes", "_b.b.tsx"), - ]; - - let routeManifest = flatRoutesUniversal(APP_DIR, testFiles); - - let routes = Object.values(routeManifest); - - expect(consoleError).not.toHaveBeenCalled(); - expect(routes).toHaveLength(5); - - // When using folders and route.tsx files - testFiles = [ - path.join(APP_DIR, "routes", "_a", "route.tsx"), - path.join(APP_DIR, "routes", "_a._index", "route.tsx"), - path.join(APP_DIR, "routes", "_a.a", "route.tsx"), - path.join(APP_DIR, "routes", "_b", "route.tsx"), - path.join(APP_DIR, "routes", "_b.b", "route.tsx"), - ]; - - routeManifest = flatRoutesUniversal(APP_DIR, testFiles); - - routes = Object.values(routeManifest); - - expect(consoleError).not.toHaveBeenCalled(); - expect(routes).toHaveLength(5); - }); - - test("nested pathless layouts should not collide", () => { - let testFiles = [ - path.join(APP_DIR, "routes", "nested._a.tsx"), - path.join(APP_DIR, "routes", "nested._a._index.tsx"), - path.join(APP_DIR, "routes", "nested._a.a.tsx"), - path.join(APP_DIR, "routes", "nested._b.tsx"), - path.join(APP_DIR, "routes", "nested._b.b.tsx"), - ]; - - let routeManifest = flatRoutesUniversal(APP_DIR, testFiles); - - let routes = Object.values(routeManifest); - - expect(consoleError).not.toHaveBeenCalled(); - expect(routes).toHaveLength(5); - - // When using folders and route.tsx files - testFiles = [ - path.join(APP_DIR, "routes", "nested._a", "route.tsx"), - path.join(APP_DIR, "routes", "nested._a._index", "route.tsx"), - path.join(APP_DIR, "routes", "nested._a.a", "route.tsx"), - path.join(APP_DIR, "routes", "nested._b", "route.tsx"), - path.join(APP_DIR, "routes", "nested._b.b", "route.tsx"), - ]; - - routeManifest = flatRoutesUniversal(APP_DIR, testFiles); - - routes = Object.values(routeManifest); - - expect(consoleError).not.toHaveBeenCalled(); - expect(routes).toHaveLength(5); - }); - - test("legit collisions without nested pathless layouts should collide (paths)", () => { - let testFiles = [ - path.join(APP_DIR, "routes", "nested._a.tsx"), - path.join(APP_DIR, "routes", "nested._a.a.tsx"), - path.join(APP_DIR, "routes", "nested._b.tsx"), - path.join(APP_DIR, "routes", "nested._b.a.tsx"), - ]; - - let routeManifest = flatRoutesUniversal(APP_DIR, testFiles); - - let routes = Object.values(routeManifest); - - expect(consoleError).toHaveBeenCalledWith( - getRoutePathConflictErrorMessage("/nested/a", [ - "routes/nested._a.a.tsx", - "routes/nested._b.a.tsx", - ]) - ); - expect(routes).toHaveLength(3); - - // When using folders and route.tsx files - consoleError.mockClear(); - testFiles = [ - path.join(APP_DIR, "routes", "nested._a", "route.tsx"), - path.join(APP_DIR, "routes", "nested._a.a", "route.tsx"), - path.join(APP_DIR, "routes", "nested._b", "route.tsx"), - path.join(APP_DIR, "routes", "nested._b.a", "route.tsx"), - ]; - - routeManifest = flatRoutesUniversal(APP_DIR, testFiles); - - routes = Object.values(routeManifest); - - expect(consoleError).toHaveBeenCalledWith( - getRoutePathConflictErrorMessage("/nested/a", [ - "routes/nested._a.a/route.tsx", - "routes/nested._b.a/route.tsx", - ]) - ); - expect(routes).toHaveLength(3); - }); - - test("legit collisions without nested pathless layouts should collide (index routes)", () => { - let testFiles = [ - path.join(APP_DIR, "routes", "nested._a.tsx"), - path.join(APP_DIR, "routes", "nested._a._index.tsx"), - path.join(APP_DIR, "routes", "nested._b.tsx"), - path.join(APP_DIR, "routes", "nested._b._index.tsx"), - ]; - - let routeManifest = flatRoutesUniversal(APP_DIR, testFiles); - - let routes = Object.values(routeManifest); - - expect(consoleError).toHaveBeenCalledWith( - getRoutePathConflictErrorMessage("/nested", [ - "routes/nested._a._index.tsx", - "routes/nested._b._index.tsx", - ]) - ); - expect(routes).toHaveLength(3); - - // When using folders and route.tsx files - consoleError.mockClear(); - testFiles = [ - path.join(APP_DIR, "routes", "nested._a", "route.tsx"), - path.join(APP_DIR, "routes", "nested._a._index", "route.tsx"), - path.join(APP_DIR, "routes", "nested._b", "route.tsx"), - path.join(APP_DIR, "routes", "nested._b._index", "route.tsx"), - ]; - - routeManifest = flatRoutesUniversal(APP_DIR, testFiles); - - routes = Object.values(routeManifest); - - expect(consoleError).toHaveBeenCalledWith( - getRoutePathConflictErrorMessage("/nested", [ - "routes/nested._a._index/route.tsx", - "routes/nested._b._index/route.tsx", - ]) - ); - expect(routes).toHaveLength(3); - }); - }); -}); diff --git a/packages/remix-fs-routes/flatRoutes.ts b/packages/remix-fs-routes/flatRoutes.ts deleted file mode 100644 index 9d48763b9dc..00000000000 --- a/packages/remix-fs-routes/flatRoutes.ts +++ /dev/null @@ -1,566 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import { makeRe } from "minimatch"; -import type { - UNSAFE_RouteManifest as RouteManifest, - UNSAFE_RouteManifestEntry as RouteManifestEntry, -} from "@remix-run/dev"; - -import { normalizeSlashes } from "./normalizeSlashes"; - -export const routeModuleExts = [".js", ".jsx", ".ts", ".tsx", ".md", ".mdx"]; - -export let paramPrefixChar = "$" as const; -export let escapeStart = "[" as const; -export let escapeEnd = "]" as const; - -export let optionalStart = "(" as const; -export let optionalEnd = ")" as const; - -const PrefixLookupTrieEndSymbol = Symbol("PrefixLookupTrieEndSymbol"); -type PrefixLookupNode = { - [key: string]: PrefixLookupNode; -} & Record; - -class PrefixLookupTrie { - root: PrefixLookupNode = { - [PrefixLookupTrieEndSymbol]: false, - }; - - add(value: string) { - if (!value) throw new Error("Cannot add empty string to PrefixLookupTrie"); - - let node = this.root; - for (let char of value) { - if (!node[char]) { - node[char] = { - [PrefixLookupTrieEndSymbol]: false, - }; - } - node = node[char]; - } - node[PrefixLookupTrieEndSymbol] = true; - } - - findAndRemove( - prefix: string, - filter: (nodeValue: string) => boolean - ): string[] { - let node = this.root; - for (let char of prefix) { - if (!node[char]) return []; - node = node[char]; - } - - return this.#findAndRemoveRecursive([], node, prefix, filter); - } - - #findAndRemoveRecursive( - values: string[], - node: PrefixLookupNode, - prefix: string, - filter: (nodeValue: string) => boolean - ): string[] { - for (let char of Object.keys(node)) { - this.#findAndRemoveRecursive(values, node[char], prefix + char, filter); - } - - if (node[PrefixLookupTrieEndSymbol] && filter(prefix)) { - node[PrefixLookupTrieEndSymbol] = false; - values.push(prefix); - } - - return values; - } -} - -export function flatRoutes( - appDirectory: string, - ignoredFilePatterns: string[] = [], - prefix = "routes" -) { - let ignoredFileRegex = Array.from(new Set(["**/.*", ...ignoredFilePatterns])) - .map((re) => makeRe(re)) - .filter((re: any): re is RegExp => !!re); - let routesDir = path.join(appDirectory, prefix); - - let rootRoute = findFile(appDirectory, "root", routeModuleExts); - - if (!rootRoute) { - throw new Error( - `Could not find a root route module in the app directory: ${appDirectory}` - ); - } - - if (!fs.existsSync(rootRoute)) { - throw new Error( - `Could not find the routes directory: ${routesDir}. Did you forget to create it?` - ); - } - - // Only read the routes directory - let entries = fs.readdirSync(routesDir, { - withFileTypes: true, - encoding: "utf-8", - }); - - let routes: string[] = []; - for (let entry of entries) { - let filepath = normalizeSlashes(path.join(routesDir, entry.name)); - - let route: string | null = null; - // If it's a directory, don't recurse into it, instead just look for a route module - if (entry.isDirectory()) { - route = findRouteModuleForFolder( - appDirectory, - filepath, - ignoredFileRegex - ); - } else if (entry.isFile()) { - route = findRouteModuleForFile(appDirectory, filepath, ignoredFileRegex); - } - - if (route) routes.push(route); - } - - let routeManifest = flatRoutesUniversal(appDirectory, routes, prefix); - return routeManifest; -} - -export function flatRoutesUniversal( - appDirectory: string, - routes: string[], - prefix: string = "routes" -): RouteManifest { - let urlConflicts = new Map(); - let routeManifest: RouteManifest = {}; - let prefixLookup = new PrefixLookupTrie(); - let uniqueRoutes = new Map(); - let routeIdConflicts = new Map(); - - // id -> file - let routeIds = new Map(); - - for (let file of routes) { - let normalizedFile = normalizeSlashes(file); - let routeExt = path.extname(normalizedFile); - let routeDir = path.dirname(normalizedFile); - let normalizedApp = normalizeSlashes(appDirectory); - let routeId = - routeDir === path.posix.join(normalizedApp, prefix) - ? path.posix - .relative(normalizedApp, normalizedFile) - .slice(0, -routeExt.length) - : path.posix.relative(normalizedApp, routeDir); - - let conflict = routeIds.get(routeId); - if (conflict) { - let currentConflicts = routeIdConflicts.get(routeId); - if (!currentConflicts) { - currentConflicts = [path.posix.relative(normalizedApp, conflict)]; - } - currentConflicts.push(path.posix.relative(normalizedApp, normalizedFile)); - routeIdConflicts.set(routeId, currentConflicts); - continue; - } - - routeIds.set(routeId, normalizedFile); - } - - let sortedRouteIds = Array.from(routeIds).sort( - ([a], [b]) => b.length - a.length - ); - - for (let [routeId, file] of sortedRouteIds) { - let index = routeId.endsWith("_index"); - let [segments, raw] = getRouteSegments(routeId.slice(prefix.length + 1)); - let pathname = createRoutePath(segments, raw, index); - - routeManifest[routeId] = { - file: file.slice(appDirectory.length + 1), - id: routeId, - path: pathname, - }; - if (index) routeManifest[routeId].index = true; - let childRouteIds = prefixLookup.findAndRemove(routeId, (value) => { - return [".", "/"].includes(value.slice(routeId.length).charAt(0)); - }); - prefixLookup.add(routeId); - - if (childRouteIds.length > 0) { - for (let childRouteId of childRouteIds) { - routeManifest[childRouteId].parentId = routeId; - } - } - } - - // path creation - let parentChildrenMap = new Map(); - for (let [routeId] of sortedRouteIds) { - let config = routeManifest[routeId]; - if (!config.parentId) continue; - let existingChildren = parentChildrenMap.get(config.parentId) || []; - existingChildren.push(config); - parentChildrenMap.set(config.parentId, existingChildren); - } - - for (let [routeId] of sortedRouteIds) { - let config = routeManifest[routeId]; - let originalPathname = config.path || ""; - let pathname = config.path; - let parentConfig = config.parentId ? routeManifest[config.parentId] : null; - if (parentConfig?.path && pathname) { - pathname = pathname - .slice(parentConfig.path.length) - .replace(/^\//, "") - .replace(/\/$/, ""); - } - - if (!config.parentId) config.parentId = "root"; - config.path = pathname || undefined; - - /** - * We do not try to detect path collisions for pathless layout route - * files because, by definition, they create the potential for route - * collisions _at that level in the tree_. - * - * Consider example where a user may want multiple pathless layout routes - * for different subfolders - * - * routes/ - * account.tsx - * account._private.tsx - * account._private.orders.tsx - * account._private.profile.tsx - * account._public.tsx - * account._public.login.tsx - * account._public.perks.tsx - * - * In order to support both a public and private layout for `/account/*` - * URLs, we are creating a mutually exclusive set of URLs beneath 2 - * separate pathless layout routes. In this case, the route paths for - * both account._public.tsx and account._private.tsx is the same - * (/account), but we're again not expecting to match at that level. - * - * By only ignoring this check when the final portion of the filename is - * pathless, we will still detect path collisions such as: - * - * routes/parent._pathless.foo.tsx - * routes/parent._pathless2.foo.tsx - * - * and - * - * routes/parent._pathless/index.tsx - * routes/parent._pathless2/index.tsx - */ - let lastRouteSegment = config.id - .replace(new RegExp(`^${prefix}/`), "") - .split(".") - .pop(); - let isPathlessLayoutRoute = - lastRouteSegment && - lastRouteSegment.startsWith("_") && - lastRouteSegment !== "_index"; - if (isPathlessLayoutRoute) { - continue; - } - - let conflictRouteId = originalPathname + (config.index ? "?index" : ""); - let conflict = uniqueRoutes.get(conflictRouteId); - uniqueRoutes.set(conflictRouteId, config); - - if (conflict && (originalPathname || config.index)) { - let currentConflicts = urlConflicts.get(originalPathname); - if (!currentConflicts) currentConflicts = [conflict]; - currentConflicts.push(config); - urlConflicts.set(originalPathname, currentConflicts); - continue; - } - } - - if (routeIdConflicts.size > 0) { - for (let [routeId, files] of routeIdConflicts.entries()) { - console.error(getRouteIdConflictErrorMessage(routeId, files)); - } - } - - // report conflicts - if (urlConflicts.size > 0) { - for (let [path, routes] of urlConflicts.entries()) { - // delete all but the first route from the manifest - for (let i = 1; i < routes.length; i++) { - delete routeManifest[routes[i].id]; - } - let files = routes.map((r) => r.file); - console.error(getRoutePathConflictErrorMessage(path, files)); - } - } - - return routeManifest; -} - -function findRouteModuleForFile( - appDirectory: string, - filepath: string, - ignoredFileRegex: RegExp[] -): string | null { - let relativePath = normalizeSlashes(path.relative(appDirectory, filepath)); - let isIgnored = ignoredFileRegex.some((regex) => regex.test(relativePath)); - if (isIgnored) return null; - return filepath; -} - -function findRouteModuleForFolder( - appDirectory: string, - filepath: string, - ignoredFileRegex: RegExp[] -): string | null { - let relativePath = path.relative(appDirectory, filepath); - let isIgnored = ignoredFileRegex.some((regex) => regex.test(relativePath)); - if (isIgnored) return null; - - let routeRouteModule = findFile(filepath, "route", routeModuleExts); - let routeIndexModule = findFile(filepath, "index", routeModuleExts); - - // if both a route and index module exist, throw a conflict error - // preferring the route module over the index module - if (routeRouteModule && routeIndexModule) { - let [segments, raw] = getRouteSegments( - path.relative(appDirectory, filepath) - ); - let routePath = createRoutePath(segments, raw, false); - console.error( - getRoutePathConflictErrorMessage(routePath || "/", [ - routeRouteModule, - routeIndexModule, - ]) - ); - } - - return routeRouteModule || routeIndexModule || null; -} - -type State = - | // normal path segment normal character concatenation until we hit a special character or the end of the segment (i.e. `/`, `.`, '\') - "NORMAL" - // we hit a `[` and are now in an escape sequence until we hit a `]` - take characters literally and skip isSegmentSeparator checks - | "ESCAPE" - // we hit a `(` and are now in an optional segment until we hit a `)` or an escape sequence - | "OPTIONAL" - // we previously were in a opt fional segment and hit a `[` and are now in an escape sequence until we hit a `]` - take characters literally and skip isSegmentSeparator checks - afterwards go back to OPTIONAL state - | "OPTIONAL_ESCAPE"; - -export function getRouteSegments(routeId: string): [string[], string[]] { - let routeSegments: string[] = []; - let rawRouteSegments: string[] = []; - let index = 0; - let routeSegment = ""; - let rawRouteSegment = ""; - let state: State = "NORMAL"; - - let pushRouteSegment = (segment: string, rawSegment: string) => { - if (!segment) return; - - let notSupportedInRR = (segment: string, char: string) => { - throw new Error( - `Route segment "${segment}" for "${routeId}" cannot contain "${char}".\n` + - `If this is something you need, upvote this proposal for React Router https://github.com/remix-run/react-router/discussions/9822.` - ); - }; - - if (rawSegment.includes("*")) { - return notSupportedInRR(rawSegment, "*"); - } - - if (rawSegment.includes(":")) { - return notSupportedInRR(rawSegment, ":"); - } - - if (rawSegment.includes("/")) { - return notSupportedInRR(segment, "/"); - } - - routeSegments.push(segment); - rawRouteSegments.push(rawSegment); - }; - - while (index < routeId.length) { - let char = routeId[index]; - index++; //advance to next char - - switch (state) { - case "NORMAL": { - if (isSegmentSeparator(char)) { - pushRouteSegment(routeSegment, rawRouteSegment); - routeSegment = ""; - rawRouteSegment = ""; - state = "NORMAL"; - break; - } - if (char === escapeStart) { - state = "ESCAPE"; - rawRouteSegment += char; - break; - } - if (char === optionalStart) { - state = "OPTIONAL"; - rawRouteSegment += char; - break; - } - if (!routeSegment && char === paramPrefixChar) { - if (index === routeId.length) { - routeSegment += "*"; - rawRouteSegment += char; - } else { - routeSegment += ":"; - rawRouteSegment += char; - } - break; - } - - routeSegment += char; - rawRouteSegment += char; - break; - } - case "ESCAPE": { - if (char === escapeEnd) { - state = "NORMAL"; - rawRouteSegment += char; - break; - } - - routeSegment += char; - rawRouteSegment += char; - break; - } - case "OPTIONAL": { - if (char === optionalEnd) { - routeSegment += "?"; - rawRouteSegment += char; - state = "NORMAL"; - break; - } - - if (char === escapeStart) { - state = "OPTIONAL_ESCAPE"; - rawRouteSegment += char; - break; - } - - if (!routeSegment && char === paramPrefixChar) { - if (index === routeId.length) { - routeSegment += "*"; - rawRouteSegment += char; - } else { - routeSegment += ":"; - rawRouteSegment += char; - } - break; - } - - routeSegment += char; - rawRouteSegment += char; - break; - } - case "OPTIONAL_ESCAPE": { - if (char === escapeEnd) { - state = "OPTIONAL"; - rawRouteSegment += char; - break; - } - - routeSegment += char; - rawRouteSegment += char; - break; - } - } - } - - // process remaining segment - pushRouteSegment(routeSegment, rawRouteSegment); - return [routeSegments, rawRouteSegments]; -} - -export function createRoutePath( - routeSegments: string[], - rawRouteSegments: string[], - isIndex?: boolean -) { - let result: string[] = []; - - if (isIndex) { - routeSegments = routeSegments.slice(0, -1); - } - - for (let index = 0; index < routeSegments.length; index++) { - let segment = routeSegments[index]; - let rawSegment = rawRouteSegments[index]; - - // skip pathless layout segments - if (segment.startsWith("_") && rawSegment.startsWith("_")) { - continue; - } - - // remove trailing slash - if (segment.endsWith("_") && rawSegment.endsWith("_")) { - segment = segment.slice(0, -1); - } - - result.push(segment); - } - - return result.length ? result.join("/") : undefined; -} - -export function getRoutePathConflictErrorMessage( - pathname: string, - routes: string[] -) { - let [taken, ...others] = routes; - - if (!pathname.startsWith("/")) { - pathname = "/" + pathname; - } - - return ( - `⚠️ Route Path Collision: "${pathname}"\n\n` + - `The following routes all define the same URL, only the first one will be used\n\n` + - `🟢 ${taken}\n` + - others.map((route) => `⭕️️ ${route}`).join("\n") + - "\n" - ); -} - -export function getRouteIdConflictErrorMessage( - routeId: string, - files: string[] -) { - let [taken, ...others] = files; - - return ( - `⚠️ Route ID Collision: "${routeId}"\n\n` + - `The following routes all define the same Route ID, only the first one will be used\n\n` + - `🟢 ${taken}\n` + - others.map((route) => `⭕️️ ${route}`).join("\n") + - "\n" - ); -} - -export function isSegmentSeparator(checkChar: string | undefined) { - if (!checkChar) return false; - return ["/", ".", path.win32.sep].includes(checkChar); -} - -function findFile( - dir: string, - basename: string, - extensions: string[] -): string | undefined { - for (let ext of extensions) { - let name = basename + ext; - let file = path.join(dir, name); - if (fs.existsSync(file)) return file; - } - - return undefined; -} diff --git a/packages/remix-fs-routes/index.ts b/packages/remix-fs-routes/index.ts index faf5ea44c63..04e61405570 100644 --- a/packages/remix-fs-routes/index.ts +++ b/packages/remix-fs-routes/index.ts @@ -1,12 +1,14 @@ import fs from "node:fs"; import path from "node:path"; -import { UNSAFE_routeManifestToRouteConfig as routeManifestToRouteConfig } from "@remix-run/dev"; +import { + UNSAFE_flatRoutes as flatRoutesImpl, + UNSAFE_routeManifestToRouteConfig as routeManifestToRouteConfig, +} from "@remix-run/dev"; import { type RouteConfigEntry, getAppDirectory, } from "@remix-run/route-config"; -import { flatRoutes as flatRoutesImpl } from "./flatRoutes"; import { normalizeSlashes } from "./normalizeSlashes"; /** diff --git a/packages/remix-fs-routes/jest.config.js b/packages/remix-fs-routes/jest.config.js deleted file mode 100644 index 47d93e75154..00000000000 --- a/packages/remix-fs-routes/jest.config.js +++ /dev/null @@ -1,6 +0,0 @@ -/** @type {import('jest').Config} */ -module.exports = { - ...require("../../jest/jest.config.shared"), - displayName: "fs-routes", - setupFiles: [], -}; diff --git a/packages/remix-fs-routes/package.json b/packages/remix-fs-routes/package.json index 75b82093267..fc174d16a55 100644 --- a/packages/remix-fs-routes/package.json +++ b/packages/remix-fs-routes/package.json @@ -23,9 +23,6 @@ "scripts": { "tsc": "tsc" }, - "dependencies": { - "minimatch": "^9.0.0" - }, "devDependencies": { "@remix-run/dev": "workspace:*", "@remix-run/route-config": "workspace:*", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3ee9624be80..2a5540bdb5e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1199,10 +1199,6 @@ importers: version: 5.1.6 packages/remix-fs-routes: - dependencies: - minimatch: - specifier: ^9.0.0 - version: 9.0.3 devDependencies: '@remix-run/dev': specifier: workspace:* From 7b9806feb00d1082e9fa60028ee46220e0efd4af Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Fri, 18 Oct 2024 11:52:35 +1100 Subject: [PATCH 10/22] Add routes.ts docs to future flags guide --- docs/start/future-flags.md | 134 +++++++++++++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) diff --git a/docs/start/future-flags.md b/docs/start/future-flags.md index 664388b602b..2ca6fb6523b 100644 --- a/docs/start/future-flags.md +++ b/docs/start/future-flags.md @@ -476,6 +476,139 @@ You may find some usage for the new [``][discover-prop] API if yo Opt into automatic [dependency optimization][dependency-optimization] during development. This flag will remain in an "unstable" state until React Router v7 so you do not need to adopt this in your Remix v2 app prior to upgrading to React Router v7. +## routes.ts + +Config-based routing is the new default in React Router v7. Support for `routes.ts` and its related APIs in Remix are designed as a migration path to help minimize the number changes required when moving your Remix project over to React Router v7. Since React Router v7 is not yet stable, these APIs are also considered unstable. + +While not a future flag, the presence of an `app/routes.ts` file will disable Remix's built-in file system routing and opt your project into React Router v7's config-based routing. To opt back in to file system routing, this can be explicitly configured within `routes.ts` as we'll cover below. + +**Update your code** + +To migrate Remix's file system routing and route config to the equivalent setup in React Router v7, you can follow these steps: + +👉 **Install `@remix-run/route-config`** + +This package matches the API of React Router v7's `@react-router/dev/routes`, making the React Router v7 migration as easy as possible. + +```shellscript nonumber +npm install --dev @remix-run/route-config +``` + +This provides the core `RouteConfig` type as well as a set of helpers for configuring routes in code. + +👉 **Add an `app/routes.ts` file without any configured routes** + +```shellscript nonumber +touch app/routes.ts +``` + +```ts filename=app/routes.ts +import type { RouteConfig } from "@remix-run/route-config"; + +export const routes: RouteConfig = []; +``` + +This is a good way to check that your new `routes.ts` file is being picked up successfully. Your app should now be rendering a blank page since there aren't any routes defined yet. + +👉 **Install `@remix-run/fs-routes` and use it in `routes.ts`** + +This package matches the API of React Router v7's `@react-router/fs-routes`, making the React Router v7 migration as easy as possible. + +> If you've configured `ignoredRouteFiles` to `["**/*"]`, you should skip this step since you're already opting out of Remix's file system routing. + +```ts filename=app/routes.ts +import { flatRoutes } from "@remix-run/fs-routes"; +import type { RouteConfig } from "@remix-run/route-config"; + +export const routes: RouteConfig = flatRoutes(); +``` + +👉 **If you used the `routes` config option, add `@remix-run/routes-option-adapter` and use it in `routes.ts`** + +Remix provides a mechanism for defining routes in code and plugging in alternative file system routing conventions, available via the `routes` option on the Vite plugin. + +To make migration easier, an adapter package is available that converts Remix's `routes` option into React Router's `RouteConfig` array. + +To get started, first install the adapter: + +```shellscript nonumber +npm install --dev @remix-run/routes-option-adapter +``` + +This package matches the API of React Router v7's `@react-router/remix-route-config-adapter`, making the React Router v7 migration as easy as possible. + +Then, update your `routes.ts` file to use the adapter, passing the value of your `routes` option to the `routesOptionAdapter` function which will return an array of configured routes. + +For example, if you were using the `routes` option to use an alternative file system routing implementation like [remix-flat-routes]: + +```ts filename=app/routes.ts +import { type RouteConfig } from "@remix-run/route-config"; +import { routesOptionAdapter } from "@remix-run/routes-option-adapter"; +import { flatRoutes } from "remix-flat-routes"; + +export const routes: RouteConfig = routesOptionAdapter( + (defineRoutes) => flatRoutes("routes", defineRoutes) +); +``` + +Or, if you were using the `routes` option to define config-based routes: + +```ts filename=app/routes.ts +import { flatRoutes } from "@remix-run/fs-routes"; +import { type RouteConfig } from "@remix-run/route-config"; +import { routesOptionAdapter } from "@remix-run/routes-option-adapter"; + +export const routes: RouteConfig = routesOptionAdapter( + (defineRoutes) => { + return defineRoutes((route) => { + route("/", "home/route.tsx", { index: true }); + route("about", "about/route.tsx"); + route("", "concerts/layout.tsx", () => { + route("trending", "concerts/trending.tsx"); + route(":city", "concerts/city.tsx"); + }); + }); + } +); +``` + +If you're defining config-based routes in this way, you might want to consider migrating to the new route config API since it's more streamlined while still being very similar to the old API. For example, the routes above would look like this: + +```ts +import { + type RouteConfig, + route, + layout, + index, +} from "@remix-run/route-config"; + +export const routes: RouteConfig = [ + index("home/route.tsx"), + route("about", "about/route.tsx"), + layout("concerts/layout.tsx", [ + route("trending", "concerts/trending.tsx"), + route(":city", "concerts/city.tsx"), + ]), +]; +``` + +Note that if you need to mix and match different route config approaches, they can be merged together into a single array of routes. The `RouteConfig` type ensures that everything is still valid. + +```ts +import { flatRoutes } from "@remix-run/fs-routes"; +import type { RouteConfig } from "@remix-run/route-config"; +import { route } from "@remix-run/route-config"; +import { routesOptionAdapter } from "@remix-run/routes-option-adapter"; + +export const routes: RouteConfig = [ + ...(await flatRoutes({ rootDirectory: "fs-routes" })), + + ...(await routesOptionAdapter(/* ... */)), + + route("/hello", "routes/hello.tsx"), +]; +``` + [development-strategy]: ../guides/api-development-strategy [fetcherpersist-rfc]: https://github.com/remix-run/remix/discussions/7698 [relativesplatpath-changelog]: https://github.com/remix-run/remix/blob/main/CHANGELOG.md#futurev3_relativesplatpath @@ -496,3 +629,4 @@ Opt into automatic [dependency optimization][dependency-optimization] during dev [mdx]: https://mdxjs.com [mdx-rollup-plugin]: https://mdxjs.com/packages/rollup [dependency-optimization]: ../guides/dependency-optimization +[remix-flat-routes]: https://github.com/kiliman/remix-flat-routes From 210d09e4c60e66dc65f3fb2f32079e6f19c4cba4 Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Fri, 18 Oct 2024 14:19:41 +1100 Subject: [PATCH 11/22] Rename routes option adapter function --- docs/start/future-flags.md | 14 +++++++------- integration/vite-fs-routes-test.ts | 4 ++-- packages/remix-routes-option-adapter/index.ts | 2 +- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/start/future-flags.md b/docs/start/future-flags.md index 2ca6fb6523b..4afc75967c4 100644 --- a/docs/start/future-flags.md +++ b/docs/start/future-flags.md @@ -537,16 +537,16 @@ npm install --dev @remix-run/routes-option-adapter This package matches the API of React Router v7's `@react-router/remix-route-config-adapter`, making the React Router v7 migration as easy as possible. -Then, update your `routes.ts` file to use the adapter, passing the value of your `routes` option to the `routesOptionAdapter` function which will return an array of configured routes. +Then, update your `routes.ts` file to use the adapter, passing the value of your `routes` option to the `remixRoutesOptionAdapter` function which will return an array of configured routes. For example, if you were using the `routes` option to use an alternative file system routing implementation like [remix-flat-routes]: ```ts filename=app/routes.ts import { type RouteConfig } from "@remix-run/route-config"; -import { routesOptionAdapter } from "@remix-run/routes-option-adapter"; +import { remixRoutesOptionAdapter } from "@remix-run/routes-option-adapter"; import { flatRoutes } from "remix-flat-routes"; -export const routes: RouteConfig = routesOptionAdapter( +export const routes: RouteConfig = remixRoutesOptionAdapter( (defineRoutes) => flatRoutes("routes", defineRoutes) ); ``` @@ -556,9 +556,9 @@ Or, if you were using the `routes` option to define config-based routes: ```ts filename=app/routes.ts import { flatRoutes } from "@remix-run/fs-routes"; import { type RouteConfig } from "@remix-run/route-config"; -import { routesOptionAdapter } from "@remix-run/routes-option-adapter"; +import { remixRoutesOptionAdapter } from "@remix-run/routes-option-adapter"; -export const routes: RouteConfig = routesOptionAdapter( +export const routes: RouteConfig = remixRoutesOptionAdapter( (defineRoutes) => { return defineRoutes((route) => { route("/", "home/route.tsx", { index: true }); @@ -598,12 +598,12 @@ Note that if you need to mix and match different route config approaches, they c import { flatRoutes } from "@remix-run/fs-routes"; import type { RouteConfig } from "@remix-run/route-config"; import { route } from "@remix-run/route-config"; -import { routesOptionAdapter } from "@remix-run/routes-option-adapter"; +import { remixRoutesOptionAdapter } from "@remix-run/routes-option-adapter"; export const routes: RouteConfig = [ ...(await flatRoutes({ rootDirectory: "fs-routes" })), - ...(await routesOptionAdapter(/* ... */)), + ...(await remixRoutesOptionAdapter(/* ... */)), route("/hello", "routes/hello.tsx"), ]; diff --git a/integration/vite-fs-routes-test.ts b/integration/vite-fs-routes-test.ts index 5e9f9d44545..1bd9d06cfb3 100644 --- a/integration/vite-fs-routes-test.ts +++ b/integration/vite-fs-routes-test.ts @@ -29,7 +29,7 @@ test.describe("fs-routes", () => { "app/routes.ts": js` import { type RouteConfig } from "@remix-run/route-config"; import { flatRoutes } from "@remix-run/fs-routes"; - import { routesOptionAdapter } from "@remix-run/routes-option-adapter"; + import { remixRoutesOptionAdapter } from "@remix-run/routes-option-adapter"; export const routes: RouteConfig = [ ...await flatRoutes({ @@ -38,7 +38,7 @@ test.describe("fs-routes", () => { }), // Ensure back compat layer works - ...await routesOptionAdapter(async (defineRoutes) => { + ...await remixRoutesOptionAdapter(async (defineRoutes) => { // Ensure async routes work return defineRoutes((route) => { route("/routes/option/adapter/route", "routes-option-adapter-route.tsx") diff --git a/packages/remix-routes-option-adapter/index.ts b/packages/remix-routes-option-adapter/index.ts index bc16aa18fa8..77e8ee59b37 100644 --- a/packages/remix-routes-option-adapter/index.ts +++ b/packages/remix-routes-option-adapter/index.ts @@ -12,7 +12,7 @@ export type { DefineRoutesFunction }; * option](https://remix.run/docs/en/v2/file-conventions/vite-config#routes), * for use within `routes.ts`. */ -export async function routesOptionAdapter( +export async function remixRoutesOptionAdapter( routes: ( defineRoutes: DefineRoutesFunction ) => From 0cf86a0ad6cb6e349aa3898b418df4cd35b82f9b Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Fri, 18 Oct 2024 14:29:10 +1100 Subject: [PATCH 12/22] Mention Vite requirement in docs --- docs/start/future-flags.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/start/future-flags.md b/docs/start/future-flags.md index 4afc75967c4..b6704bf8a31 100644 --- a/docs/start/future-flags.md +++ b/docs/start/future-flags.md @@ -480,7 +480,7 @@ Opt into automatic [dependency optimization][dependency-optimization] during dev Config-based routing is the new default in React Router v7. Support for `routes.ts` and its related APIs in Remix are designed as a migration path to help minimize the number changes required when moving your Remix project over to React Router v7. Since React Router v7 is not yet stable, these APIs are also considered unstable. -While not a future flag, the presence of an `app/routes.ts` file will disable Remix's built-in file system routing and opt your project into React Router v7's config-based routing. To opt back in to file system routing, this can be explicitly configured within `routes.ts` as we'll cover below. +While not a future flag, the presence of an `app/routes.ts` file when using the Remix Vite plugin will disable Remix's built-in file system routing and opt your project into React Router v7's config-based routing. To opt back in to file system routing, this can be explicitly configured within `routes.ts` as we'll cover below. **Update your code** From 713d049d7022c619305961209ae10e373bd8fcc7 Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Fri, 18 Oct 2024 15:12:06 +1100 Subject: [PATCH 13/22] Reduce git diff --- packages/remix-dev/config/routes.ts | 6 +-- packages/remix-fs-routes/index.ts | 6 ++- packages/remix-fs-routes/normalizeSlashes.ts | 5 --- packages/remix-route-config/routes.ts | 41 ++++++++------------ 4 files changed, 23 insertions(+), 35 deletions(-) delete mode 100644 packages/remix-fs-routes/normalizeSlashes.ts diff --git a/packages/remix-dev/config/routes.ts b/packages/remix-dev/config/routes.ts index 3e2b919517c..cbf90b5f9d3 100644 --- a/packages/remix-dev/config/routes.ts +++ b/packages/remix-dev/config/routes.ts @@ -1,4 +1,4 @@ -import { win32 } from "node:path"; +import * as path from "node:path"; import * as v from "valibot"; import invariant from "../invariant"; @@ -386,9 +386,9 @@ export function createRouteId(file: string) { } export function normalizeSlashes(file: string) { - return file.split(win32.sep).join("/"); + return file.split(path.win32.sep).join("/"); } -export function stripFileExtension(file: string) { +function stripFileExtension(file: string) { return file.replace(/\.[a-z0-9]+$/i, ""); } diff --git a/packages/remix-fs-routes/index.ts b/packages/remix-fs-routes/index.ts index 04e61405570..1ad52e53d2d 100644 --- a/packages/remix-fs-routes/index.ts +++ b/packages/remix-fs-routes/index.ts @@ -9,8 +9,6 @@ import { getAppDirectory, } from "@remix-run/route-config"; -import { normalizeSlashes } from "./normalizeSlashes"; - /** * Creates route config from the file system that matches [Remix's default file * conventions](https://remix.run/docs/en/v2/file-conventions/routes), for @@ -44,3 +42,7 @@ export async function flatRoutes( return routeManifestToRouteConfig(routes); } + +function normalizeSlashes(file: string) { + return file.split(path.win32.sep).join("/"); +} diff --git a/packages/remix-fs-routes/normalizeSlashes.ts b/packages/remix-fs-routes/normalizeSlashes.ts deleted file mode 100644 index 3d16e5041e8..00000000000 --- a/packages/remix-fs-routes/normalizeSlashes.ts +++ /dev/null @@ -1,5 +0,0 @@ -import path from "node:path"; - -export function normalizeSlashes(file: string) { - return file.split(path.win32.sep).join("/"); -} diff --git a/packages/remix-route-config/routes.ts b/packages/remix-route-config/routes.ts index d4a16790eb6..b595c00b3c8 100644 --- a/packages/remix-route-config/routes.ts +++ b/packages/remix-route-config/routes.ts @@ -13,15 +13,12 @@ export function getAppDirectory() { return getRouteConfigAppDirectory(); } -const createConfigRouteOptionKeys = [ +const routeOptionKeys = [ "id", "index", "caseSensitive", ] as const satisfies ReadonlyArray; -type CreateRouteOptions = Pick< - RouteConfigEntry, - typeof createConfigRouteOptionKeys[number] ->; +type RouteOptions = Pick; /** * Helper function for creating a route config entry, for use within * `routes.ts`. @@ -34,16 +31,16 @@ function route( function route( path: string | null | undefined, file: string, - options: CreateRouteOptions, + options: RouteOptions, children?: RouteConfigEntry[] ): RouteConfigEntry; function route( path: string | null | undefined, file: string, - optionsOrChildren: CreateRouteOptions | RouteConfigEntry[] | undefined, + optionsOrChildren: RouteOptions | RouteConfigEntry[] | undefined, children?: RouteConfigEntry[] ): RouteConfigEntry { - let options: CreateRouteOptions = {}; + let options: RouteOptions = {}; if (Array.isArray(optionsOrChildren) || !optionsOrChildren) { children = optionsOrChildren; @@ -55,36 +52,30 @@ function route( file, children, path: path ?? undefined, - ...pick(options, createConfigRouteOptionKeys), + ...pick(options, routeOptionKeys), }; } -const createIndexOptionKeys = ["id"] as const satisfies ReadonlyArray< +const indexOptionKeys = ["id"] as const satisfies ReadonlyArray< keyof RouteConfigEntry >; -type CreateIndexOptions = Pick< - RouteConfigEntry, - typeof createIndexOptionKeys[number] ->; +type IndexOptions = Pick; /** * Helper function for creating a route config entry for an index route, for use * within `routes.ts`. */ -function index(file: string, options?: CreateIndexOptions): RouteConfigEntry { +function index(file: string, options?: IndexOptions): RouteConfigEntry { return { file, index: true, - ...pick(options, createIndexOptionKeys), + ...pick(options, indexOptionKeys), }; } -const createLayoutOptionKeys = ["id"] as const satisfies ReadonlyArray< +const layoutOptionKeys = ["id"] as const satisfies ReadonlyArray< keyof RouteConfigEntry >; -type CreateLayoutOptions = Pick< - RouteConfigEntry, - typeof createLayoutOptionKeys[number] ->; +type LayoutOptions = Pick; /** * Helper function for creating a route config entry for a layout route, for use * within `routes.ts`. @@ -92,15 +83,15 @@ type CreateLayoutOptions = Pick< function layout(file: string, children?: RouteConfigEntry[]): RouteConfigEntry; function layout( file: string, - options: CreateLayoutOptions, + options: LayoutOptions, children?: RouteConfigEntry[] ): RouteConfigEntry; function layout( file: string, - optionsOrChildren: CreateLayoutOptions | RouteConfigEntry[] | undefined, + optionsOrChildren: LayoutOptions | RouteConfigEntry[] | undefined, children?: RouteConfigEntry[] ): RouteConfigEntry { - let options: CreateLayoutOptions = {}; + let options: LayoutOptions = {}; if (Array.isArray(optionsOrChildren) || !optionsOrChildren) { children = optionsOrChildren; @@ -111,7 +102,7 @@ function layout( return { file, children, - ...pick(options, createLayoutOptionKeys), + ...pick(options, layoutOptionKeys), }; } From 40509e0480c222616487e84d71d23616ffb4cb12 Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Fri, 18 Oct 2024 15:22:37 +1100 Subject: [PATCH 14/22] Expand changeset, update docs --- .changeset/popular-humans-attend.md | 16 +++++++++++++++- docs/start/future-flags.md | 4 ++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/.changeset/popular-humans-attend.md b/.changeset/popular-humans-attend.md index af5c3b428cd..e564b3f3806 100644 --- a/.changeset/popular-humans-attend.md +++ b/.changeset/popular-humans-attend.md @@ -2,4 +2,18 @@ "@remix-run/dev": minor --- -Add support for `routes.ts` +Add unstable support for `routes.ts` to assist with the migration to React Router v7. + +Config-based routing is the new default in React Router v7. Support for `routes.ts` and its related APIs in Remix are designed as a migration path to help minimize the number changes required when moving your Remix project over to React Router v7. Since React Router v7 is not yet stable, these APIs are also considered unstable. + +The presence of an `app/routes.ts` file when using the Remix Vite plugin will disable Remix's built-in file system routing and opt your project into React Router v7's config-based routing. + +A minimal `routes.ts` file to support Remix's built-in file system routing looks like this: + +```ts +// app/routes.ts +import { flatRoutes } from "@remix-run/fs-routes"; +import type { RouteConfig } from "@remix-run/route-config"; + +export const routes: RouteConfig = flatRoutes(); +``` diff --git a/docs/start/future-flags.md b/docs/start/future-flags.md index b6704bf8a31..b768792c9e4 100644 --- a/docs/start/future-flags.md +++ b/docs/start/future-flags.md @@ -512,6 +512,10 @@ This is a good way to check that your new `routes.ts` file is being picked up su 👉 **Install `@remix-run/fs-routes` and use it in `routes.ts`** +```shellscript nonumber +npm install --dev @remix-run/fs-routes +``` + This package matches the API of React Router v7's `@react-router/fs-routes`, making the React Router v7 migration as easy as possible. > If you've configured `ignoredRouteFiles` to `["**/*"]`, you should skip this step since you're already opting out of Remix's file system routing. From 63eb78c619b1e684c2abedff25bfb5421d1ae01b Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Fri, 18 Oct 2024 15:45:58 +1100 Subject: [PATCH 15/22] Fix tsconfig outdir --- packages/remix-route-config/tsconfig.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/remix-route-config/tsconfig.json b/packages/remix-route-config/tsconfig.json index 2e85dccebf7..20e4f5cb27e 100644 --- a/packages/remix-route-config/tsconfig.json +++ b/packages/remix-route-config/tsconfig.json @@ -14,6 +14,6 @@ "declaration": true, "emitDeclarationOnly": true, "rootDir": ".", - "outDir": "./dist" + "outDir": "../../build/node_modules/@remix-run/route-config/dist" } } From cf7c6d8563e4b3916609e7b2c7285a428e2af400 Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Fri, 18 Oct 2024 15:49:18 +1100 Subject: [PATCH 16/22] Remove unused minimatch dep --- packages/remix-routes-option-adapter/package.json | 3 --- pnpm-lock.yaml | 4 ---- 2 files changed, 7 deletions(-) diff --git a/packages/remix-routes-option-adapter/package.json b/packages/remix-routes-option-adapter/package.json index 529b43c5531..5bbad0ffb04 100644 --- a/packages/remix-routes-option-adapter/package.json +++ b/packages/remix-routes-option-adapter/package.json @@ -23,9 +23,6 @@ "scripts": { "tsc": "tsc" }, - "dependencies": { - "minimatch": "^9.0.0" - }, "devDependencies": { "@remix-run/dev": "workspace:*", "@remix-run/route-config": "workspace:*", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2a5540bdb5e..ddc56d5b390 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1310,10 +1310,6 @@ importers: version: 5.1.8(@types/node@18.17.1) packages/remix-routes-option-adapter: - dependencies: - minimatch: - specifier: ^9.0.0 - version: 9.0.3 devDependencies: '@remix-run/dev': specifier: workspace:* From ec9aa7481c7896b30522e949d93f18fa72acd984 Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Fri, 18 Oct 2024 15:56:50 +1100 Subject: [PATCH 17/22] Remove exports fields --- packages/remix-fs-routes/package.json | 7 ------- packages/remix-route-config/package.json | 7 ------- packages/remix-routes-option-adapter/package.json | 7 ------- 3 files changed, 21 deletions(-) diff --git a/packages/remix-fs-routes/package.json b/packages/remix-fs-routes/package.json index fc174d16a55..f26bbfd7e00 100644 --- a/packages/remix-fs-routes/package.json +++ b/packages/remix-fs-routes/package.json @@ -13,13 +13,6 @@ "license": "MIT", "main": "dist/index.js", "typings": "dist/index.d.ts", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "default": "./dist/index.js" - }, - "./package.json": "./package.json" - }, "scripts": { "tsc": "tsc" }, diff --git a/packages/remix-route-config/package.json b/packages/remix-route-config/package.json index 701968b67e9..65c09e0bd5d 100644 --- a/packages/remix-route-config/package.json +++ b/packages/remix-route-config/package.json @@ -13,13 +13,6 @@ "license": "MIT", "main": "dist/index.js", "typings": "dist/index.d.ts", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "default": "./dist/index.js" - }, - "./package.json": "./package.json" - }, "scripts": { "tsc": "tsc" }, diff --git a/packages/remix-routes-option-adapter/package.json b/packages/remix-routes-option-adapter/package.json index 5bbad0ffb04..8353e06c4ab 100644 --- a/packages/remix-routes-option-adapter/package.json +++ b/packages/remix-routes-option-adapter/package.json @@ -13,13 +13,6 @@ "license": "MIT", "main": "dist/index.js", "typings": "dist/index.d.ts", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "default": "./dist/index.js" - }, - "./package.json": "./package.json" - }, "scripts": { "tsc": "tsc" }, From a99541062ff99657a6ae363c381b776fade5d8a5 Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Sat, 19 Oct 2024 09:48:56 +1100 Subject: [PATCH 18/22] Update docs/start/future-flags.md Co-authored-by: Brooks Lybrand --- docs/start/future-flags.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/start/future-flags.md b/docs/start/future-flags.md index b768792c9e4..5e18137c71d 100644 --- a/docs/start/future-flags.md +++ b/docs/start/future-flags.md @@ -478,7 +478,7 @@ Opt into automatic [dependency optimization][dependency-optimization] during dev ## routes.ts -Config-based routing is the new default in React Router v7. Support for `routes.ts` and its related APIs in Remix are designed as a migration path to help minimize the number changes required when moving your Remix project over to React Router v7. Since React Router v7 is not yet stable, these APIs are also considered unstable. +Config-based routing is the new default in React Router v7. Support for `routes.ts` and its related APIs in Remix are designed as a migration path to help minimize the number of changes required when moving your Remix project over to React Router v7. Since React Router v7 is not yet stable, these APIs are also considered unstable. While not a future flag, the presence of an `app/routes.ts` file when using the Remix Vite plugin will disable Remix's built-in file system routing and opt your project into React Router v7's config-based routing. To opt back in to file system routing, this can be explicitly configured within `routes.ts` as we'll cover below. From 143f1400cbaa32633fc105d87e8883fe054d9359 Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Sat, 19 Oct 2024 09:49:42 +1100 Subject: [PATCH 19/22] Fix changeset typo --- .changeset/popular-humans-attend.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/popular-humans-attend.md b/.changeset/popular-humans-attend.md index e564b3f3806..cc6472a8cbf 100644 --- a/.changeset/popular-humans-attend.md +++ b/.changeset/popular-humans-attend.md @@ -4,7 +4,7 @@ Add unstable support for `routes.ts` to assist with the migration to React Router v7. -Config-based routing is the new default in React Router v7. Support for `routes.ts` and its related APIs in Remix are designed as a migration path to help minimize the number changes required when moving your Remix project over to React Router v7. Since React Router v7 is not yet stable, these APIs are also considered unstable. +Config-based routing is the new default in React Router v7. Support for `routes.ts` and its related APIs in Remix are designed as a migration path to help minimize the number of changes required when moving your Remix project over to React Router v7. Since React Router v7 is not yet stable, these APIs are also considered unstable. The presence of an `app/routes.ts` file when using the Remix Vite plugin will disable Remix's built-in file system routing and opt your project into React Router v7's config-based routing. From 72511f7ae42fd2d3a7a96f3c8f2a2a1f90847ef0 Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Tue, 29 Oct 2024 14:40:12 +1100 Subject: [PATCH 20/22] Add `v3_routeConfig` future flag --- docs/start/future-flags.md | 26 +++++-- integration/helpers/vite.ts | 10 ++- integration/vite-fs-routes-test.ts | 16 +++- integration/vite-route-config-test.ts | 76 ++++++++++++++----- .../remix-dev/__tests__/readConfig-test.ts | 1 + packages/remix-dev/config.ts | 22 +++++- 6 files changed, 114 insertions(+), 37 deletions(-) diff --git a/docs/start/future-flags.md b/docs/start/future-flags.md index 5e18137c71d..4a6894239f8 100644 --- a/docs/start/future-flags.md +++ b/docs/start/future-flags.md @@ -472,20 +472,26 @@ You shouldn't need to make any changes to your application code for this feature You may find some usage for the new [``][discover-prop] API if you wish to disable eager route discovery on certain links. -## unstable_optimizeDeps - -Opt into automatic [dependency optimization][dependency-optimization] during development. This flag will remain in an "unstable" state until React Router v7 so you do not need to adopt this in your Remix v2 app prior to upgrading to React Router v7. +## v3_routeConfig -## routes.ts +Config-based routing is the new default in React Router v7, configured via the `routes.ts` file in the app directory. Support for `routes.ts` and its related APIs in Remix are designed as a migration path to help minimize the number of changes required when moving your Remix project over to React Router v7. While some new packages have been introduced within the `@remix-run` scope, these new packages only exist to keep the code in `routes.ts` as similar as possible to the equivalent code for React Router v7. -Config-based routing is the new default in React Router v7. Support for `routes.ts` and its related APIs in Remix are designed as a migration path to help minimize the number of changes required when moving your Remix project over to React Router v7. Since React Router v7 is not yet stable, these APIs are also considered unstable. - -While not a future flag, the presence of an `app/routes.ts` file when using the Remix Vite plugin will disable Remix's built-in file system routing and opt your project into React Router v7's config-based routing. To opt back in to file system routing, this can be explicitly configured within `routes.ts` as we'll cover below. +When the `v3_routeConfig` future flag is enabled, Remix's built-in file system routing will be disabled and your project will opted into React Router v7's config-based routing. To opt back in to file system routing, this can be explicitly configured within `routes.ts` as we'll cover below. **Update your code** To migrate Remix's file system routing and route config to the equivalent setup in React Router v7, you can follow these steps: +👉 **Enable the Flag** + +```ts filename=vite.config.ts +remix({ + future: { + v3_routeConfig: true, + }, +}); +``` + 👉 **Install `@remix-run/route-config`** This package matches the API of React Router v7's `@react-router/dev/routes`, making the React Router v7 migration as easy as possible. @@ -613,6 +619,10 @@ export const routes: RouteConfig = [ ]; ``` +## unstable_optimizeDeps + +Opt into automatic [dependency optimization][dependency-optimization] during development. This flag will remain in an "unstable" state until React Router v7 so you do not need to adopt this in your Remix v2 app prior to upgrading to React Router v7. + [development-strategy]: ../guides/api-development-strategy [fetcherpersist-rfc]: https://github.com/remix-run/remix/discussions/7698 [relativesplatpath-changelog]: https://github.com/remix-run/remix/blob/main/CHANGELOG.md#futurev3_relativesplatpath @@ -632,5 +642,5 @@ export const routes: RouteConfig = [ [vite-url-imports]: https://vitejs.dev/guide/assets.html#explicit-url-imports [mdx]: https://mdxjs.com [mdx-rollup-plugin]: https://mdxjs.com/packages/rollup -[dependency-optimization]: ../guides/dependency-optimization [remix-flat-routes]: https://github.com/kiliman/remix-flat-routes +[dependency-optimization]: ../guides/dependency-optimization diff --git a/integration/helpers/vite.ts b/integration/helpers/vite.ts index 65c805288ff..cf6cc609dbf 100644 --- a/integration/helpers/vite.ts +++ b/integration/helpers/vite.ts @@ -32,13 +32,19 @@ export const viteConfig = { `; return text; }, - basic: async (args: { port: number; fsAllow?: string[] }) => { + basic: async (args: { + port: number; + fsAllow?: string[]; + routeConfig?: boolean; + }) => { return dedent` import { vitePlugin as remix } from "@remix-run/dev"; export default { ${await viteConfig.server(args)} - plugins: [remix()] + plugins: [remix(${ + args.routeConfig ? "{ future: { v3_routeConfig: true } }" : "" + })] } `; }, diff --git a/integration/vite-fs-routes-test.ts b/integration/vite-fs-routes-test.ts index 1bd9d06cfb3..09c6755681c 100644 --- a/integration/vite-fs-routes-test.ts +++ b/integration/vite-fs-routes-test.ts @@ -23,7 +23,9 @@ test.describe("fs-routes", () => { import { vitePlugin as remix } from "@remix-run/dev"; export default defineConfig({ - plugins: [remix()], + plugins: [remix({ + future: { v3_routeConfig: true }, + })], }); `, "app/routes.ts": js` @@ -254,7 +256,9 @@ test.describe("emits warnings for route conflicts", async () => { import { vitePlugin as remix } from "@remix-run/dev"; export default defineConfig({ - plugins: [remix()], + plugins: [remix({ + future: { v3_routeConfig: true }, + })], }); `, "app/routes.ts": js` @@ -326,7 +330,9 @@ test.describe("", () => { import { vitePlugin as remix } from "@remix-run/dev"; export default defineConfig({ - plugins: [remix()], + plugins: [remix({ + future: { v3_routeConfig: true }, + })], }); `, "app/routes.ts": js` @@ -373,7 +379,9 @@ test.describe("pathless routes and route collisions", () => { import { vitePlugin as remix } from "@remix-run/dev"; export default defineConfig({ - plugins: [remix()], + plugins: [remix({ + future: { v3_routeConfig: true }, + })], }); `, "app/routes.ts": js` diff --git a/integration/vite-route-config-test.ts b/integration/vite-route-config-test.ts index d1ec6c3d5bd..28c96e9f34b 100644 --- a/integration/vite-route-config-test.ts +++ b/integration/vite-route-config-test.ts @@ -36,6 +36,27 @@ async function reloadPage({ } test.describe("route config", () => { + test("fails the build if route config is missing", async () => { + let cwd = await createProject({ + "vite.config.js": ` + import { vitePlugin as remix } from "@remix-run/dev"; + + export default { + plugins: [remix({ + future: { v3_routeConfig: true }, + })] + } + `, + }); + // Ensure file is missing in case it's ever added to test fixture + await fs.rm(path.join(cwd, "app/routes.ts"), { force: true }); + let buildResult = viteBuild({ cwd }); + expect(buildResult.status).toBe(1); + expect(buildResult.stderr.toString()).toContain( + 'Route config file not found at "app/routes.ts"' + ); + }); + test("fails the build if routes option is used", async () => { let cwd = await createProject({ "vite.config.js": ` @@ -43,6 +64,7 @@ test.describe("route config", () => { export default { plugins: [remix({ + future: { v3_routeConfig: true }, routes: () => {}, })] } @@ -66,6 +88,7 @@ test.describe("route config", () => { export default { ${await viteConfig.server({ port })} plugins: [remix({ + future: { v3_routeConfig: true }, routes: () => {}, })] } @@ -85,6 +108,15 @@ test.describe("route config", () => { test("fails the build if route config is invalid", async () => { let cwd = await createProject({ + "vite.config.js": ` + import { vitePlugin as remix } from "@remix-run/dev"; + + export default { + plugins: [remix({ + future: { v3_routeConfig: true }, + })] + } + `, "app/routes.ts": `export default INVALID(`, }); let buildResult = viteBuild({ cwd }); @@ -98,7 +130,10 @@ test.describe("route config", () => { viteDev, }) => { let files: Files = async ({ port }) => ({ - "vite.config.js": await viteConfig.basic({ port }), + "vite.config.js": await viteConfig.basic({ + routeConfig: true, + port, + }), "app/routes.ts": `export default INVALID(`, }); let devError: Error | undefined; @@ -119,7 +154,10 @@ test.describe("route config", () => { viteDev, }) => { let files: Files = async ({ port }) => ({ - "vite.config.js": await viteConfig.basic({ port }), + "vite.config.js": await viteConfig.basic({ + routeConfig: true, + port, + }), "app/routes.ts": js` import { type RouteConfig, index } from "@remix-run/route-config"; @@ -177,7 +215,10 @@ test.describe("route config", () => { viteDev, }) => { let files: Files = async ({ port }) => ({ - "vite.config.js": await viteConfig.basic({ port }), + "vite.config.js": await viteConfig.basic({ + routeConfig: true, + port, + }), "app/routes.ts": js` export { routes } from "./actual-routes"; `, @@ -238,7 +279,10 @@ test.describe("route config", () => { viteDev, }) => { let files: Files = async ({ port }) => ({ - "vite.config.js": await viteConfig.basic({ port }), + "vite.config.js": await viteConfig.basic({ + routeConfig: true, + port, + }), "app/routes.ts": js` import { type RouteConfig, index } from "@remix-run/route-config"; @@ -256,11 +300,6 @@ test.describe("route config", () => { return
Test route 2
} `, - "app/routes/_index.tsx": ` - export default function FsRoute() { - return
FS route
- } - `, }); let { cwd, port } = await viteDev(files); @@ -277,18 +316,12 @@ test.describe("route config", () => { path.join(cwd, INVALID_FILENAME) ); - await expect(async () => { - // Reload to pick up classic FS routes - page = await reloadPage({ browserName, page, context }); - await expect(page.locator("[data-test-route]")).toHaveText("FS route"); - }).toPass(); - - // Ensure dev server falls back to FS routes + HMR - await edit("app/routes/_index.tsx", (contents) => - contents.replace("FS route", "FS route updated") + // Ensure dev server is still running with old config + HMR + await edit("app/test-route-1.tsx", (contents) => + contents.replace("Test route 1", "Test route 1 updated") ); await expect(page.locator("[data-test-route]")).toHaveText( - "FS route updated" + "Test route 1 updated" ); // Add new route @@ -313,7 +346,10 @@ test.describe("route config", () => { test("supports absolute route file paths", async ({ page, viteDev }) => { let files: Files = async ({ port }) => ({ - "vite.config.js": await viteConfig.basic({ port }), + "vite.config.js": await viteConfig.basic({ + routeConfig: true, + port, + }), "app/routes.ts": js` import path from "node:path"; import { type RouteConfig, index } from "@remix-run/route-config"; diff --git a/packages/remix-dev/__tests__/readConfig-test.ts b/packages/remix-dev/__tests__/readConfig-test.ts index f528c16f15e..f24e37ecf5e 100644 --- a/packages/remix-dev/__tests__/readConfig-test.ts +++ b/packages/remix-dev/__tests__/readConfig-test.ts @@ -40,6 +40,7 @@ describe("readConfig", () => { "v3_fetcherPersist": false, "v3_lazyRouteDiscovery": false, "v3_relativeSplatPath": false, + "v3_routeConfig": false, "v3_singleFetch": false, "v3_throwAbortReason": false, }, diff --git a/packages/remix-dev/config.ts b/packages/remix-dev/config.ts index 6e8f8f9da5e..2c0d9a65d57 100644 --- a/packages/remix-dev/config.ts +++ b/packages/remix-dev/config.ts @@ -22,6 +22,7 @@ import { serverBuildVirtualModule } from "./compiler/server/virtualModules"; import { flatRoutes } from "./config/flat-routes"; import { detectPackageManager } from "./cli/detectPackageManager"; import { logger } from "./tux"; +import invariant from "./invariant"; export interface RemixMdxConfig { rehypePlugins?: any[]; @@ -49,6 +50,7 @@ interface FutureConfig { v3_throwAbortReason: boolean; v3_singleFetch: boolean; v3_lazyRouteDiscovery: boolean; + v3_routeConfig: boolean; unstable_optimizeDeps: boolean; } @@ -577,9 +579,12 @@ export async function resolveConfig( root: { path: "", id: "root", file: rootRouteFile }, }; - setRouteConfigAppDirectory(appDirectory); - let routeConfigFile = findEntry(appDirectory, "routes"); - if (routesViteNodeContext && vite && routeConfigFile) { + if (appConfig.future?.v3_routeConfig) { + invariant(routesViteNodeContext); + invariant(vite); + + let routeConfigFile = findEntry(appDirectory, "routes"); + class FriendlyError extends Error {} let logger = vite.createLogger(viteUserConfig?.logLevel, { @@ -593,6 +598,16 @@ export async function resolveConfig( ); } + if (!routeConfigFile) { + let routeConfigDisplayPath = vite.normalizePath( + path.relative(rootDirectory, path.join(appDirectory, "routes.ts")) + ); + throw new FriendlyError( + `Route config file not found at "${routeConfigDisplayPath}".` + ); + } + + setRouteConfigAppDirectory(appDirectory); let routeConfigExport: RouteConfig = ( await routesViteNodeContext.runner.executeFile( path.join(appDirectory, routeConfigFile) @@ -706,6 +721,7 @@ export async function resolveConfig( v3_throwAbortReason: appConfig.future?.v3_throwAbortReason === true, v3_singleFetch: appConfig.future?.v3_singleFetch === true, v3_lazyRouteDiscovery: appConfig.future?.v3_lazyRouteDiscovery === true, + v3_routeConfig: appConfig.future?.v3_routeConfig === true, unstable_optimizeDeps: appConfig.future?.unstable_optimizeDeps === true, }; From 0df345540b4c7fecf50673bfcd06a4d56e443bde Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Tue, 29 Oct 2024 15:02:39 +1100 Subject: [PATCH 21/22] Update package name in future flags guide --- docs/start/future-flags.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/start/future-flags.md b/docs/start/future-flags.md index 4a6894239f8..d0ed2ee3803 100644 --- a/docs/start/future-flags.md +++ b/docs/start/future-flags.md @@ -545,7 +545,7 @@ To get started, first install the adapter: npm install --dev @remix-run/routes-option-adapter ``` -This package matches the API of React Router v7's `@react-router/remix-route-config-adapter`, making the React Router v7 migration as easy as possible. +This package matches the API of React Router v7's `@react-router/remix-routes-option-adapter`, making the React Router v7 migration as easy as possible. Then, update your `routes.ts` file to use the adapter, passing the value of your `routes` option to the `remixRoutesOptionAdapter` function which will return an array of configured routes. From 5f983941cdd14c4760298bcdbde90fc11e77e5de Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Tue, 29 Oct 2024 15:34:06 +1100 Subject: [PATCH 22/22] Update changeset --- .changeset/popular-humans-attend.md | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/.changeset/popular-humans-attend.md b/.changeset/popular-humans-attend.md index cc6472a8cbf..fd0fad3f579 100644 --- a/.changeset/popular-humans-attend.md +++ b/.changeset/popular-humans-attend.md @@ -2,11 +2,21 @@ "@remix-run/dev": minor --- -Add unstable support for `routes.ts` to assist with the migration to React Router v7. +Add support for `routes.ts` behind `future.v3_routeConfig` flag to assist with the migration to React Router v7. -Config-based routing is the new default in React Router v7. Support for `routes.ts` and its related APIs in Remix are designed as a migration path to help minimize the number of changes required when moving your Remix project over to React Router v7. Since React Router v7 is not yet stable, these APIs are also considered unstable. +Config-based routing is the new default in React Router v7, configured via the `routes.ts` file in the app directory. Support for `routes.ts` and its related APIs in Remix are designed as a migration path to help minimize the number of changes required when moving your Remix project over to React Router v7. While some new packages have been introduced within the `@remix-run` scope, these new packages only exist to keep the code in `routes.ts` as similar as possible to the equivalent code for React Router v7. -The presence of an `app/routes.ts` file when using the Remix Vite plugin will disable Remix's built-in file system routing and opt your project into React Router v7's config-based routing. +When the `v3_routeConfig` future flag is enabled, Remix's built-in file system routing will be disabled and your project will opted into React Router v7's config-based routing. + +To enable the flag, in your `vite.config.ts` file: + +```ts +remix({ + future: { + v3_routeConfig: true, + }, +}) +``` A minimal `routes.ts` file to support Remix's built-in file system routing looks like this: