Repository: remix-run/remix
Pull request #4421 (2022-10-25 22:07:14)

fix(remix-dev): adjust conventional routes logic for pathless layout routes
### tl;dr; 

This fixes 2 issues ion our conventional routes logic:

* Removes the trailing slash from all generated `path` values since # of slash-delimited segments counts towards route ranking so the trailing slash incorrectly increases the scoring for routes.
* Removes pathless layout routes from the unique route path checks since they inherently trigger duplicate paths.  This allows us to support sibling pathless layouts.

Closes #3014 #3327

### Deets

#4355 had an integration test failure once merged to dev due to conflicts with an unrelated PR that merged an integration tests with an index file inside a pathless layout route folder.  This uncovered a bit of a rabbit hole into some details around our path collision detection in `conventionalRoutes.ts`.

Before #4355, index routes inside pathless layout routes were fine because they had a trailing slash hanging around on the "path", so our `uniqueRouteIds` structure looked like:

```
{
  'parent': 'routes/parent.tsx',
  'parent/': 'routes/parent/__pathless.tsx',
  'parent?index': 'routes/parent/__pathless/index.tsx'
}
```

Once we got rid of the trailing slash in #4355 (to fix #3014) we now start failing the build on the above since the "path" (the key in the Map) is identical for both matches.

This turns out to also be the reason that sibling pathless layout routes fail - even though the following is valid in React Router:

```jsx
<Routes>
  <Route file="root.tsx">
    <Route path="parent" file="routes/parent.jsx">
      <Route file="routes/parent/__a.jsx">
        <Route path="a-child" file="routes/parent/__a/a-child.jsx" />
      </Route>
      <Route file="routes/parent/__b.jsx">
        <Route path="b-child" file="routes/parent/__b/b-child.jsx" />
      </Route>
    </Route>
  </Route>
</Routes>
```

With the current logic, we end up reporting a path collision on `/parent` between `routes/parent/__a.jsx` and `routes/parent/__b.jsx` even though they have mutually exclusive children routes.

What I ended up doing in here is just ignoring path collision detection _only for pathless-layout route files_, since by definition they sort of introduce potential path collisions that the user should be aware of and accounting for (we even [warn them in the docs!](https://remix.run/docs/en/v1/api/conventions#pathless-layout-routes)).  We still check for collisions on any children route files inside a `__pathless/` directory - which lets us detect if they include non-mutually-exclusive route paths _beneath_ a pathless layout route folder.

So at the end of the day we get 2 things from this PR:
* #3014 is fixed and we correctly route to index routes next to pathless layout routes ✅
* We also get some _new_ functionality and can support sibling pathless layout routes 😄 

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

Latest commit: 5bab97597fa7e4666f958b650f1fbdb1541e4a4d

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

<details><summary>This PR includes changesets to release 16 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/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/brophdawg11/pathless-route-collision-3?filename=.changeset/good-mice-grab.md&value=---%0A%22remix%22%3A%20patch%0A%22%40remix-run%2Fdev%22%3A%20patch%0A---%0A%0Aadjust%20conventional%20routes%20logic%20for%20pathless%20layout%20routes%0A)


brophdawg11: yeah I can look into the same tests/logic for flat routes 👍 
sixers: Hey, any update on this? I have to patch-package and apply this hotfix each time I upgrade Remix. It would be awesome to finally merge this fix.
sixers: @brophdawg11 Yes, it's needed, the issue also exists in V2 route convention.
chaance: @brophdawg11 Just so it's on your radar, you probably want to keep the v1 compat package in sync with these kind of fixes. https://github.com/remix-run/v1-compat-utils
brophdawg11: Added PR for `@remix-run/v1-route-convention` in https://github.com/remix-run/v1-compat-utils/pull/20

Patch:
diff --git a/.changeset/error-response-resource-route.md b/.changeset/error-response-resource-route.md
new file mode 100644
index 000000000..7dd7264c4
--- /dev/null
+++ b/.changeset/error-response-resource-route.md
@@ -0,0 +1,5 @@
+---
+"@remix-run/server-runtime": patch
+---
+
+Properly handle thrown `ErrorResponse` instances inside resource routes
diff --git a/.changeset/expose-error-headers.md b/.changeset/expose-error-headers.md
index 2637ed429..0d49a2519 100644
--- a/.changeset/expose-error-headers.md
+++ b/.changeset/expose-error-headers.md
@@ -2,4 +2,4 @@
 "@remix-run/server-runtime": minor
 ---
 
-Add `errorHeaders` parameter to the leaf `headers()` function to expose headers from thrown responses that bubble up to ancestor route boundaries.  If the throwing route contains the boundary, then `errorHeaders` will be the same object as `loaderHeaders`/`actionHeaders` for that route.
+Add `errorHeaders` parameter to the leaf `headers()` function to expose headers from thrown responses that bubble up to ancestor route boundaries. If the throwing route contains the boundary, then `errorHeaders` will be the same object as `loaderHeaders`/`actionHeaders` for that route.
diff --git a/.changeset/gorgeous-fireants-fry.md b/.changeset/gorgeous-fireants-fry.md
new file mode 100644
index 000000000..36fe7fb63
--- /dev/null
+++ b/.changeset/gorgeous-fireants-fry.md
@@ -0,0 +1,7 @@
+---
+"@remix-run/dev": patch
+---
+
+Fix Tailwind performance issue when `postcss.config.js` contains `plugins: { tailwindcss: {} }` and `remix.config.js` contains both `tailwind: true` and `postcss: true`.
+
+Note that this was _not_ an issue when the plugin function had been explicitly called, i.e. `plugins: [tailwindcss()]`. Remix avoids adding the Tailwind plugin to PostCSS if it's already present but we were failing to detect when the plugin function hadn't been called — either because the plugin function itself had been passed, i.e. `plugins: [require('tailwindcss')]`, or the plugin config object syntax had been used, i.e. `plugins: { tailwindcss: {} }`.
diff --git a/.changeset/great-geese-compare.md b/.changeset/great-geese-compare.md
new file mode 100644
index 000000000..bfc0cf917
--- /dev/null
+++ b/.changeset/great-geese-compare.md
@@ -0,0 +1,9 @@
+---
+"@remix-run/react": minor
+"@remix-run/dev": patch
+---
+
+Faster server export removal for routes when `unstable_dev` is enabled.
+
+Also, only render modulepreloads on SSR.
+Do not render modulepreloads when hydrated.
diff --git a/.changeset/headers-args-type.md b/.changeset/headers-args-type.md
index c9133fc45..16456a5d3 100644
--- a/.changeset/headers-args-type.md
+++ b/.changeset/headers-args-type.md
@@ -12,11 +12,11 @@
 Add `HeadersArgs` type to be consistent with loaders/actions/meta and allows for using a `function` declaration in addition to an arrow function expression
 
 ```tsx
-import type { HeadersArgs } from '@remix-run/node'; // or cloudflare/deno
+import type { HeadersArgs } from "@remix-run/node"; // or cloudflare/deno
 
 export function headers({ loaderHeaders }: HeadersArgs) {
   return {
-    "x-my-custom-thing": loaderHeaders.get("x-my-custom-thing") || "fallback"
-  }
+    "x-my-custom-thing": loaderHeaders.get("x-my-custom-thing") || "fallback",
+  };
 }
 ```
diff --git a/.changeset/lemon-beers-fetch.md b/.changeset/lemon-beers-fetch.md
index 0e9c3032f..83458a2fc 100644
--- a/.changeset/lemon-beers-fetch.md
+++ b/.changeset/lemon-beers-fetch.md
@@ -6,19 +6,20 @@
 Force Typescript to simplify type produced by `Serialize`.
 
 As a result, the following types and functions have simplified return types:
+
 - SerializeFrom
 - useLoaderData
 - useActionData
 - useFetcher
 
 ```ts
-type Data = { hello: string; when: Date }
+type Data = { hello: string; when: Date };
 
 // BEFORE
-type Unsimplified = SerializeFrom<Data>
+type Unsimplified = SerializeFrom<Data>;
 //   ^? SerializeObject<UndefinedToOptional<{ hello: string; when: Date }>>
 
 // AFTER
-type Simplified = SerializeFrom<Data>
+type Simplified = SerializeFrom<Data>;
 //   ^? { hello: string; when: string }
 ```
diff --git a/.changeset/stale-spoons-tie.md b/.changeset/stale-spoons-tie.md
new file mode 100644
index 000000000..ffe2da099
--- /dev/null
+++ b/.changeset/stale-spoons-tie.md
@@ -0,0 +1,8 @@
+---
+"@remix-run/dev": patch
+---
+
+- Fix route ranking bug with pathless layout route next to a sibling index route
+  - Under the hood this is done by removing the trailing slash from all generated `path` values since the number of slash-delimited segments counts towards route ranking so the trailing slash incorrectly increases the score for routes
+
+- Support sibling pathless layout routes by removing pathless layout routes from the unique route path checks in conventional route generation since they inherently trigger duplicate paths
\ No newline at end of file
diff --git a/.changeset/strong-dolphins-tell.md b/.changeset/strong-dolphins-tell.md
new file mode 100644
index 000000000..3e109e2b7
--- /dev/null
+++ b/.changeset/strong-dolphins-tell.md
@@ -0,0 +1,5 @@
+---
+"@remix-run/dev": patch
+---
+
+fix dev server crashes caused by ungraceful hdr error handling
diff --git a/.changeset/v2-headers.md b/.changeset/v2-headers.md
new file mode 100644
index 000000000..31d7f5f4a
--- /dev/null
+++ b/.changeset/v2-headers.md
@@ -0,0 +1,5 @@
+---
+"@remix-run/server-runtime": minor
+---
+
+Added a new `future.v2_headers` future flag to opt into automatic inheriting of ancestor route `headers` functions so you do not need to export a `headers` function from every possible leaf route if you don't wish to.
diff --git a/docs/pages/api-development-strategy.md b/docs/pages/api-development-strategy.md
index ff30a4830..152dd1c19 100644
--- a/docs/pages/api-development-strategy.md
+++ b/docs/pages/api-development-strategy.md
@@ -56,6 +56,7 @@ Here's the current future flags in Remix v1 today:
 | ------------------------ | --------------------------------------------------------------------- |
 | `unstable_dev`           | Enable the new development server (including HMR/HDR support)         |
 | `v2_errorBoundary`       | Combine `ErrorBoundary`/`CatchBoundary` into a single `ErrorBoundary` |
+| `v2_headers`             | Leverage ancestor `headers` if children do not export `headers`       |
 | `v2_meta`                | Enable the new API for your `meta` functions                          |
 | `v2_normalizeFormMethod` | Normalize `useNavigation().formMethod` to be an uppercase HTTP Method |
 | `v2_routeConvention`     | Enable the flat routes style of file-based routing                    |
diff --git a/docs/pages/v2.md b/docs/pages/v2.md
index 8307a2c43..4435df722 100644
--- a/docs/pages/v2.md
+++ b/docs/pages/v2.md
@@ -124,6 +124,14 @@ routes
 
 For more background on this change, see the [original "flat routes" proposal][flat-routes].
 
+## Route `headers`
+
+In Remix v2, the behavior for route `headers` functions is changing slightly. You can opt-into this new behavior ahead of time via the `future.v2_headers` flag in `remix.config.js`.
+
+In v1, Remix would only use the result of the leaf "rendered" route `headers` function. It was your responsibility to add a `headers` function to every potential leaf and merge in `parentHeaders` accordingly. This can get tedious quickly and is also easy to forget to add a `headers` function when you add a new route, even if you want it to just share the same headers from it's parent.
+
+In v2, Remix will use the deepest `headers` function that it finds in the rendered routes. This more easily allows you to share headers across routes from a common ancestor. Then as needed you can add `headers` functions to deeper routes if they require specific behavior.
+
 ## Route `meta`
 
 In Remix v2, the signature for route `meta` functions and how Remix handles meta tags under the hood have changed.
diff --git a/docs/route/headers.md b/docs/route/headers.md
index b214b6fb0..42a8972e4 100644
--- a/docs/route/headers.md
+++ b/docs/route/headers.md
@@ -13,6 +13,7 @@ export const headers: HeadersFunction = ({
   actionHeaders,
   loaderHeaders,
   parentHeaders,
+  errorHeaders,
 }) => ({
   "X-Stretchy-Pants": "its for fun",
   "Cache-Control": "max-age=300, s-maxage=3600",
@@ -33,7 +34,11 @@ export const headers: HeadersFunction = ({
 
 Note: `actionHeaders` & `loaderHeaders` are an instance of the [Web Fetch API][headers] `Headers` class.
 
-Because Remix has nested routes, there's a battle of the headers to be won when nested routes match. In this case, the deepest route wins. Consider these files in the routes directory:
+If an action or a loader threw a `Response` and we're rendering a boundary, any headers from the thrown `Response` will be available in `errorHeaders`. This allows you to access headers from a child loader that threw in a parent error boundary.
+
+## Nested Routes
+
+Because Remix has nested routes, there's a battle of the headers to be won when nested routes match. The default behavior is that Remix only leverages the resulting headers from the leaf rendered route. Consider these files in the routes directory:
 
 ```
 ├── users.tsx
@@ -53,7 +58,11 @@ If we are looking at `/users/123/profile` then three routes are rendering:
 </Users>
 ```
 
-If all three define `headers`, the deepest module wins, in this case `profile.tsx`.
+If all three define `headers`, the deepest module wins, in this case `profile.tsx`. However, if your `profile.tsx` loader threw and bubbled to a boundary in `userId.tsx` - then `userId.tsx`'s `headers` function would be used as it is the leaf rendered route.
+
+<docs-info>
+We realize that it can be tedious and error-prone to have to define `headers` on every possible leaf route so we're changing the current behavior in v2 behind the [`future.v2_headers`][v2_headers] flag.
+</docs-info>
 
 We don't want surprise headers in your responses, so it's your job to merge them if you'd like. Remix passes in the `parentHeaders` to your `headers` function. So `users.tsx` headers get passed to `$userId.tsx`, and then `$userId.tsx` headers are passed to `profile.tsx` headers.
 
@@ -118,5 +127,25 @@ export default function handleRequest(
 
 Just keep in mind that doing this will apply to _all_ document requests, but does not apply to `data` requests (for client-side transitions for example). For those, use [`handleDataRequest`][handledatarequest].
 
+## v2 Behavior
+
+Since it can be tedious and error-prone to define a `header` function in every single possible leaf route, we're changing the behavior slightly in v2 and you can opt-into the new behavior via the `future.v2_headers` [Future Flag][future-flags] in `remix.config.js`.
+
+When enabling this flag, Remix will now use the deepest `headers` function it finds in the renderable matches (up to and including the boundary route if an error is present). You'll still need to handle merging together headers as shown above for any `headers` functions above this route.
+
+This means that, re-using the example above:
+
+```
+├── users.tsx
+└── users
+    ├── $userId.tsx
+    └── $userId
+        └── profile.tsx
+```
+
+If a user is looking at `/users/123/profile` and `profile.tsx` does not export a `headers` function, then Remix will use the return value of `$userId.tsx`'s `headers` function. If that file doesn't export one, then it will use the result of the one in `users.tsx`, and so on.
+
 [headers]: https://developer.mozilla.org/en-US/docs/Web/API/Headers
 [handledatarequest]: ../file-conventions/entry.server
+[v2_headers]: #v2-behavior
+[future-flags]: ../pages/api-development-strategy
diff --git a/packages/remix-dev/compiler/js/compiler.ts b/packages/remix-dev/compiler/js/compiler.ts
index d55e9ff98..7cc89714f 100644
--- a/packages/remix-dev/compiler/js/compiler.ts
+++ b/packages/remix-dev/compiler/js/compiler.ts
@@ -8,7 +8,6 @@ import { type Manifest } from "../../manifest";
 import { getAppDependencies } from "../../dependencies";
 import { loaders } from "../utils/loaders";
 import { browserRouteModulesPlugin } from "./plugins/routes";
-import { browserRouteModulesPlugin as browserRouteModulesPlugin_v2 } from "./plugins/routes_unstable";
 import { cssFilePlugin } from "../plugins/cssImports";
 import { absoluteCssUrlsPlugin } from "../plugins/absoluteCssUrlsPlugin";
 import { deprecatedRemixPackagePlugin } from "../plugins/deprecatedRemixPackage";
@@ -78,29 +77,12 @@ const createEsbuildConfig = (
     "entry.client": ctx.config.entryClientFilePath,
   };
 
-  let routeModulePaths = new Map<string, string>();
   for (let id of Object.keys(ctx.config.routes)) {
     entryPoints[id] = ctx.config.routes[id].file;
-    if (ctx.config.future.unstable_dev) {
-      // In V2 we are doing AST transforms to remove server code, this means we
-      // have to re-map all route modules back to the same module in the graph
-      // otherwise we will have duplicate modules in the graph. We have to resolve
-      // the path as we get the relative for the entrypoint and absolute for imports
-      // from other modules.
-      routeModulePaths.set(
-        ctx.config.routes[id].file,
-        ctx.config.routes[id].file
-      );
-      routeModulePaths.set(
-        path.resolve(ctx.config.appDirectory, ctx.config.routes[id].file),
-        ctx.config.routes[id].file
-      );
-    } else {
-      // All route entry points are virtual modules that will be loaded by the
-      // browserEntryPointsPlugin. This allows us to tree-shake server-only code
-      // that we don't want to run in the browser (i.e. action & loader).
-      entryPoints[id] += "?browser";
-    }
+    // All route entry points are virtual modules that will be loaded by the
+    // browserEntryPointsPlugin. This allows us to tree-shake server-only code
+    // that we don't want to run in the browser (i.e. action & loader).
+    entryPoints[id] += "?browser";
   }
 
   if (
@@ -133,16 +115,18 @@ const createEsbuildConfig = (
   }
 
   let plugins: esbuild.Plugin[] = [
+    browserRouteModulesPlugin(ctx, /\?browser$/),
     deprecatedRemixPackagePlugin(ctx),
     cssModulesPlugin(ctx, { outputCss: false }),
     vanillaExtractPlugin(ctx, { outputCss: false }),
-    cssSideEffectImportsPlugin(ctx),
+    cssSideEffectImportsPlugin(ctx, {
+      hmr:
+        ctx.options.mode === "development" &&
+        ctx.config.future.unstable_dev !== false,
+    }),
     cssFilePlugin(ctx),
     absoluteCssUrlsPlugin(),
     externalPlugin(/^https?:\/\//, { sideEffects: false }),
-    ctx.config.future.unstable_dev
-      ? browserRouteModulesPlugin_v2(ctx, routeModulePaths)
-      : browserRouteModulesPlugin(ctx, /\?browser$/),
     mdxPlugin(ctx),
     emptyModulesPlugin(ctx, /\.server(\.[jt]sx?)?$/),
     NodeModulesPolyfillPlugin(),
diff --git a/packages/remix-dev/compiler/plugins/cssSideEffectImports.ts b/packages/remix-dev/compiler/plugins/cssSideEffectImports.ts
index 9478841b5..aa4862c18 100644
--- a/packages/remix-dev/compiler/plugins/cssSideEffectImports.ts
+++ b/packages/remix-dev/compiler/plugins/cssSideEffectImports.ts
@@ -6,9 +6,9 @@ import { parse, type ParserOptions } from "@babel/parser";
 import traverse from "@babel/traverse";
 import generate from "@babel/generator";
 
-import type { RemixConfig } from "../../config";
-import type { Options } from "../options";
 import { getPostcssProcessor } from "../utils/postcss";
+import { applyHMR } from "../js/plugins/hmr";
+import type { Context } from "../context";
 
 const pluginName = "css-side-effects-plugin";
 const namespace = `${pluginName}-ns`;
@@ -44,17 +44,14 @@ const loaderForExtension: Record<Extension, Loader> = {
  * to the CSS bundle. This is primarily designed to support packages that
  * import plain CSS files directly within JS files.
  */
-export const cssSideEffectImportsPlugin = ({
-  config,
-  options,
-}: {
-  config: RemixConfig;
-  options: Options;
-}): Plugin => {
+export const cssSideEffectImportsPlugin = (
+  ctx: Context,
+  { hmr = false } = {}
+): Plugin => {
   return {
     name: pluginName,
     setup: async (build) => {
-      let postcssProcessor = await getPostcssProcessor({ config });
+      let postcssProcessor = await getPostcssProcessor(ctx);
 
       build.onLoad(
         { filter: allJsFilesFilter, namespace: "file" },
@@ -69,6 +66,15 @@ export const cssSideEffectImportsPlugin = ({
           let loader = loaderForExtension[path.extname(args.path) as Extension];
           let contents = addSuffixToCssSideEffectImports(loader, code);
 
+          if (hmr) {
+            contents = await applyHMR(
+              contents,
+              args,
+              ctx.config,
+              !!build.initialOptions.sourcemap
+            );
+          }
+
           return {
             contents,
             loader,
@@ -94,7 +100,7 @@ export const cssSideEffectImportsPlugin = ({
           }
 
           return {
-            path: path.relative(config.rootDirectory, resolvedPath),
+            path: path.relative(ctx.config.rootDirectory, resolvedPath),
             namespace,
           };
         }
@@ -108,7 +114,7 @@ export const cssSideEffectImportsPlugin = ({
             await postcssProcessor.process(contents, {
               from: args.path,
               to: args.path,
-              map: options.sourcemap,
+              map: ctx.options.sourcemap,
             })
           ).css;
         }
diff --git a/packages/remix-dev/compiler/utils/postcss.ts b/packages/remix-dev/compiler/utils/postcss.ts
index bf27ae437..8efe0cac2 100644
--- a/packages/remix-dev/compiler/utils/postcss.ts
+++ b/packages/remix-dev/compiler/utils/postcss.ts
@@ -63,7 +63,7 @@ export async function loadPostcssPlugins({
 
   if (config.tailwind) {
     let tailwindPlugin = await loadTailwindPlugin(config);
-    if (tailwindPlugin && !hasTailwindPlugin(plugins)) {
+    if (tailwindPlugin && !hasTailwindPlugin(plugins, tailwindPlugin)) {
       plugins.push(tailwindPlugin);
     }
   }
@@ -94,10 +94,15 @@ export async function getPostcssProcessor({
   return processor;
 }
 
-function hasTailwindPlugin(plugins: Array<AcceptedPlugin>) {
+function hasTailwindPlugin(
+  plugins: Array<AcceptedPlugin>,
+  tailwindPlugin: AcceptedPlugin
+) {
   return plugins.some(
     (plugin) =>
-      "postcssPlugin" in plugin && plugin.postcssPlugin === "tailwindcss"
+      plugin === tailwindPlugin ||
+      (typeof plugin === "function" && plugin.name === "tailwindcss") ||
+      ("postcssPlugin" in plugin && plugin.postcssPlugin === "tailwindcss")
   );
 }
 
diff --git a/packages/remix-dev/config.ts b/packages/remix-dev/config.ts
index a795c10cc..ed90d95a1 100644
--- a/packages/remix-dev/config.ts
+++ b/packages/remix-dev/config.ts
@@ -55,6 +55,7 @@ interface FutureConfig {
   /** @deprecated Use the `tailwind` config option instead */
   unstable_tailwind: boolean;
   v2_errorBoundary: boolean;
+  v2_headers: boolean;
   v2_meta: boolean;
   v2_normalizeFormMethod: boolean;
   v2_routeConvention: boolean;
@@ -442,6 +443,10 @@ export async function readConfig(
     warnOnce(metaWarning, "v2_meta");
   }
 
+  if (!appConfig.future?.v2_headers) {
+    warnOnce(headersWarning, "v2_headers");
+  }
+
   let isCloudflareRuntime = ["cloudflare-pages", "cloudflare-workers"].includes(
     appConfig.serverBuildTarget ?? ""
   );
@@ -738,6 +743,7 @@ export async function readConfig(
     unstable_postcss: appConfig.future?.unstable_postcss === true,
     unstable_tailwind: appConfig.future?.unstable_tailwind === true,
     v2_errorBoundary: appConfig.future?.v2_errorBoundary === true,
+    v2_headers: appConfig.future?.v2_headers === true,
     v2_meta: appConfig.future?.v2_meta === true,
     v2_normalizeFormMethod: appConfig.future?.v2_normalizeFormMethod === true,
     v2_routeConvention: appConfig.future?.v2_routeConvention === true,
@@ -928,3 +934,9 @@ export const metaWarning =
   "You can prepare for this change at your convenience with the `v2_meta` future flag. " +
   "For instructions on making this change see " +
   "https://remix.run/docs/en/v1.15.0/pages/v2#meta";
+
+export const headersWarning =
+  "⚠️ REMIX FUTURE CHANGE: The route `headers` export behavior is changing in v2. " +
+  "You can prepare for this change at your convenience with the `v2_headers` future flag. " +
+  "For instructions on making this change see " +
+  "https://remix.run/docs/en/v1.17.0/pages/v2#route-headers";
diff --git a/packages/remix-dev/config/flat-routes.ts b/packages/remix-dev/config/flat-routes.ts
index e4f49393f..70811b727 100644
--- a/packages/remix-dev/config/flat-routes.ts
+++ b/packages/remix-dev/config/flat-routes.ts
@@ -214,10 +214,57 @@ export function flatRoutesUniversal(
         .replace(/\/$/, "");
     }
 
+    if (!config.parentId) config.parentId = "root";
+
+    /**
+     * 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);
 
-    if (!config.parentId) config.parentId = "root";
     config.path = pathname || undefined;
     uniqueRoutes.set(conflictRouteId, config);
 
diff --git a/packages/remix-dev/config/routesConvention.ts b/packages/remix-dev/config/routesConvention.ts
index 742d1bb0e..35713e756 100644
--- a/packages/remix-dev/config/routesConvention.ts
+++ b/packages/remix-dev/config/routesConvention.ts
@@ -73,11 +73,52 @@ export function defineConventionalRoutes(
       let isIndexRoute = routeId.endsWith("/index");
       let fullPath = createRoutePath(routeId.slice("routes".length + 1));
       let uniqueRouteId = (fullPath || "") + (isIndexRoute ? "?index" : "");
-
-      if (uniqueRouteId) {
+      let isPathlessLayoutRoute =
+        routeId.split("/").pop()?.startsWith("__") === true;
+
+      /**
+       * 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/
+       *       __public/
+       *         login.tsx
+       *         perks.tsx
+       *       __private/
+       *         orders.tsx
+       *         profile.tsx
+       *       __public.tsx
+       *       __private.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
+       */
+      if (uniqueRouteId && !isPathlessLayoutRoute) {
         if (uniqueRoutes.has(uniqueRouteId)) {
           throw new Error(
-            `Path ${JSON.stringify(fullPath)} defined by route ${JSON.stringify(
+            `Path ${JSON.stringify(
+              fullPath || "/"
+            )} defined by route ${JSON.stringify(
               routeId
             )} conflicts with route ${JSON.stringify(
               uniqueRoutes.get(uniqueRouteId)
@@ -245,6 +286,8 @@ export function createRoutePath(partialRouteId: string): string | undefined {
 
   if (rawSegmentBuffer === "index" && result.endsWith("index")) {
     result = result.replace(/\/?index$/, "");
+  } else {
+    result = result.replace(/\/$/, "");
   }
 
   if (rawSegmentBuffer === "index" && result.endsWith("index?")) {
diff --git a/packages/remix-dev/devServer_unstable/hdr.ts b/packages/remix-dev/devServer_unstable/hdr.ts
index bacb64753..69e859b24 100644
--- a/packages/remix-dev/devServer_unstable/hdr.ts
+++ b/packages/remix-dev/devServer_unstable/hdr.ts
@@ -16,7 +16,9 @@ function isBareModuleId(id: string): boolean {
 
 type Route = Context["config"]["routes"][string];
 
-export let detectLoaderChanges = async (ctx: Context) => {
+export let detectLoaderChanges = async (
+  ctx: Context
+): Promise<Record<string, string>> => {
   let entryPoints: Record<string, string> = {};
   for (let id of Object.keys(ctx.config.routes)) {
     entryPoints[id] = ctx.config.routes[id].file + "?loader";
@@ -30,6 +32,7 @@ export let detectLoaderChanges = async (ctx: Context) => {
     write: false,
     entryNames: "[hash]",
     loader: loaders,
+    logLevel: "silent",
     plugins: [
       {
         name: "hmr-loader",
@@ -98,13 +101,13 @@ export let detectLoaderChanges = async (ctx: Context) => {
   };
 
   let { metafile } = await esbuild.build(options);
-  let entries = Object.entries(metafile!.outputs).map(
-    ([hashjs, { entryPoint }]) => {
-      let file = entryPoint
-        ?.replace(/^hmr-loader:/, "")
-        ?.replace(/\?loader$/, "");
-      return [file, hashjs.replace(/\.js$/, "")];
-    }
-  );
-  return Object.fromEntries(entries);
+
+  let entries: Record<string, string> = {};
+  for (let [hashjs, { entryPoint }] of Object.entries(metafile!.outputs)) {
+    if (entryPoint === undefined) continue;
+    let file = entryPoint.replace(/^hmr-loader:/, "").replace(/\?loader$/, "");
+    entries[file] = hashjs.replace(/\.js$/, "");
+  }
+
+  return entries;
 };
diff --git a/packages/remix-dev/devServer_unstable/index.ts b/packages/remix-dev/devServer_unstable/index.ts
index c3d0e0a2f..e4492d350 100644
--- a/packages/remix-dev/devServer_unstable/index.ts
+++ b/packages/remix-dev/devServer_unstable/index.ts
@@ -15,6 +15,8 @@ import * as HMR from "./hmr";
 import { warnOnce } from "../warnOnce";
 import { detectPackageManager } from "../cli/detectPackageManager";
 import * as HDR from "./hdr";
+import type { Result } from "../result";
+import { err, ok } from "../result";
 
 type Origin = {
   scheme: string;
@@ -59,7 +61,7 @@ export let serve = async (
     manifest?: Manifest;
     prevManifest?: Manifest;
     appReady?: Channel.Type<void>;
-    hdr?: Promise<Record<string, string>>;
+    loaderChanges?: Promise<Result<Record<string, string>>>;
     prevLoaderHashes?: Record<string, string>;
   } = {};
 
@@ -122,57 +124,70 @@ export let serve = async (
     },
     {
       onBuildStart: async (ctx) => {
+        // stop listening for previous manifest
         state.appReady?.err();
+
         clean(ctx.config);
         websocket.log(state.prevManifest ? "Rebuilding..." : "Building...");
 
-        state.hdr = HDR.detectLoaderChanges(ctx);
+        state.loaderChanges = HDR.detectLoaderChanges(ctx).then(ok, err);
       },
       onBuildManifest: (manifest: Manifest) => {
         state.manifest = manifest;
+        state.appReady = Channel.create();
       },
       onBuildFinish: async (ctx, durationMs, succeeded) => {
         if (!succeeded) return;
-
         websocket.log(
           (state.prevManifest ? "Rebuilt" : "Built") +
             ` in ${prettyMs(durationMs)}`
         );
-        state.appReady = Channel.create();
 
-        let start = Date.now();
-        console.log(`Waiting for app server (${state.manifest?.version})`);
-        if (
-          options.command &&
-          (state.appServer === undefined || options.restart)
-        ) {
-          await kill(state.appServer);
-          state.appServer = startAppServer(options.command);
-        }
-        let { ok } = await state.appReady.result;
-        // result not ok -> new build started before this one finished. do not process outdated manifest
-        let loaderHashes = await state.hdr;
-        if (ok) {
+        // accumulate new state, but only update state after updates are processed
+        let newState: typeof state = { prevManifest: state.manifest };
+        try {
+          console.log(`Waiting for app server (${state.manifest?.version})`);
+          let start = Date.now();
+          if (
+            options.command &&
+            (state.appServer === undefined || options.restart)
+          ) {
+            await kill(state.appServer);
+            state.appServer = startAppServer(options.command);
+          }
+          let appReady = await state.appReady!.result;
+          if (!appReady.ok) return;
           console.log(`App server took ${prettyMs(Date.now() - start)}`);
-          if (state.manifest && loaderHashes && state.prevManifest) {
+
+          // HMR + HDR
+          let loaderChanges = await state.loaderChanges!;
+          if (loaderChanges.ok) {
+            newState.prevLoaderHashes = loaderChanges.value;
+          }
+          if (loaderChanges?.ok && state.manifest && state.prevManifest) {
             let updates = HMR.updates(
               ctx.config,
               state.manifest,
               state.prevManifest,
-              loaderHashes,
+              loaderChanges.value,
               state.prevLoaderHashes
             );
             websocket.hmr(state.manifest, updates);
 
             let hdr = updates.some((u) => u.revalidate);
             console.log("> HMR" + (hdr ? " + HDR" : ""));
-          } else if (state.prevManifest !== undefined) {
+            return;
+          }
+
+          // Live Reload
+          if (state.prevManifest !== undefined) {
             websocket.reload();
             console.log("> Live reload");
           }
+        } finally {
+          // commit accumulated state
+          Object.assign(state, newState);
         }
-        state.prevManifest = state.manifest;
-        state.prevLoaderHashes = loaderHashes;
       },
       onFileCreated: (file) =>
         websocket.log(`File created: ${relativePath(file)}`),
diff --git a/packages/remix-react/components.tsx b/packages/remix-react/components.tsx
index 89fa1b7e9..cd5fbf8e8 100644
--- a/packages/remix-react/components.tsx
+++ b/packages/remix-react/components.tsx
@@ -1087,13 +1087,8 @@ import(${JSON.stringify(manifest.entry.module)});`;
 
   let preloads = isHydrated ? [] : manifest.entry.imports.concat(routePreloads);
 
-  return (
+  return isHydrated ? null : (
     <>
-      <link
-        rel="modulepreload"
-        href={manifest.url}
-        crossOrigin={props.crossOrigin}
-      />
       <link
         rel="modulepreload"
         href={manifest.entry.module}
@@ -1107,8 +1102,8 @@ import(${JSON.stringify(manifest.entry.module)});`;
           crossOrigin={props.crossOrigin}
         />
       ))}
-      {!isHydrated && initialScripts}
-      {!isHydrated && deferredScripts}
+      {initialScripts}
+      {deferredScripts}
     </>
   );
 }
diff --git a/packages/remix-react/entry.ts b/packages/remix-react/entry.ts
index fde03eca1..d2a2ab2f2 100644
--- a/packages/remix-react/entry.ts
+++ b/packages/remix-react/entry.ts
@@ -33,6 +33,7 @@ export interface FutureConfig {
   /** @deprecated Use the `tailwind` config option instead */
   unstable_tailwind: boolean;
   v2_errorBoundary: boolean;
+  v2_headers: boolean;
   v2_meta: boolean;
   v2_normalizeFormMethod: boolean;
   v2_routeConvention: boolean;
diff --git a/packages/remix-server-runtime/entry.ts b/packages/remix-server-runtime/entry.ts
index 1191e01cc..ea09f729b 100644
--- a/packages/remix-server-runtime/entry.ts
+++ b/packages/remix-server-runtime/entry.ts
@@ -25,6 +25,7 @@ export interface FutureConfig {
   /** @deprecated Use the `tailwind` config option instead */
   unstable_tailwind: boolean;
   v2_errorBoundary: boolean;
+  v2_headers: boolean;
   v2_meta: boolean;
   v2_normalizeFormMethod: boolean;
   v2_routeConvention: boolean;
diff --git a/packages/remix-server-runtime/headers.ts b/packages/remix-server-runtime/headers.ts
index e13e65aee..98ac48533 100644
--- a/packages/remix-server-runtime/headers.ts
+++ b/packages/remix-server-runtime/headers.ts
@@ -37,6 +37,16 @@ export function getDocumentHeadersRR(
     let routeModule = build.routes[id].module;
     let loaderHeaders = context.loaderHeaders[id] || new Headers();
     let actionHeaders = context.actionHeaders[id] || new Headers();
+
+    // When the future flag is enabled, use the parent headers for any route
+    // that doesn't have a `headers` export
+    if (routeModule.headers == null && build.future.v2_headers) {
+      let headers = parentHeaders;
+      prependCookies(actionHeaders, headers);
+      prependCookies(loaderHeaders, headers);
+      return headers;
+    }
+
     let headers = new Headers(
       routeModule.headers
         ? typeof routeModule.headers === "function"
diff --git a/packages/remix-server-runtime/serialize.ts b/packages/remix-server-runtime/serialize.ts
index d418bc072..7e2f9c98f 100644
--- a/packages/remix-server-runtime/serialize.ts
+++ b/packages/remix-server-runtime/serialize.ts
@@ -46,7 +46,11 @@ type SerializeObject<T extends object> = {
 
 // prettier-ignore
 type SerializeDeferred<T extends Record<string, unknown>> = {
-  [k in keyof T as T[k] extends Promise<unknown> ? k : T[k] extends NonJsonPrimitive ? never : k]:
+  [k in keyof T as
+    T[k] extends Promise<unknown> ? k :
+    T[k] extends NonJsonPrimitive ? never :
+    k
+  ]:
     T[k] extends Promise<infer U>
     ? Promise<Serialize<U>> extends never ? "wtf" : Promise<Serialize<U>>
     : Serialize<T[k]>  extends never ? k : Serialize<T[k]>;
diff --git a/packages/remix-server-runtime/server.ts b/packages/remix-server-runtime/server.ts
index 97fa3a420..7bc58a547 100644
--- a/packages/remix-server-runtime/server.ts
+++ b/packages/remix-server-runtime/server.ts
@@ -1,5 +1,6 @@
 import type {
   UNSAFE_DeferredData as DeferredData,
+  ErrorResponse,
   StaticHandler,
   StaticHandlerContext,
 } from "@remix-run/router";
@@ -8,6 +9,7 @@ import {
   getStaticContextFromError,
   isRouteErrorResponse,
   createStaticHandler,
+  json as routerJson,
 } from "@remix-run/router";
 
 import type { AppLoadContext } from "./data";
@@ -23,7 +25,6 @@ import type { ServerRouteManifest } from "./routes";
 import { createStaticHandlerDataRoutes, createRoutes } from "./routes";
 import {
   createDeferredReadableStream,
-  json,
   isRedirectResponse,
   isResponse,
 } from "./responses";
@@ -158,18 +159,16 @@ async function handleDataRequestRR(
       return error;
     }
 
-    let status = isRouteErrorResponse(error) ? error.status : 500;
-    let errorInstance =
-      isRouteErrorResponse(error) && error.error
-        ? error.error
-        : error instanceof Error
-        ? error
-        : new Error("Unexpected Server Error");
+    if (isRouteErrorResponse(error)) {
+      logServerErrorIfNotAborted(error.error || error, request, serverMode);
+      return errorResponseToJson(error, serverMode);
+    }
 
+    let errorInstance =
+      error instanceof Error ? error : new Error("Unexpected Server Error");
     logServerErrorIfNotAborted(errorInstance, request, serverMode);
-
-    return json(serializeError(errorInstance, serverMode), {
-      status,
+    return routerJson(serializeError(errorInstance, serverMode), {
+      status: 500,
       headers: {
         "X-Remix-Error": "yes",
       },
@@ -359,6 +358,12 @@ async function handleResourceRequestRR(
       error.headers.set("X-Remix-Catch", "yes");
       return error;
     }
+
+    if (isRouteErrorResponse(error)) {
+      logServerErrorIfNotAborted(error.error || error, request, serverMode);
+      return errorResponseToJson(error, serverMode);
+    }
+
     logServerErrorIfNotAborted(error, request, serverMode);
     return returnLastResortErrorResponse(error, serverMode);
   }
@@ -374,6 +379,25 @@ function logServerErrorIfNotAborted(
   }
 }
 
+function errorResponseToJson(
+  errorResponse: ErrorResponse,
+  serverMode: ServerMode
+): Response {
+  return routerJson(
+    serializeError(
+      errorResponse.error || new Error("Unexpected Server Error"),
+      serverMode
+    ),
+    {
+      status: errorResponse.status,
+      statusText: errorResponse.statusText,
+      headers: {
+        "X-Remix-Error": "yes",
+      },
+    }
+  );
+}
+
 function returnLastResortErrorResponse(error: any, serverMode?: ServerMode) {
   let message = "Unexpected Server Error";
 


Test patch:
diff --git a/integration/conventional-routes-test.ts b/integration/conventional-routes-test.ts
new file mode 100644
index 000000000..665ad3516
--- /dev/null
+++ b/integration/conventional-routes-test.ts
@@ -0,0 +1,132 @@
+import { test, expect } from "@playwright/test";
+
+import { PlaywrightFixture } from "./helpers/playwright-fixture";
+import type { Fixture, AppFixture } from "./helpers/create-fixture";
+import { createAppFixture, createFixture, js } from "./helpers/create-fixture";
+
+let fixture: Fixture;
+let appFixture: AppFixture;
+
+test.beforeAll(async () => {
+  fixture = await createFixture({
+    files: {
+      "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 (
+            <html lang="en">
+              <body>
+                <nav>
+                  <Link to="/nested">/nested</Link>
+                  <br />
+                  <Link to="/nested/foo">/nested/foo</Link>
+                  <br />
+                </nav>
+                <p>{matches}</p>
+                <Outlet />
+                <Scripts />
+              </body>
+            </html>
+          );
+        }
+      `,
+      "app/routes/nested/index.jsx": js`
+        export default function Index() {
+          return <h1>Index</h1>;
+        }
+      `,
+      "app/routes/nested/__pathless.jsx": js`
+        import { Outlet } from "@remix-run/react";
+
+        export default function Layout() {
+          return (
+            <>
+              <div>Pathless Layout</div>
+              <Outlet />
+            </>
+          );
+        }
+      `,
+      "app/routes/nested/__pathless/foo.jsx": js`
+        export default function Foo() {
+          return <h1>Foo</h1>;
+        }
+      `,
+      "app/routes/nested/__pathless2.jsx": js`
+        import { Outlet } from "@remix-run/react";
+
+        export default function Layout() {
+          return (
+            <>
+              <div>Pathless 2 Layout</div>
+              <Outlet />
+            </>
+          );
+        }
+      `,
+      "app/routes/nested/__pathless2/bar.jsx": js`
+        export default function Bar() {
+          return <h1>Bar</h1>;
+        }
+      `,
+    },
+  });
+
+  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:
+ *
+ * <Routes>
+ *   <Route file="root.jsx">
+ *     <Route path="nested" file="routes/nested/__pathless.jsx">
+ *       <Route path="foo" file="routes/nested/__pathless/foo.jsx" />
+ *     </Route>
+ *     <Route path="nested" index file="routes/nested/index.jsx" />
+ *     <Route index file="routes/index.jsx" />
+ *   </Route>
+ * </Routes>
+ */
+
+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/integration/flat-routes-test.ts b/integration/flat-routes-test.ts
index f685f4b4f..31bc29e84 100644
--- a/integration/flat-routes-test.ts
+++ b/integration/flat-routes-test.ts
@@ -312,3 +312,132 @@ test.describe("", () => {
     expect(buildOutput).not.toContain(`Route Path Collision`);
   });
 });
+
+test.describe("pathless routes and route collisions", () => {
+  test.beforeAll(async () => {
+    fixture = await createFixture({
+      future: { v2_routeConvention: true },
+      files: {
+        "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 (
+              <html lang="en">
+                <body>
+                  <nav>
+                    <Link to="/nested">/nested</Link>
+                    <br />
+                    <Link to="/nested/foo">/nested/foo</Link>
+                    <br />
+                  </nav>
+                  <p>{matches}</p>
+                  <Outlet />
+                  <Scripts />
+                </body>
+              </html>
+            );
+          }
+        `,
+        "app/routes/nested._index.jsx": js`
+          export default function Index() {
+            return <h1>Index</h1>;
+          }
+        `,
+        "app/routes/nested._pathless.jsx": js`
+          import { Outlet } from "@remix-run/react";
+
+          export default function Layout() {
+            return (
+              <>
+                <div>Pathless Layout</div>
+                <Outlet />
+              </>
+            );
+          }
+        `,
+        "app/routes/nested._pathless.foo.jsx": js`
+          export default function Foo() {
+            return <h1>Foo</h1>;
+          }
+        `,
+        "app/routes/nested._pathless2.jsx": js`
+          import { Outlet } from "@remix-run/react";
+
+          export default function Layout() {
+            return (
+              <>
+                <div>Pathless 2 Layout</div>
+                <Outlet />
+              </>
+            );
+          }
+        `,
+        "app/routes/nested._pathless2.bar.jsx": js`
+          export default function Bar() {
+            return <h1>Bar</h1>;
+          }
+        `,
+      },
+    });
+
+    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:
+   *
+   * <Routes>
+   *   <Route file="root.jsx">
+   *     <Route path="nested" file="routes/nested/__pathless.jsx">
+   *       <Route path="foo" file="routes/nested/__pathless/foo.jsx" />
+   *     </Route>
+   *     <Route path="nested" index file="routes/nested/index.jsx" />
+   *     <Route index file="routes/index.jsx" />
+   *   </Route>
+   * </Routes>
+   */
+
+  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/integration/headers-test.ts b/integration/headers-test.ts
index 7521cd6a4..0bf306be5 100644
--- a/integration/headers-test.ts
+++ b/integration/headers-test.ts
@@ -13,7 +13,11 @@ test.describe("headers export", () => {
 
   test.beforeAll(async () => {
     appFixture = await createFixture({
-      future: { v2_routeConvention: true, v2_errorBoundary: true },
+      future: {
+        v2_routeConvention: true,
+        v2_errorBoundary: true,
+        v2_headers: true,
+      },
       files: {
         "app/root.jsx": js`
           import { json } from "@remix-run/node";
@@ -122,15 +126,6 @@ test.describe("headers export", () => {
         `,
 
         "app/routes/parent.child.jsx": js`
-          export function headers({ actionHeaders, errorHeaders, loaderHeaders, parentHeaders }) {
-            return new Headers([
-              ...(parentHeaders ? Array.from(parentHeaders.entries()) : []),
-              ...(actionHeaders ? Array.from(actionHeaders.entries()) : []),
-              ...(loaderHeaders ? Array.from(loaderHeaders.entries()) : []),
-              ...(errorHeaders ? Array.from(errorHeaders.entries()) : []),
-            ]);
-          }
-
           export function loader({ request }) {
             if (new URL(request.url).searchParams.get('throw') === "child") {
               throw new Response(null, {
@@ -138,24 +133,18 @@ test.describe("headers export", () => {
                 headers: { 'X-Child-Loader': 'error' },
               })
             }
-            return new Response(null, {
-              headers: { 'X-Child-Loader': 'success' },
-            })
+            return null
           }
 
           export async function action({ request }) {
             let fd = await request.formData();
             if (fd.get('throw') === "child") {
-              console.log('throwing from child action')
               throw new Response(null, {
                 status: 400,
                 headers: { 'X-Child-Action': 'error' },
               })
             }
-            console.log('returning from child action')
-            return new Response(null, {
-              headers: { 'X-Child-Action': 'success' },
-            })
+            return null
           }
 
           export default function Component() { return <div/> }
@@ -256,7 +245,6 @@ test.describe("headers export", () => {
     expect(JSON.stringify(Array.from(response.headers.entries()))).toBe(
       JSON.stringify([
         ["content-type", "text/html"],
-        ["x-child-loader", "success"],
         ["x-parent-loader", "success"],
       ])
     );
@@ -284,8 +272,6 @@ test.describe("headers export", () => {
     expect(JSON.stringify(Array.from(response.headers.entries()))).toBe(
       JSON.stringify([
         ["content-type", "text/html"],
-        ["x-child-action", "success"],
-        ["x-child-loader", "success"],
         ["x-parent-loader", "success"],
       ])
     );
@@ -365,3 +351,84 @@ test.describe("headers export", () => {
     );
   });
 });
+
+test.describe("v1 behavior (future.v2_headers=false)", () => {
+  let appFixture: Fixture;
+
+  test.beforeAll(async () => {
+    appFixture = await createFixture({
+      future: {
+        v2_routeConvention: true,
+        v2_errorBoundary: true,
+        v2_headers: false,
+      },
+      files: {
+        "app/root.jsx": js`
+          import { json } from "@remix-run/node";
+          import { Links, Meta, Outlet, Scripts } from "@remix-run/react";
+
+          export const loader = () => json({});
+
+          export default function Root() {
+            return (
+              <html lang="en">
+                <head>
+                  <Meta />
+                  <Links />
+                </head>
+                <body>
+                  <Outlet />
+                  <Scripts />
+                </body>
+              </html>
+            );
+          }
+        `,
+
+        "app/routes/parent.jsx": js`
+          export function headers({ actionHeaders, errorHeaders, loaderHeaders, parentHeaders }) {
+            return new Headers([
+              ...(parentHeaders ? Array.from(parentHeaders.entries()) : []),
+              ...(actionHeaders ? Array.from(actionHeaders.entries()) : []),
+              ...(loaderHeaders ? Array.from(loaderHeaders.entries()) : []),
+              ...(errorHeaders ? Array.from(errorHeaders.entries()) : []),
+            ]);
+          }
+
+          export function loader({ request }) {
+            return new Response(null, {
+              headers: { 'X-Parent-Loader': 'success' },
+            })
+          }
+
+          export default function Component() { return <div/> }
+        `,
+
+        "app/routes/parent.child.jsx": js`
+          export async function action({ request }) {
+            return null;
+          }
+
+          export default function Component() { return <div/> }
+        `,
+      },
+    });
+  });
+
+  test("returns no headers when the leaf route doesn't export a header function (GET)", async () => {
+    let response = await appFixture.requestDocument("/parent/child");
+    expect(JSON.stringify(Array.from(response.headers.entries()))).toBe(
+      JSON.stringify([["content-type", "text/html"]])
+    );
+  });
+
+  test("returns no headers when the leaf route doesn't export a header function (POST)", async () => {
+    let response = await appFixture.postDocument(
+      "/parent/child",
+      new URLSearchParams()
+    );
+    expect(JSON.stringify(Array.from(response.headers.entries()))).toBe(
+      JSON.stringify([["content-type", "text/html"]])
+    );
+  });
+});
diff --git a/integration/hmr-log-test.ts b/integration/hmr-log-test.ts
index cdb02556e..d7aa1dfd6 100644
--- a/integration/hmr-log-test.ts
+++ b/integration/hmr-log-test.ts
@@ -463,6 +463,49 @@ whatsup
     fs.writeFileSync(mdxPath, originalMdx);
     await page.waitForSelector(`#crazy`);
     expect(dataRequests).toBe(5);
+
+    // dev server doesn't crash when rebuild fails
+    await page.click(`a[href="/"]`);
+    await page.getByText("Hello, planet").waitFor({ timeout: HMR_TIMEOUT_MS });
+    await page.waitForLoadState("networkidle");
+
+    expect(devStderr()).toBe("");
+    let withSyntaxError = `
+      import { useLoaderData } from "@remix-run/react";
+      export function shouldRevalidate(args) {
+        return true;
+      }
+      eport efault functio Index() {
+        const t = useLoaderData();
+        return (
+          <mai>
+            <h1>With Syntax Error</h1>
+          </main>
+        )
+      }
+    `;
+    fs.writeFileSync(indexPath, withSyntaxError);
+    await wait(() => devStderr().includes('Expected ";" but found "efault"'), {
+      timeoutMs: HMR_TIMEOUT_MS,
+    });
+
+    let withFix = `
+      import { useLoaderData } from "@remix-run/react";
+      export function shouldRevalidate(args) {
+        return true;
+      }
+      export default function Index() {
+        // const t = useLoaderData();
+        return (
+          <main>
+            <h1>With Fix</h1>
+          </main>
+        )
+      }
+    `;
+    fs.writeFileSync(indexPath, withFix);
+    await page.waitForLoadState("networkidle");
+    await page.getByText("With Fix").waitFor({ timeout: HMR_TIMEOUT_MS });
   } catch (e) {
     console.log("stdout begin -----------------------");
     console.log(devStdout());
diff --git a/integration/hmr-test.ts b/integration/hmr-test.ts
index b3c418906..e369070b9 100644
--- a/integration/hmr-test.ts
+++ b/integration/hmr-test.ts
@@ -462,6 +462,49 @@ whatsup
     fs.writeFileSync(mdxPath, originalMdx);
     await page.waitForSelector(`#crazy`);
     expect(dataRequests).toBe(5);
+
+    // dev server doesn't crash when rebuild fails
+    await page.click(`a[href="/"]`);
+    await page.getByText("Hello, planet").waitFor({ timeout: HMR_TIMEOUT_MS });
+    await page.waitForLoadState("networkidle");
+
+    expect(devStderr()).toBe("");
+    let withSyntaxError = `
+      import { useLoaderData } from "@remix-run/react";
+      export function shouldRevalidate(args) {
+        return true;
+      }
+      eport efault functio Index() {
+        const t = useLoaderData();
+        return (
+          <mai>
+            <h1>With Syntax Error</h1>
+          </main>
+        )
+      }
+    `;
+    fs.writeFileSync(indexPath, withSyntaxError);
+    await wait(() => devStderr().includes('Expected ";" but found "efault"'), {
+      timeoutMs: HMR_TIMEOUT_MS,
+    });
+
+    let withFix = `
+      import { useLoaderData } from "@remix-run/react";
+      export function shouldRevalidate(args) {
+        return true;
+      }
+      export default function Index() {
+        // const t = useLoaderData();
+        return (
+          <main>
+            <h1>With Fix</h1>
+          </main>
+        )
+      }
+    `;
+    fs.writeFileSync(indexPath, withFix);
+    await page.waitForLoadState("networkidle");
+    await page.getByText("With Fix").waitFor({ timeout: HMR_TIMEOUT_MS });
   } catch (e) {
     console.log("stdout begin -----------------------");
     console.log(devStdout());
diff --git a/integration/resource-routes-test.ts b/integration/resource-routes-test.ts
index 5307d4711..82d1218e2 100644
--- a/integration/resource-routes-test.ts
+++ b/integration/resource-routes-test.ts
@@ -8,10 +8,13 @@ import { PlaywrightFixture } from "./helpers/playwright-fixture";
 test.describe("loader in an app", async () => {
   let appFixture: AppFixture;
   let fixture: Fixture;
+  let _consoleError: typeof console.error;
 
   let SVG_CONTENTS = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" fill="none" stroke="#000" stroke-width="4" aria-label="Chicken"><path d="M48.1 34C22.1 32 1.4 51 2.5 67.2c1.2 16.1 19.8 17 29 17.8H89c15.7-6.6 6.3-18.9.3-20.5A28 28 0 0073 41.7c-.5-7.2 3.4-11.6 6.9-15.3 8.5 2.6 8-8 .8-7.2.6-6.5-12.3-5.9-6.7 2.7l-3.7 5c-6.9 5.4-10.9 5.1-22.2 7zM48.1 34c-38 31.9 29.8 58.4 25 7.7M70.3 26.9l5.4 4.2"/></svg>`;
 
   test.beforeAll(async () => {
+    _consoleError = console.error;
+    console.error = () => {};
     fixture = await createFixture({
       future: { v2_routeConvention: true },
       files: {
@@ -25,6 +28,9 @@ test.describe("loader in an app", async () => {
                 <input name="destination" defaultValue="/redirect-destination" />
                 <button type="submit">Redirect</button>
               </Form>
+              <Form action="/no-action" method="post">
+                <button type="submit">Submit to no action route</button>
+              </Form>
             </>
           )
         `,
@@ -94,6 +100,12 @@ test.describe("loader in an app", async () => {
             throw { but: 'why' };
           }
         `,
+        "app/routes/no-action.jsx": js`
+          import { json } from "@remix-run/node";
+          export let loader = () => {
+            return json({ ok: true });
+          }
+        `,
       },
     });
     appFixture = await createAppFixture(fixture, ServerMode.Test);
@@ -101,6 +113,7 @@ test.describe("loader in an app", async () => {
 
   test.afterAll(() => {
     appFixture.close();
+    console.error = _consoleError;
   });
 
   test.describe("with JavaScript", () => {
@@ -190,6 +203,28 @@ test.describe("loader in an app", async () => {
       "Unexpected Server Error\n\n[object Object]"
     );
   });
+
+  test("should handle ErrorResponses thrown from resource routes on document requests", async () => {
+    let res = await fixture.postDocument("/no-action", new FormData());
+    expect(res.status).toBe(405);
+    expect(res.statusText).toBe("Method Not Allowed");
+    expect(await res.text()).toBe('{"message":"Unexpected Server Error"}');
+  });
+
+  test("should handle ErrorResponses thrown from resource routes on client submissions", async ({
+    page,
+  }) => {
+    let logs: string[] = [];
+    page.on("console", (msg) => logs.push(msg.text()));
+    let app = new PlaywrightFixture(appFixture, page);
+    await app.goto("/");
+    await app.clickSubmitButton("/no-action");
+    let html = await app.getHtml();
+    expect(html).toMatch("Application Error");
+    expect(logs[0]).toContain(
+      'Route "routes/no-action" does not have an action'
+    );
+  });
 });
 
 test.describe("Development server", async () => {
diff --git a/integration/route-collisions-test.ts b/integration/route-collisions-test.ts
new file mode 100644
index 000000000..d6ea36068
--- /dev/null
+++ b/integration/route-collisions-test.ts
@@ -0,0 +1,283 @@
+import { PassThrough } from "node:stream";
+import { test, expect } from "@playwright/test";
+
+import { createFixture, js } from "./helpers/create-fixture";
+
+let ROOT_FILE_CONTENTS = js`
+  import { Outlet, Scripts } from "@remix-run/react";
+
+  export default function App() {
+    return (
+      <html lang="en">
+        <body>
+          <Outlet />
+          <Scripts />
+        </body>
+      </html>
+    );
+  }
+`;
+
+let LAYOUT_FILE_CONTENTS = js`
+  import { Outlet } from "@remix-run/react";
+
+  export default function Layout() {
+    return <Outlet />
+  }
+`;
+
+let LEAF_FILE_CONTENTS = js`
+  export default function Foo() {
+    return <h1>Foo</h1>;
+  }
+`;
+
+test.describe("build failures (v1 routes)", () => {
+  let errorLogs: string[];
+  let oldConsoleError: typeof console.error;
+
+  test.beforeEach(() => {
+    errorLogs = [];
+    oldConsoleError = console.error;
+    console.error = (str) => errorLogs.push(str);
+  });
+
+  test.afterEach(() => {
+    console.error = oldConsoleError;
+  });
+
+  test("detects path collisions inside pathless layout routes", async () => {
+    try {
+      await createFixture({
+        future: { v2_routeConvention: false },
+        files: {
+          "app/root.tsx": ROOT_FILE_CONTENTS,
+          "app/routes/foo.jsx": LEAF_FILE_CONTENTS,
+          "app/routes/__pathless.jsx": LAYOUT_FILE_CONTENTS,
+          "app/routes/__pathless/foo.jsx": LEAF_FILE_CONTENTS,
+        },
+      });
+      expect(false).toBe(true);
+    } catch (e) {
+      expect(errorLogs[0]).toMatch(
+        'Error: Path "foo" defined by route "routes/foo" conflicts with route "routes/__pathless/foo"'
+      );
+      expect(errorLogs.length).toBe(1);
+    }
+  });
+
+  test("detects path collisions across pathless layout routes", async () => {
+    try {
+      await createFixture({
+        future: { v2_routeConvention: false },
+        files: {
+          "app/root.tsx": ROOT_FILE_CONTENTS,
+          "app/routes/__pathless.jsx": LAYOUT_FILE_CONTENTS,
+          "app/routes/__pathless/foo.jsx": LEAF_FILE_CONTENTS,
+          "app/routes/__pathless2.jsx": LAYOUT_FILE_CONTENTS,
+          "app/routes/__pathless2/foo.jsx": LEAF_FILE_CONTENTS,
+        },
+      });
+      expect(false).toBe(true);
+    } catch (e) {
+      expect(errorLogs[0]).toMatch(
+        'Error: Path "foo" defined by route "routes/__pathless/foo" conflicts with route "routes/__pathless2/foo"'
+      );
+      expect(errorLogs.length).toBe(1);
+    }
+  });
+
+  test("detects path collisions inside multiple pathless layout routes", async () => {
+    try {
+      await createFixture({
+        future: { v2_routeConvention: false },
+        files: {
+          "app/root.tsx": ROOT_FILE_CONTENTS,
+          "app/routes/foo.jsx": LEAF_FILE_CONTENTS,
+          "app/routes/__pathless.jsx": LAYOUT_FILE_CONTENTS,
+          "app/routes/__pathless/__again.jsx": LAYOUT_FILE_CONTENTS,
+          "app/routes/__pathless/__again/foo.jsx": LEAF_FILE_CONTENTS,
+        },
+      });
+      expect(false).toBe(true);
+    } catch (e) {
+      expect(errorLogs[0]).toMatch(
+        'Error: Path "foo" defined by route "routes/foo" conflicts with route "routes/__pathless/__again/foo"'
+      );
+      expect(errorLogs.length).toBe(1);
+    }
+  });
+
+  test("detects path collisions of index files inside pathless layouts", async () => {
+    try {
+      await createFixture({
+        future: { v2_routeConvention: false },
+        files: {
+          "app/root.tsx": ROOT_FILE_CONTENTS,
+          "app/routes/index.jsx": LEAF_FILE_CONTENTS,
+          "app/routes/__pathless.jsx": LAYOUT_FILE_CONTENTS,
+          "app/routes/__pathless/index.jsx": LEAF_FILE_CONTENTS,
+        },
+      });
+      expect(false).toBe(true);
+    } catch (e) {
+      expect(errorLogs[0]).toMatch(
+        'Error: Path "/" defined by route "routes/index" conflicts with route "routes/__pathless/index"'
+      );
+      expect(errorLogs.length).toBe(1);
+    }
+  });
+
+  test("detects path collisions of index files across multiple pathless layouts", async () => {
+    try {
+      await createFixture({
+        future: { v2_routeConvention: false },
+        files: {
+          "app/root.tsx": ROOT_FILE_CONTENTS,
+          "app/routes/nested/__pathless.jsx": LAYOUT_FILE_CONTENTS,
+          "app/routes/nested/__pathless/index.jsx": LEAF_FILE_CONTENTS,
+          "app/routes/nested/__oops.jsx": LAYOUT_FILE_CONTENTS,
+          "app/routes/nested/__oops/index.jsx": LEAF_FILE_CONTENTS,
+        },
+      });
+      expect(false).toBe(true);
+    } catch (e) {
+      expect(errorLogs[0]).toMatch(
+        'Error: Path "nested" defined by route "routes/nested/__oops/index" conflicts with route "routes/nested/__pathless/index"'
+      );
+      expect(errorLogs.length).toBe(1);
+    }
+  });
+
+  test("detects path collisions of param routes inside pathless layouts", async () => {
+    try {
+      await createFixture({
+        future: { v2_routeConvention: false },
+        files: {
+          "app/root.tsx": ROOT_FILE_CONTENTS,
+          "app/routes/$param.jsx": LEAF_FILE_CONTENTS,
+          "app/routes/__pathless.jsx": LAYOUT_FILE_CONTENTS,
+          "app/routes/__pathless/$param.jsx": LEAF_FILE_CONTENTS,
+        },
+      });
+      expect(false).toBe(true);
+    } catch (e) {
+      expect(errorLogs[0]).toMatch(
+        'Error: Path ":param" defined by route "routes/$param" conflicts with route "routes/__pathless/$param"'
+      );
+      expect(errorLogs.length).toBe(1);
+    }
+  });
+});
+
+test.describe("build failures (v2 routes)", () => {
+  let originalConsoleLog = console.log;
+  let originalConsoleWarn = console.warn;
+  let originalConsoleError = console.error;
+
+  test.beforeAll(async () => {
+    console.log = () => {};
+    console.warn = () => {};
+    console.error = () => {};
+  });
+
+  test.afterAll(() => {
+    console.log = originalConsoleLog;
+    console.warn = originalConsoleWarn;
+    console.error = originalConsoleError;
+  });
+
+  async function setup(files: Record<string, string>) {
+    let buildStdio = new PassThrough();
+    let buildOutput: string;
+    await createFixture({
+      buildStdio,
+      future: { v2_routeConvention: true },
+      files,
+    });
+    let chunks: Buffer[] = [];
+    buildOutput = await new Promise<string>((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"))
+      );
+    });
+    return buildOutput;
+  }
+
+  test("detects path collisions inside pathless layout routes", async () => {
+    let buildOutput = await setup({
+      "app/root.tsx": ROOT_FILE_CONTENTS,
+      "app/routes/foo.jsx": LEAF_FILE_CONTENTS,
+      "app/routes/_pathless.jsx": LAYOUT_FILE_CONTENTS,
+      "app/routes/_pathless.foo.jsx": LEAF_FILE_CONTENTS,
+    });
+    expect(buildOutput).toContain(`⚠️ Route Path Collision: "/foo"`);
+    expect(buildOutput).toContain(`🟢 routes/_pathless.foo.jsx`);
+    expect(buildOutput).toContain(`⭕️️ routes/foo.jsx`);
+  });
+
+  test("detects path collisions across pathless layout routes", async () => {
+    let buildOutput = await setup({
+      "app/root.tsx": ROOT_FILE_CONTENTS,
+      "app/routes/_pathless.jsx": LAYOUT_FILE_CONTENTS,
+      "app/routes/_pathless.foo.jsx": LEAF_FILE_CONTENTS,
+      "app/routes/_pathless2.jsx": LAYOUT_FILE_CONTENTS,
+      "app/routes/_pathless2.foo.jsx": LEAF_FILE_CONTENTS,
+    });
+    expect(buildOutput).toContain(`⚠️ Route Path Collision: "/foo"`);
+    expect(buildOutput).toContain(`🟢 routes/_pathless2.foo.jsx`);
+    expect(buildOutput).toContain(`⭕️️ routes/_pathless.foo.jsx`);
+  });
+
+  test("detects path collisions inside multiple pathless layout routes", async () => {
+    let buildOutput = await setup({
+      "app/root.tsx": ROOT_FILE_CONTENTS,
+      "app/routes/foo.jsx": LEAF_FILE_CONTENTS,
+      "app/routes/_pathless.jsx": LAYOUT_FILE_CONTENTS,
+      "app/routes/_pathless._again.jsx": LAYOUT_FILE_CONTENTS,
+      "app/routes/_pathless._again.foo.jsx": LEAF_FILE_CONTENTS,
+    });
+    expect(buildOutput).toContain(`⚠️ Route Path Collision: "/foo"`);
+    expect(buildOutput).toContain(`🟢 routes/_pathless._again.foo.jsx`);
+    expect(buildOutput).toContain(`⭕️️ routes/foo.jsx`);
+  });
+
+  test("detects path collisions of index files inside pathless layouts", async () => {
+    let buildOutput = await setup({
+      "app/root.tsx": ROOT_FILE_CONTENTS,
+      "app/routes/_index.jsx": LEAF_FILE_CONTENTS,
+      "app/routes/_pathless.jsx": LAYOUT_FILE_CONTENTS,
+      "app/routes/_pathless._index.jsx": LEAF_FILE_CONTENTS,
+    });
+    expect(buildOutput).toContain(`⚠️ Route Path Collision: "/"`);
+    expect(buildOutput).toContain(`🟢 routes/_pathless._index.jsx`);
+    expect(buildOutput).toContain(`⭕️️ routes/_index.jsx`);
+  });
+
+  test("detects path collisions of index files across multiple pathless layouts", async () => {
+    let buildOutput = await setup({
+      "app/root.tsx": ROOT_FILE_CONTENTS,
+      "app/routes/nested._pathless.jsx": LAYOUT_FILE_CONTENTS,
+      "app/routes/nested._pathless._index.jsx": LEAF_FILE_CONTENTS,
+      "app/routes/nested._oops.jsx": LAYOUT_FILE_CONTENTS,
+      "app/routes/nested._oops._index.jsx": LEAF_FILE_CONTENTS,
+    });
+    expect(buildOutput).toContain(`⚠️ Route Path Collision: "/nested"`);
+    expect(buildOutput).toContain(`🟢 routes/nested._pathless._index.jsx`);
+    expect(buildOutput).toContain(`⭕️️ routes/nested._oops._index.jsx`);
+  });
+
+  test("detects path collisions of param routes inside pathless layouts", async () => {
+    let buildOutput = await setup({
+      "app/root.tsx": ROOT_FILE_CONTENTS,
+      "app/routes/$param.jsx": LEAF_FILE_CONTENTS,
+      "app/routes/_pathless.jsx": LAYOUT_FILE_CONTENTS,
+      "app/routes/_pathless.$param.jsx": LEAF_FILE_CONTENTS,
+    });
+    expect(buildOutput).toContain(`⚠️ Route Path Collision: "/:param"`);
+    expect(buildOutput).toContain(`🟢 routes/_pathless.$param.jsx`);
+    expect(buildOutput).toContain(`⭕️️ routes/$param.jsx`);
+  });
+});
diff --git a/packages/remix-dev/__tests__/create-test.ts b/packages/remix-dev/__tests__/create-test.ts
index 389197293..15d6d7fcc 100644
--- a/packages/remix-dev/__tests__/create-test.ts
+++ b/packages/remix-dev/__tests__/create-test.ts
@@ -12,6 +12,7 @@ import {
   errorBoundaryWarning,
   flatRoutesWarning,
   formMethodWarning,
+  headersWarning,
   metaWarning,
   serverModuleFormatWarning,
 } from "../config";
@@ -360,6 +361,8 @@ describe("the create command", () => {
         "\n" +
         metaWarning +
         "\n" +
+        headersWarning +
+        "\n" +
         serverModuleFormatWarning +
         "\n" +
         flatRoutesWarning +
diff --git a/packages/remix-dev/__tests__/flat-routes-test.ts b/packages/remix-dev/__tests__/flat-routes-test.ts
index 8929c5084..ec8fa4f9f 100644
--- a/packages/remix-dev/__tests__/flat-routes-test.ts
+++ b/packages/remix-dev/__tests__/flat-routes-test.ts
@@ -712,5 +712,155 @@ describe("flatRoutes", () => {
         getRoutePathConflictErrorMessage("/products/:pid", testFiles)
       );
     });
+
+    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-dev/__tests__/readConfig-test.ts b/packages/remix-dev/__tests__/readConfig-test.ts
index e1f795537..3267b8717 100644
--- a/packages/remix-dev/__tests__/readConfig-test.ts
+++ b/packages/remix-dev/__tests__/readConfig-test.ts
@@ -34,6 +34,7 @@ describe("readConfig", () => {
           unstable_postcss: expect.any(Boolean),
           unstable_tailwind: expect.any(Boolean),
           v2_errorBoundary: expect.any(Boolean),
+          v2_headers: expect.any(Boolean),
           v2_meta: expect.any(Boolean),
           v2_normalizeFormMethod: expect.any(Boolean),
           v2_routeConvention: expect.any(Boolean),
@@ -55,6 +56,7 @@ describe("readConfig", () => {
           "unstable_postcss": Any<Boolean>,
           "unstable_tailwind": Any<Boolean>,
           "v2_errorBoundary": Any<Boolean>,
+          "v2_headers": Any<Boolean>,
           "v2_meta": Any<Boolean>,
           "v2_normalizeFormMethod": Any<Boolean>,
           "v2_routeConvention": Any<Boolean>,
diff --git a/packages/remix-testing/create-remix-stub.tsx b/packages/remix-testing/create-remix-stub.tsx
index 1ef4b85ad..b7f0f4647 100644
--- a/packages/remix-testing/create-remix-stub.tsx
+++ b/packages/remix-testing/create-remix-stub.tsx
@@ -128,6 +128,7 @@ export function createRemixStub(
           unstable_postcss: false,
           unstable_tailwind: false,
           v2_errorBoundary: false,
+          v2_headers: false,
           v2_meta: false,
           v2_normalizeFormMethod: false,
           v2_routeConvention: false,