Repository: remix-run/remix
Pull request #4880 (2022-12-15 17:56:09)

feat(remix-dev): flat routes
~~note: [this test](https://github.com/remix-run/remix/blob/434836ff7eb5afa7436fdabcee7e0d063691f6e1/packages/remix-dev/__tests__/routesConvention-test.ts#L66-L69) is currently failing, but i believe that the "expected" result is actually wrong and should actually be ` ":$dollabills?/.lol?/what?/$?/*"`, so i think we have a bug in `defineConventionalRoutes` where this test originated from.~~

<!--

👋 Hey, thanks for your interest in contributing to Remix!

If this is a simple docs change, go ahead and delete all this.

**Please ask first before starting work on any significant new features.**

It's never a fun experience to have your pull request declined after investing a
lot of time and effort into a new feature. To avoid this from happening, we
request that contributors create a
[Feature Request discussion](https://github.com/remix-run/remix/discussions/new?category=ideas)
to first discuss any significant new features.

https://github.com/remix-run/remix/blob/main/CONTRIBUTING.md

Please fill in or delete each item below:

-->

Closes: #

- [ ] Docs
- [x] Tests

Testing Strategy:

tests and `yarn playground:new`

<!--
Please explain how you tested this. For example:

> This test covers this code: <link to test>

Or

> I opened up my windows machine and ran this script:
>
> ```
> npx create-remix@0.0.0-experimental-7e420ee3 --template remix my-test
> cd my-test
> npm run dev
> ```
-->


changeset-bot[bot]: ###  🦋  Changeset detected

Latest commit: 07a3ec2ca64a4b2453592a7e77a0fc0a87a3bfa4

**The changes in this PR will be included in the next version bump.**

<details><summary>This PR includes changesets to release 17 packages</summary>

  | Name                          | Type  |
| ----------------------------- | ----- |
| @remix-run/dev                | Patch |
| create-remix                  | Patch |
| remix                         | Patch |
| @remix-run/architect          | Patch |
| @remix-run/cloudflare         | Patch |
| @remix-run/cloudflare-pages   | Patch |
| @remix-run/cloudflare-workers | Patch |
| @remix-run/deno               | Patch |
| @remix-run/eslint-config      | Patch |
| @remix-run/express            | Patch |
| @remix-run/netlify            | Patch |
| @remix-run/node               | Patch |
| @remix-run/react              | Patch |
| @remix-run/serve              | Patch |
| @remix-run/server-runtime     | Patch |
| @remix-run/testing            | Patch |
| @remix-run/vercel             | Patch |

</details>

Not sure what this means? [Click here  to learn what changesets are](https://github.com/changesets/changesets/blob/master/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add another changeset to this PR](https://github.com/remix-run/remix/new/logan/flat-routes?filename=.changeset/wicked-kids-remain.md&value=---%0A%22remix%22%3A%20patch%0A%22%40remix-run%2Fdev%22%3A%20patch%0A---%0A%0Afeat(remix-dev)%3A%20flat%20routes%0A)


pcattori: > note: [this test](https://github.com/remix-run/remix/blob/434836ff7eb5afa7436fdabcee7e0d063691f6e1/packages/remix-dev/__tests__/routesConvention-test.ts#L66-L69) is currently failing, but i believe that the "expected" result is actually wrong and should actually be  ":$dollabills?/.lol?/what?/$?/*", so i think we have a bug in defineConventionalRoutes where this test originated from.

@mcansh I has the same confusion, but I think its [actually correct](https://github.com/remix-run/remix/pull/4706#discussion_r1041228519)

Patch:
diff --git a/.changeset/eight-forks-collect.md b/.changeset/eight-forks-collect.md
new file mode 100644
index 000000000..7ab4e8abc
--- /dev/null
+++ b/.changeset/eight-forks-collect.md
@@ -0,0 +1,5 @@
+---
+"@remix-run/dev": patch
+---
+
+Add flat routes convention
diff --git a/.prettierignore b/.prettierignore
index 8b09e0301..deeb6036e 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -1,1 +1,1 @@
-.changeset/*.md
\ No newline at end of file
+.changeset/*.md
diff --git a/packages/remix-dev/config.ts b/packages/remix-dev/config.ts
index 63483c842..53839fd12 100644
--- a/packages/remix-dev/config.ts
+++ b/packages/remix-dev/config.ts
@@ -9,6 +9,7 @@ import { defineConventionalRoutes } from "./config/routesConvention";
 import { ServerMode, isValidServerMode } from "./config/serverModes";
 import { serverBuildVirtualModule } from "./compiler/virtualModules";
 import { writeConfigDefaults } from "./compiler/utils/tsconfig/write-config-defaults";
+import { flatRoutes } from "./config/flat-routes";
 
 export interface RemixMdxConfig {
   rehypePlugins?: any[];
@@ -35,6 +36,7 @@ interface FutureConfig {
   unstable_cssModules: boolean;
   unstable_cssSideEffectImports: boolean;
   v2_meta: boolean;
+  v2_routeConvention: boolean;
 }
 
 /**
@@ -428,8 +430,13 @@ export async function readConfig(
   let routes: RouteManifest = {
     root: { path: "", id: "root", file: rootRouteFile },
   };
+
+  let routesConvention = appConfig.future?.v2_routeConvention
+    ? flatRoutes
+    : defineConventionalRoutes;
+
   if (fse.existsSync(path.resolve(appDirectory, "routes"))) {
-    let conventionalRoutes = defineConventionalRoutes(
+    let conventionalRoutes = routesConvention(
       appDirectory,
       appConfig.ignoredRouteFiles
     );
@@ -487,6 +494,7 @@ export async function readConfig(
     unstable_cssSideEffectImports:
       appConfig.future?.unstable_cssSideEffectImports === true,
     v2_meta: appConfig.future?.v2_meta === true,
+    v2_routeConvention: appConfig.future?.v2_routeConvention === true,
   };
 
   return {
diff --git a/packages/remix-dev/config/flat-routes.ts b/packages/remix-dev/config/flat-routes.ts
new file mode 100644
index 000000000..089f62ac9
--- /dev/null
+++ b/packages/remix-dev/config/flat-routes.ts
@@ -0,0 +1,317 @@
+import path from "node:path";
+import fg from "fast-glob";
+
+import type { ConfigRoute, DefineRouteFunction, RouteManifest } from "./routes";
+import { createRouteId, defineRoutes } from "./routes";
+import {
+  escapeEnd,
+  escapeStart,
+  isSegmentSeparator,
+  optionalEnd,
+  optionalStart,
+  paramPrefixChar,
+  routeModuleExts,
+} from "./routesConvention";
+
+export function flatRoutes(
+  appDirectory: string,
+  ignoredFilePatterns?: string[]
+): RouteManifest {
+  let extensions = routeModuleExts.join(",");
+
+  let routePaths = fg.sync(`**/*{${extensions}}`, {
+    absolute: true,
+    cwd: path.join(appDirectory, "routes"),
+    ignore: ignoredFilePatterns,
+  });
+
+  return flatRoutesUniversal(appDirectory, routePaths);
+}
+
+interface RouteInfo extends ConfigRoute {
+  name: string;
+  segments: string[];
+}
+
+/**
+ * Create route configs from a list of routes using the flat routes conventions.
+ * @param {string} appDirectory - The absolute root directory the routes were looked up from.
+ * @param {string[]} routePaths - The absolute route paths.
+ * @param {string} [prefix=routes] - The prefix to strip off of the routes.
+ */
+export function flatRoutesUniversal(
+  appDirectory: string,
+  routePaths: string[],
+  prefix: string = "routes"
+): RouteManifest {
+  let routeMap = getRouteMap(appDirectory, routePaths, prefix);
+
+  let uniqueRoutes = new Map<string, string>();
+
+  function defineNestedRoutes(
+    defineRoute: DefineRouteFunction,
+    parentId?: string
+  ): void {
+    let childRoutes = Array.from(routeMap.values()).filter(
+      (routeInfo) => routeInfo.parentId === parentId
+    );
+    let parentRoute = parentId ? routeMap.get(parentId) : undefined;
+    let parentRoutePath = parentRoute?.path ?? "/";
+    for (let childRoute of childRoutes) {
+      let routePath = childRoute.path?.slice(parentRoutePath.length) ?? "";
+      // remove leading slash
+      routePath = routePath.replace(/^\//, "");
+      let index = childRoute.index;
+      let fullPath = childRoute.path;
+
+      let uniqueRouteId = (fullPath || "") + (index ? "?index" : "");
+      if (uniqueRouteId) {
+        let conflict = uniqueRoutes.get(uniqueRouteId);
+        if (conflict) {
+          throw new Error(
+            `Path ${JSON.stringify(fullPath)} defined by route ${JSON.stringify(
+              childRoute.id
+            )} conflicts with route ${JSON.stringify(conflict)}`
+          );
+        }
+        uniqueRoutes.set(uniqueRouteId, childRoute.id);
+      }
+      if (index) {
+        let invalidChildRoutes = Object.values(routeMap).filter(
+          (routeInfo) => routeInfo.parentId === childRoute.id
+        );
+
+        if (invalidChildRoutes.length > 0) {
+          throw new Error(
+            `Child routes are not allowed in index routes. Please remove child routes of ${childRoute.id}`
+          );
+        }
+
+        defineRoute(routePath, routeMap.get(childRoute.id!)!.file, {
+          index: true,
+        });
+      } else {
+        defineRoute(routePath, routeMap.get(childRoute.id!)!.file, () => {
+          defineNestedRoutes(defineRoute, childRoute.id);
+        });
+      }
+    }
+  }
+
+  let routes = defineRoutes(defineNestedRoutes);
+
+  return routes;
+}
+
+function isIndexRoute(routeId: string) {
+  return routeId.endsWith("_index");
+}
+
+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 optional 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) {
+  let routeSegments: string[] = [];
+  let index = 0;
+  let routeSegment = "";
+  let rawRouteSegment = "";
+  let state: State = "NORMAL";
+  let pushRouteSegment = (routeSegment: string) => {
+    if (!routeSegment) return;
+
+    if (rawRouteSegment === "*") {
+      throw new Error(
+        `Route segment "${rawRouteSegment}" for "${routeId}" cannot contain "*"`
+      );
+    }
+
+    if (rawRouteSegment.includes(":")) {
+      throw new Error(
+        `Route segment "${rawRouteSegment}" for "${routeId}" cannot contain ":"`
+      );
+    }
+
+    if (rawRouteSegment.includes("/")) {
+      throw new Error(
+        `Route segment "${routeSegment}" for "${routeId}" cannot contain "/"`
+      );
+    }
+    routeSegments.push(routeSegment);
+  };
+
+  while (index < routeId.length) {
+    let char = routeId[index];
+    index++; //advance to next char
+
+    switch (state) {
+      case "NORMAL": {
+        if (isSegmentSeparator(char)) {
+          pushRouteSegment(routeSegment);
+          routeSegment = "";
+          rawRouteSegment = "";
+          state = "NORMAL";
+          break;
+        }
+        if (char === escapeStart) {
+          state = "ESCAPE";
+          break;
+        }
+        if (char === optionalStart) {
+          state = "OPTIONAL";
+          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";
+          break;
+        }
+
+        routeSegment += char;
+        rawRouteSegment += char;
+        break;
+      }
+      case "OPTIONAL": {
+        if (char === optionalEnd) {
+          routeSegment += "?";
+          rawRouteSegment += "?";
+          state = "NORMAL";
+          break;
+        }
+
+        if (char === escapeStart) {
+          state = "OPTIONAL_ESCAPE";
+          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";
+          break;
+        }
+
+        routeSegment += char;
+        rawRouteSegment += char;
+        break;
+      }
+    }
+  }
+
+  // process remaining segment
+  pushRouteSegment(routeSegment);
+  return routeSegments;
+}
+
+function findParentRouteId(
+  routeInfo: RouteInfo,
+  nameMap: Map<string, RouteInfo>
+) {
+  let parentName = routeInfo.segments.slice(0, -1).join("/");
+  while (parentName) {
+    let parentRoute = nameMap.get(parentName);
+    if (parentRoute) return parentRoute.id;
+    parentName = parentName.substring(0, parentName.lastIndexOf("/"));
+  }
+  return undefined;
+}
+
+function getRouteInfo(
+  appDirectory: string,
+  routeDirectory: string,
+  filePath: string
+): RouteInfo {
+  let filePathWithoutApp = filePath.slice(appDirectory.length + 1);
+  let routeId = createRouteId(filePathWithoutApp);
+  let routeIdWithoutRoutes = routeId.slice(routeDirectory.length + 1);
+  let index = isIndexRoute(routeIdWithoutRoutes);
+  let routeSegments = getRouteSegments(routeIdWithoutRoutes);
+  let routePath = createRoutePath(routeSegments);
+
+  return {
+    id: routeIdWithoutRoutes,
+    path: routePath,
+    file: filePathWithoutApp,
+    name: routeSegments.join("/"),
+    segments: routeSegments,
+    index,
+  };
+}
+
+export function createRoutePath(routeSegments: string[]) {
+  let result = "";
+
+  for (let segment of routeSegments) {
+    // skip pathless layout segments
+    if (segment.startsWith("_")) {
+      continue;
+    }
+
+    // remove trailing slash
+    if (segment.endsWith("_")) {
+      segment = segment.slice(0, -1);
+    }
+
+    result += `/${segment}`;
+  }
+
+  return result || undefined;
+}
+
+function getRouteMap(
+  appDirectory: string,
+  routePaths: string[],
+  prefix: string = "routes"
+) {
+  let routeMap = new Map<string, RouteInfo>();
+  let nameMap = new Map<string, RouteInfo>();
+
+  for (let routePath of routePaths) {
+    let routeInfo = getRouteInfo(appDirectory, prefix, routePath);
+    routeMap.set(routeInfo.id, routeInfo);
+    nameMap.set(routeInfo.name, routeInfo);
+  }
+
+  // update parentIds for all routes
+  for (let routeInfo of routeMap.values()) {
+    let parentId = findParentRouteId(routeInfo, nameMap);
+    routeInfo.parentId = parentId;
+  }
+
+  return routeMap;
+}
diff --git a/packages/remix-dev/config/routes.ts b/packages/remix-dev/config/routes.ts
index 7f1e5696e..d6e70e44f 100644
--- a/packages/remix-dev/config/routes.ts
+++ b/packages/remix-dev/config/routes.ts
@@ -151,7 +151,7 @@ export function defineRoutes(
       parentId:
         parentRoutes.length > 0
           ? parentRoutes[parentRoutes.length - 1].id
-          : undefined,
+          : "root",
       file,
     };
 
diff --git a/packages/remix-dev/config/routesConvention.ts b/packages/remix-dev/config/routesConvention.ts
index 60ea589ef..b9bbce99d 100644
--- a/packages/remix-dev/config/routesConvention.ts
+++ b/packages/remix-dev/config/routesConvention.ts
@@ -5,7 +5,7 @@ import minimatch from "minimatch";
 import type { RouteManifest, DefineRouteFunction } from "./routes";
 import { defineRoutes, createRouteId } from "./routes";
 
-const routeModuleExts = [".js", ".jsx", ".ts", ".tsx", ".md", ".mdx"];
+export const routeModuleExts = [".js", ".jsx", ".ts", ".tsx", ".md", ".mdx"];
 
 export function isRouteModuleFile(filename: string): boolean {
   return routeModuleExts.includes(path.extname(filename));
@@ -110,11 +110,12 @@ export function defineConventionalRoutes(
   return defineRoutes(defineNestedRoutes);
 }
 
-let escapeStart = "[";
-let escapeEnd = "]";
+export let paramPrefixChar = "$" as const;
+export let escapeStart = "[" as const;
+export let escapeEnd = "]" as const;
 
-let optionalStart = "(";
-let optionalEnd = ")";
+export let optionalStart = "(" as const;
+export let optionalEnd = ")" as const;
 
 // TODO: Cleanup and write some tests for this function
 export function createRoutePath(partialRouteId: string): string | undefined {
@@ -127,13 +128,13 @@ export function createRoutePath(partialRouteId: string): string | undefined {
   let skipSegment = false;
   for (let i = 0; i < partialRouteId.length; i++) {
     let char = partialRouteId.charAt(i);
-    let lastChar = i > 0 ? partialRouteId.charAt(i - 1) : undefined;
+    let prevChar = i > 0 ? partialRouteId.charAt(i - 1) : undefined;
     let nextChar =
       i < partialRouteId.length - 1 ? partialRouteId.charAt(i + 1) : undefined;
 
     function isNewEscapeSequence() {
       return (
-        !inEscapeSequence && char === escapeStart && lastChar !== escapeStart
+        !inEscapeSequence && char === escapeStart && prevChar !== escapeStart
       );
     }
 
@@ -145,17 +146,11 @@ export function createRoutePath(partialRouteId: string): string | undefined {
       return char === "_" && nextChar === "_" && !rawSegmentBuffer;
     }
 
-    function isSegmentSeparator(checkChar = char) {
-      return (
-        checkChar === "/" || checkChar === "." || checkChar === path.win32.sep
-      );
-    }
-
     function isNewOptionalSegment() {
       return (
         char === optionalStart &&
-        lastChar !== optionalStart &&
-        (isSegmentSeparator(lastChar) || lastChar === undefined) &&
+        prevChar !== optionalStart &&
+        (isSegmentSeparator(prevChar) || prevChar === undefined) &&
         !inOptionalSegment &&
         !inEscapeSequence
       );
@@ -172,7 +167,7 @@ export function createRoutePath(partialRouteId: string): string | undefined {
     }
 
     if (skipSegment) {
-      if (isSegmentSeparator()) {
+      if (isSegmentSeparator(char)) {
         skipSegment = false;
       }
       continue;
@@ -191,7 +186,7 @@ export function createRoutePath(partialRouteId: string): string | undefined {
     if (isNewOptionalSegment()) {
       inOptionalSegment++;
       optionalSegmentIndex = result.length;
-      result += "(";
+      result += optionalStart;
       continue;
     }
 
@@ -212,7 +207,7 @@ export function createRoutePath(partialRouteId: string): string | undefined {
       continue;
     }
 
-    if (isSegmentSeparator()) {
+    if (isSegmentSeparator(char)) {
       if (rawSegmentBuffer === "index" && result.endsWith("index")) {
         result = result.replace(/\/?index$/, "");
       } else {
@@ -232,8 +227,8 @@ export function createRoutePath(partialRouteId: string): string | undefined {
 
     rawSegmentBuffer += char;
 
-    if (char === "$") {
-      if (nextChar === ")") {
+    if (char === paramPrefixChar) {
+      if (nextChar === optionalEnd) {
         throw new Error(
           `Invalid route path: ${partialRouteId}. Splat route $ is already optional`
         );
@@ -251,13 +246,18 @@ export function createRoutePath(partialRouteId: string): string | undefined {
 
   if (rawSegmentBuffer === "index" && result.endsWith("index?")) {
     throw new Error(
-      `Invalid route path: ${partialRouteId}. Make index route optional by using [index]`
+      `Invalid route path: ${partialRouteId}. Make index route optional by using (index)`
     );
   }
 
   return result || undefined;
 }
 
+export function isSegmentSeparator(checkChar: string | undefined) {
+  if (!checkChar) return false;
+  return ["/", ".", path.win32.sep].includes(checkChar);
+}
+
 function getParentRouteIds(
   routeIds: string[]
 ): Record<string, string | undefined> {


Test patch:
diff --git a/packages/remix-dev/__tests__/defineRoutes-test.ts b/packages/remix-dev/__tests__/defineRoutes-test.ts
index dbd73350d..903641b85 100644
--- a/packages/remix-dev/__tests__/defineRoutes-test.ts
+++ b/packages/remix-dev/__tests__/defineRoutes-test.ts
@@ -18,7 +18,7 @@ describe("defineRoutes", () => {
           "file": "routes/home.js",
           "id": "routes/home",
           "index": undefined,
-          "parentId": undefined,
+          "parentId": "root",
           "path": "/",
         },
         "routes/inbox": Object {
@@ -26,7 +26,7 @@ describe("defineRoutes", () => {
           "file": "routes/inbox.js",
           "id": "routes/inbox",
           "index": undefined,
-          "parentId": undefined,
+          "parentId": "root",
           "path": "inbox",
         },
         "routes/inbox/$messageId": Object {
@@ -73,7 +73,7 @@ describe("defineRoutes", () => {
           "file": "one.md",
           "id": "one",
           "index": undefined,
-          "parentId": undefined,
+          "parentId": "root",
           "path": "one",
         },
         "two": Object {
@@ -81,7 +81,7 @@ describe("defineRoutes", () => {
           "file": "two.md",
           "id": "two",
           "index": undefined,
-          "parentId": undefined,
+          "parentId": "root",
           "path": "two",
         },
       }
@@ -102,7 +102,7 @@ describe("defineRoutes", () => {
           "file": "routes/other-route.tsx",
           "id": "routes/other-route",
           "index": undefined,
-          "parentId": undefined,
+          "parentId": "root",
           "path": "/other",
         },
         "user": Object {
@@ -110,7 +110,7 @@ describe("defineRoutes", () => {
           "file": "routes/index.tsx",
           "id": "user",
           "index": undefined,
-          "parentId": undefined,
+          "parentId": "root",
           "path": "/user",
         },
         "user-by-id": Object {
@@ -118,7 +118,7 @@ describe("defineRoutes", () => {
           "file": "routes/index.tsx",
           "id": "user-by-id",
           "index": undefined,
-          "parentId": undefined,
+          "parentId": "root",
           "path": "/user/:id",
         },
       }
diff --git a/packages/remix-dev/__tests__/flat-routes-test.ts b/packages/remix-dev/__tests__/flat-routes-test.ts
new file mode 100644
index 000000000..5b9bb523a
--- /dev/null
+++ b/packages/remix-dev/__tests__/flat-routes-test.ts
@@ -0,0 +1,616 @@
+import path from "node:path";
+
+import {
+  createRoutePath,
+  flatRoutesUniversal,
+  getRouteSegments,
+} from "../config/flat-routes";
+import type { ConfigRoute } from "../config/routes";
+
+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.sub/$", "/routes/sub/*"],
+      ["routes/$slug", "/routes/:slug"],
+      ["routes/sub/$slug", "/routes/sub/: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"],
+      ["nested/_index", "/nested"],
+      ["flat._index", "/flat"],
+      ["_index", undefined],
+      ["_layout/_index", undefined],
+      ["_layout/test", "/test"],
+      ["_layout.test", "/test"],
+      ["_layout/$slug", "/:slug"],
+      ["nested/_layout/$slug", "/nested/:slug"],
+      ["$slug[.]json", "/:slug.json"],
+      ["sub/[sitemap.xml]", "/sub/sitemap.xml"],
+      ["posts/$slug/[image.jpg]", "/posts/:slug/image.jpg"],
+      ["sub.[[]", "/sub/["],
+      ["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).(sub)/$", "/routes?/sub?/*"],
+      ["(routes)/($slug)", "/routes?/:slug?"],
+      ["(routes)/sub/($slug)", "/routes?/sub/: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.(test)", "/test?"],
+      ["_layout/($slug)", "/:slug?"],
+      ["(nested)/_layout/($slug)", "/nested?/:slug?"],
+      ["($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?/[]?"],
+      ["(sub).([[])", "/sub?/[?"],
+      ["(beef])", "/beef]?"],
+      ["([index])", "/index?"],
+      ["(test)/(inde[x])", "/test?/index?"],
+      ["([i]ndex)/([[]).([[]])", "/index?/[?/[]?"],
+
+      // Opting out of parent layout
+      ["app_.projects/$id.roadmap", "/app/projects/:id/roadmap"],
+      ["app.projects_/$id.roadmap", "/app/projects/:id/roadmap"],
+      ["app_.projects_/$id.roadmap", "/app/projects/:id/roadmap"],
+    ];
+
+    for (let [input, expected] of tests) {
+      it(`"${input}" -> "${expected}"`, () => {
+        let routeSegments = getRouteSegments(input);
+        expect(createRoutePath(routeSegments)).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",
+    ];
+
+    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<ConfigRoute, "file">][] = [
+      [
+        "routes/_auth.tsx",
+        {
+          id: "routes/_auth",
+          parentId: "root",
+        },
+      ],
+      [
+        "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.tsx",
+        {
+          id: "routes/_landing",
+          parentId: "root",
+        },
+      ],
+      [
+        "routes/_landing._index.tsx",
+        {
+          id: "routes/_landing._index",
+          index: true,
+          parentId: "routes/_landing",
+        },
+      ],
+      [
+        "routes/_landing.index.tsx",
+        {
+          id: "routes/_landing.index",
+          parentId: "routes/_landing",
+          path: "index",
+        },
+      ],
+      [
+        "routes/about.tsx",
+        {
+          id: "routes/about",
+          parentId: "root",
+          path: "about",
+        },
+      ],
+      [
+        "routes/about._index.tsx",
+        {
+          id: "routes/about._index",
+          index: true,
+          parentId: "routes/about",
+        },
+      ],
+      [
+        "routes/about.$.tsx",
+        {
+          id: "routes/about.$",
+          parentId: "routes/about",
+          path: "*",
+        },
+      ],
+      [
+        "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",
+        },
+      ],
+
+      // 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.tsx",
+        {
+          id: "routes/app.skipall",
+          parentId: "routes/app",
+          path: "skipall",
+        },
+      ],
+      [
+        "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: ".",
+        },
+      ],
+      [
+        "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: "routes/([index])",
+          path: "[?/[]?",
+        },
+      ],
+      [
+        "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/$.tsx",
+        {
+          id: "routes/$",
+          parentId: "root",
+          path: "*",
+        },
+      ],
+    ];
+
+    let files: [string, Omit<ConfigRoute, "file">][] = testFiles.map(
+      ([file, route]) => {
+        let filepath = file.split("/").join(path.sep);
+        return [filepath, { ...route, file: filepath }];
+      }
+    );
+
+    let routeManifest = flatRoutesUniversal(
+      APP_DIR,
+      files.map(([file]) => path.join(APP_DIR, file))
+    );
+    let routes = Object.values(routeManifest);
+
+    expect(routes).toHaveLength(files.length);
+
+    for (let [file, route] of files) {
+      test(`hierarchy for ${file} - ${route.path}`, () => {
+        expect(routes).toContainEqual(route);
+      });
+    }
+  });
+});
diff --git a/packages/remix-dev/__tests__/readConfig-test.ts b/packages/remix-dev/__tests__/readConfig-test.ts
index 1b79226fb..761ec836e 100644
--- a/packages/remix-dev/__tests__/readConfig-test.ts
+++ b/packages/remix-dev/__tests__/readConfig-test.ts
@@ -26,6 +26,7 @@ describe("readConfig", () => {
           unstable_cssModules: expect.any(Boolean),
           unstable_cssSideEffectImports: expect.any(Boolean),
           v2_meta: expect.any(Boolean),
+          v2_routeConvention: expect.any(Boolean),
         },
       },
       `
@@ -41,6 +42,7 @@ describe("readConfig", () => {
           "unstable_cssModules": Any<Boolean>,
           "unstable_cssSideEffectImports": Any<Boolean>,
           "v2_meta": Any<Boolean>,
+          "v2_routeConvention": Any<Boolean>,
         },
         "mdx": undefined,
         "publicPath": "/build/",
diff --git a/packages/remix-dev/__tests__/routesConvention-test.ts b/packages/remix-dev/__tests__/routesConvention-test.ts
index a3513fc10..15e77963d 100644
--- a/packages/remix-dev/__tests__/routesConvention-test.ts
+++ b/packages/remix-dev/__tests__/routesConvention-test.ts
@@ -113,8 +113,8 @@ describe("defineConventionalRoutes", () => {
       path.join(__dirname, "fixtures/replace-remix-magic-imports/app")
     );
     let keys = Object.keys(routes);
-    expect(keys.length).toBe(14);
-    expect(keys.filter((key) => routes[key].parentId).length).toBe(5);
+    expect(keys).toHaveLength(14);
+    expect(keys.filter((key) => routes[key].parentId).length).toBe(14);
     expect(keys.filter((key) => routes[key].index).length).toBe(4);
   });
 });