Repository: remix-run/remix
Pull request #6434 (2023-05-19 23:19:10)

fix(remix-react): fix typos in V2 warnings
Fix a missing space. Before this patch, the warnings print as:

```
⚠️ REMIX FUTURE CHANGE: `fetcher.type` will be removed in v2. Please use `fetcher.state`, `fetcher.formData`, and `fetcher.data` to achieve the same UX.For instructions on making this change see https://remix.run/docs/en/v1.15.0/pages/v2#usefetcher
```

Note the lack of space between "UX." and "For".

changeset-bot[bot]: ###  ⚠️  No Changeset found

Latest commit: c6bd60f7e4c217612a459e16e57cc37564a9c4fe

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. **If these changes should result in a version bump, you need to add a changeset.**

<details><summary>This PR includes no changesets</summary>

  When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

</details>

[Click here to learn what changesets are, and how to add one](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add a changeset to this PR](https://github.com/lpsinger/remix/new/typos-in-v2-warnings?filename=.changeset/silent-maps-provide.md&value=---%0A%22remix%22%3A%20patch%0A%22%40remix-run%2Freact%22%3A%20patch%0A---%0A%0AFix%20typos%20in%20v2%20warnings%0A)



Patch:
diff --git a/.changeset/bubbled-response-cookies.md b/.changeset/bubbled-response-cookies.md
new file mode 100644
index 000000000..0d863c64a
--- /dev/null
+++ b/.changeset/bubbled-response-cookies.md
@@ -0,0 +1,5 @@
+---
+"@remix-run/server-runtime": patch
+---
+
+Automatically include set-cookie headers from bubbled thrown responses
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
new file mode 100644
index 000000000..83458a2fc
--- /dev/null
+++ b/.changeset/lemon-beers-fetch.md
@@ -0,0 +1,25 @@
+---
+"@remix-run/server-runtime": minor
+"@remix-run/react": minor
+---
+
+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 };
+
+// BEFORE
+type Unsimplified = SerializeFrom<Data>;
+//   ^? SerializeObject<UndefinedToOptional<{ hello: string; when: Date }>>
+
+// AFTER
+type Simplified = SerializeFrom<Data>;
+//   ^? { hello: string; when: string }
+```
diff --git a/.changeset/ninety-hornets-brake.md b/.changeset/ninety-hornets-brake.md
new file mode 100644
index 000000000..1f91dfb43
--- /dev/null
+++ b/.changeset/ninety-hornets-brake.md
@@ -0,0 +1,89 @@
+---
+"@remix-run/dev": minor
+---
+
+built-in tls support
+
+New options:
+
+- `--tls-key` / `tlsKey`: TLS key
+- `--tls-cert` / `tlsCert`: TLS Certificate
+
+If both TLS options are set, `scheme` defaults to `https`
+
+## Example
+
+Install [mkcert](https://github.com/FiloSottile/mkcert) and create a local CA:
+
+```sh
+brew install mkcert
+mkcert -install
+```
+
+Then make sure you inform `node` about your CA certs:
+
+```sh
+export NODE_EXTRA_CA_CERTS="$(mkcert -CAROOT)/rootCA.pem"
+```
+
+👆 You'll probably want to put that env var in your scripts or `.bashrc`/`.zshrc`
+
+Now create `key.pem` and `cert.pem`:
+
+```sh
+mkcert -key-file key.pem -cert-file cert.pem localhost
+```
+
+See `mkcert` docs for more details.
+
+Finally, pass in the paths to the key and cert via flags:
+
+```sh
+remix dev --tls-key=key.pem --tls-cert=cert.pem
+```
+
+or via config:
+
+```js
+module.exports = {
+  future: {
+    unstable_dev: {
+      tlsKey: "key.pem",
+      tlsCert: "cert.pem",
+    },
+  },
+};
+```
+
+That's all that's needed to set up the Remix Dev Server with TLS.
+
+🚨 Make sure to update your app server for TLS as well.
+
+For example, with `express`:
+
+```ts
+import express from "express";
+import https from "node:https";
+import fs from "node:fs";
+
+let app = express();
+
+// ...code setting up your express app...
+
+let appServer = https.createServer(
+  {
+    key: fs.readFileSync("key.pem"),
+    cert: fs.readFileSync("cert.pem"),
+  },
+  app
+);
+
+appServer.listen(3000, () => {
+  console.log("Ready on https://localhost:3000");
+});
+```
+
+## Known limitations
+
+`remix-serve` does not yet support TLS.
+That means this only works for custom app server using the `-c` flag for now.
diff --git a/.changeset/rude-geese-remain.md b/.changeset/rude-geese-remain.md
new file mode 100644
index 000000000..a2b43df3f
--- /dev/null
+++ b/.changeset/rude-geese-remain.md
@@ -0,0 +1,5 @@
+---
+"@remix-run/dev": patch
+---
+
+better error message when `remix-serve` is not found
diff --git a/.changeset/serious-wolves-attend.md b/.changeset/serious-wolves-attend.md
new file mode 100644
index 000000000..f8f8b840c
--- /dev/null
+++ b/.changeset/serious-wolves-attend.md
@@ -0,0 +1,5 @@
+---
+"@remix-run/dev": patch
+---
+
+restore color for app server output
diff --git a/.changeset/stale-spoons-tie.md b/.changeset/stale-spoons-tie.md
new file mode 100644
index 000000000..27cdd94fc
--- /dev/null
+++ b/.changeset/stale-spoons-tie.md
@@ -0,0 +1,9 @@
+---
+"@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
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/.changeset/wicked-pandas-give.md b/.changeset/wicked-pandas-give.md
new file mode 100644
index 000000000..5d0f26f87
--- /dev/null
+++ b/.changeset/wicked-pandas-give.md
@@ -0,0 +1,23 @@
+---
+"@remix-run/dev": minor
+"@remix-run/react": minor
+"@remix-run/server-runtime": minor
+---
+
+Reuse dev server port for WebSocket (Live Reload,HMR,HDR)
+
+As a result the `webSocketPort`/`--websocket-port` option has been obsoleted.
+Additionally, scheme/host/port options for the dev server have been renamed.
+
+Available options are:
+
+| Option     | flag               | config           | default                           |
+| ---------- | ------------------ | ---------------- | --------------------------------- |
+| Command    | `-c` / `--command` | `command`        | `remix-serve <server build path>` |
+| Scheme     | `--scheme`         | `scheme`         | `http`                            |
+| Host       | `--host`           | `host`           | `localhost`                       |
+| Port       | `--port`           | `port`           | Dynamically chosen open port      |
+| No restart | `--no-restart`     | `restart: false` | `restart: true`                   |
+
+Note that scheme/host/port options are for the _dev server_, not your app server.
+You probably don't need to use scheme/host/port option if you aren't configuring networking (e.g. for Docker or SSL).
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/cli/commands.ts b/packages/remix-dev/cli/commands.ts
index 200a7a81d..0a9074923 100644
--- a/packages/remix-dev/cli/commands.ts
+++ b/packages/remix-dev/cli/commands.ts
@@ -25,7 +25,6 @@ import { TaskError } from "../codemod/utils/task";
 import { transpile as convertFileToJS } from "./useJavascript";
 import { warnOnce } from "../warnOnce";
 import type { Options } from "../compiler/options";
-import { getAppDependencies } from "../dependencies";
 
 export async function create({
   appTemplate,
@@ -177,13 +176,8 @@ export async function build(
     onWarning: warnOnce,
   };
   if (mode === "development" && config.future.unstable_dev) {
-    let dev = await resolveDevBuild(config);
-    options.devHttpOrigin = {
-      scheme: dev.httpScheme,
-      host: dev.httpHost,
-      port: dev.httpPort,
-    };
-    options.devWebSocketPort = dev.webSocketPort;
+    let origin = await resolveDevOrigin(config);
+    options.devOrigin = origin;
   }
 
   fse.emptyDirSync(config.assetsBuildDirectory);
@@ -216,20 +210,22 @@ export async function dev(
   remixRoot: string,
   flags: {
     debug?: boolean;
-    port?: number; // TODO: remove for v2
 
     // unstable_dev
     command?: string;
-    httpScheme?: string;
-    httpHost?: string;
-    httpPort?: number;
+    scheme?: string;
+    host?: string;
+    port?: number;
     restart?: boolean;
-    websocketPort?: number;
+    tlsKey?: string;
+    tlsCert?: string;
   } = {}
 ) {
   if (process.env.NODE_ENV && process.env.NODE_ENV !== "development") {
     console.warn(
-      `Forcing NODE_ENV to be 'development'. Was: ${process.env.NODE_ENV}`
+      `Forcing NODE_ENV to be 'development'. Was: ${JSON.stringify(
+        process.env.NODE_ENV
+      )}`
     );
   }
   process.env.NODE_ENV = "development";
@@ -471,51 +467,49 @@ let parseMode = (
 
 let findPort = async () => getPort({ port: makeRange(3001, 3100) });
 
-type DevBuildFlags = {
-  httpScheme: string;
-  httpHost: string;
-  httpPort: number;
-  webSocketPort: number;
+type DevOrigin = {
+  scheme: string;
+  host: string;
+  port: number;
 };
-let resolveDevBuild = async (
+let resolveDevOrigin = async (
   config: RemixConfig,
-  flags: Partial<DevBuildFlags> = {}
-): Promise<DevBuildFlags> => {
+  flags: Partial<DevOrigin> & {
+    tlsKey?: string;
+    tlsCert?: string;
+  } = {}
+): Promise<DevOrigin> => {
   let dev = config.future.unstable_dev;
   if (dev === false) throw Error("This should never happen");
 
   // prettier-ignore
-  let httpScheme =
-    flags.httpScheme ??
-    (dev === true ? undefined : dev.httpScheme) ??
-    "http";
+  let scheme =
+    flags.scheme ??
+    (dev === true ? undefined : dev.scheme) ??
+    (flags.tlsKey && flags.tlsCert) ? "https": "http";
   // prettier-ignore
-  let httpHost =
-    flags.httpHost ??
-    (dev === true ? undefined : dev.httpHost) ??
+  let host =
+    flags.host ??
+    (dev === true ? undefined : dev.host) ??
     "localhost";
   // prettier-ignore
-  let httpPort =
-    flags.httpPort ??
-    (dev === true ? undefined : dev.httpPort) ??
-    (await findPort());
-  // prettier-ignore
-  let webSocketPort =
-    flags.webSocketPort ??
-    (dev === true ? undefined : dev.webSocketPort) ??
+  let port =
+    flags.port ??
+    (dev === true ? undefined : dev.port) ??
     (await findPort());
 
   return {
-    httpScheme,
-    httpHost,
-    httpPort,
-    webSocketPort,
+    scheme,
+    host,
+    port,
   };
 };
 
-type DevServeFlags = DevBuildFlags & {
-  command: string;
+type DevServeFlags = DevOrigin & {
+  command?: string;
   restart: boolean;
+  tlsKey?: string;
+  tlsCert?: string;
 };
 let resolveDevServe = async (
   config: RemixConfig,
@@ -524,44 +518,26 @@ let resolveDevServe = async (
   let dev = config.future.unstable_dev;
   if (dev === false) throw Error("Cannot resolve dev options");
 
-  let { httpScheme, httpHost, httpPort, webSocketPort } = await resolveDevBuild(
-    config,
-    flags
-  );
+  let origin = await resolveDevOrigin(config, flags);
 
   // prettier-ignore
   let command =
     flags.command ??
     (dev === true ? undefined : dev.command)
-  if (!command) {
-    command = `remix-serve ${path.relative(
-      process.cwd(),
-      config.serverBuildPath
-    )}`;
-
-    let usingRemixAppServer =
-      getAppDependencies(config, true)["@remix-run/serve"] !== undefined;
-    if (!usingRemixAppServer) {
-      console.error(
-        [
-          `Remix dev server command defaulted to '${command}', but @remix-run/serve is not installed.`,
-          "If you are using another server, specify how to run it with `-c` or `--command` flag.",
-          "For example, `remix dev -c 'node ./server.js'`",
-        ].join("\n")
-      );
-      process.exit(1);
-    }
-  }
 
   let restart =
     flags.restart ?? (dev === true ? undefined : dev.restart) ?? true;
 
+  let tlsKey = flags.tlsKey ?? (dev === true ? undefined : dev.tlsKey);
+  if (tlsKey) tlsKey = path.resolve(tlsKey);
+  let tlsCert = flags.tlsCert ?? (dev === true ? undefined : dev.tlsCert);
+  if (tlsCert) tlsCert = path.resolve(tlsCert);
+
   return {
     command,
-    httpScheme,
-    httpHost,
-    httpPort,
-    webSocketPort,
+    ...origin,
     restart,
+    tlsKey,
+    tlsCert,
   };
 };
diff --git a/packages/remix-dev/cli/run.ts b/packages/remix-dev/cli/run.ts
index 524b1bdc8..abba2fdc9 100644
--- a/packages/remix-dev/cli/run.ts
+++ b/packages/remix-dev/cli/run.ts
@@ -44,11 +44,12 @@ ${colors.logoBlue("R")} ${colors.logoGreen("E")} ${colors.logoYellow(
 
     [unstable_dev]
     --command, -c       Command used to run your app server
-    --http-scheme       HTTP(S) scheme for the dev server. Default: http
-    --http-host         HTTP(S) host for the dev server. Default: localhost
-    --http-port         HTTP(S) port for the dev server. Default: any open port
+    --scheme            Scheme for the dev server. Default: http
+    --host              Host for the dev server. Default: localhost
+    --port              Port for the dev server. Default: any open port
     --no-restart        Do not restart the app server when rebuilds occur.
-    --websocket-port    WebSocket port for the dev server. Default: any open port
+    --tls-key           Path to TLS key (key.pem)
+    --tls-cert          Path to TLS certificate (cert.pem)
   \`init\` Options:
     --no-delete         Skip deleting the \`remix.init\` script
   \`routes\` Options:
@@ -170,8 +171,6 @@ export async function run(argv: string[] = process.argv.slice(2)) {
       "--interactive": Boolean,
       "--no-interactive": Boolean,
       "--json": Boolean,
-      "--port": Number,
-      "-p": "--port",
       "--remix-version": String,
       "--sourcemap": Boolean,
       "--template": String,
@@ -184,11 +183,13 @@ export async function run(argv: string[] = process.argv.slice(2)) {
       // dev server
       "--command": String,
       "-c": "--command",
-      "--http-scheme": String,
-      "--http-host": String,
-      "--http-port": Number,
+      "--scheme": String,
+      "--host": String,
+      "--port": Number,
+      "-p": "--port",
       "--no-restart": Boolean,
-      "--websocket-port": Number,
+      "--tls-key": String,
+      "--tls-cert": String,
     },
     {
       argv,
@@ -213,21 +214,13 @@ export async function run(argv: string[] = process.argv.slice(2)) {
     return;
   }
 
-  if (flags["http-scheme"]) {
-    flags.httpScheme = flags["http-scheme"];
-    delete flags["http-scheme"];
-  }
-  if (flags["http-host"]) {
-    flags.httpHost = flags["http-host"];
-    delete flags["http-host"];
-  }
-  if (flags["http-port"]) {
-    flags.httpPort = flags["http-port"];
-    delete flags["http-port"];
+  if (flags["tls-key"]) {
+    flags.tlsKey = flags["tls-key"];
+    delete flags["tls-key"];
   }
-  if (flags["websocket-port"]) {
-    flags.webSocketPort = flags["websocket-port"];
-    delete flags["websocket-port"];
+  if (flags["tls-cert"]) {
+    flags.tlsCert = flags["tls-cert"];
+    delete flags["tls-cert"];
   }
 
   if (args["--no-delete"]) {
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/js/plugins/routes_unstable.ts b/packages/remix-dev/compiler/js/plugins/routes_unstable.ts
deleted file mode 100644
index 7640a0dd9..000000000
--- a/packages/remix-dev/compiler/js/plugins/routes_unstable.ts
+++ /dev/null
@@ -1,171 +0,0 @@
-import * as fs from "node:fs";
-import * as path from "node:path";
-import type esbuild from "esbuild";
-import generate from "@babel/generator";
-
-import { routeModuleExts } from "../../../config/routesConvention";
-import * as Transform from "../../../transform";
-import { getLoaderForFile } from "../../utils/loaders";
-import { applyHMR } from "./hmr";
-import type { Context } from "../../context";
-import { processMDX } from "../../plugins/mdx";
-
-const serverOnlyExports = new Set(["action", "loader"]);
-
-let removeServerExports = () =>
-  Transform.create(({ types: t }) => {
-    return {
-      visitor: {
-        ExportNamedDeclaration: (path) => {
-          let { node } = path;
-          if (node.source) {
-            let specifiers = node.specifiers.filter(({ exported }) => {
-              let name = t.isIdentifier(exported)
-                ? exported.name
-                : exported.value;
-              return !serverOnlyExports.has(name);
-            });
-            if (specifiers.length === node.specifiers.length) return;
-            if (specifiers.length === 0) return path.remove();
-            path.replaceWith(
-              t.exportNamedDeclaration(
-                node.declaration,
-                specifiers,
-                node.source
-              )
-            );
-          }
-          if (t.isFunctionDeclaration(node.declaration)) {
-            let name = node.declaration.id?.name;
-            if (!name) return;
-            if (serverOnlyExports.has(name)) return path.remove();
-          }
-          if (t.isVariableDeclaration(node.declaration)) {
-            let declarations = node.declaration.declarations.filter((d) => {
-              let name = t.isIdentifier(d.id) ? d.id.name : undefined;
-              if (!name) return false;
-              return !serverOnlyExports.has(name);
-            });
-            if (declarations.length === 0) return path.remove();
-            if (declarations.length === node.declaration.declarations.length)
-              return;
-            path.replaceWith(
-              t.variableDeclaration(node.declaration.kind, declarations)
-            );
-          }
-        },
-      },
-    };
-  });
-
-/**
- * This plugin loads route modules for the browser build, using module shims
- * that re-export only the route module exports that are safe for the browser.
- */
-export function browserRouteModulesPlugin(
-  { config, options }: Context,
-  routeModulePaths: Map<string, string>
-): esbuild.Plugin {
-  return {
-    name: "browser-route-modules",
-    async setup(build) {
-      let [xdm, { default: remarkFrontmatter }] = await Promise.all([
-        import("xdm"),
-        import("remark-frontmatter") as any,
-      ]);
-
-      build.onResolve({ filter: /.*/ }, (args) => {
-        // We have to map all imports from route modules back to the virtual
-        // module in the graph otherwise we will be duplicating portions of
-        // route modules across the build.
-        let routeModulePath = routeModulePaths.get(args.path);
-        if (!routeModulePath && args.resolveDir && args.path.startsWith(".")) {
-          let lookup = resolvePath(path.join(args.resolveDir, args.path));
-          routeModulePath = routeModulePaths.get(lookup);
-        }
-        if (!routeModulePath) return;
-        return {
-          path: routeModulePath,
-          namespace: "browser-route-module",
-        };
-      });
-
-      let cache = new Map();
-
-      build.onLoad(
-        { filter: /.*/, namespace: "browser-route-module" },
-        async (args) => {
-          let file = args.path;
-          let routeFile = path.resolve(config.appDirectory, file);
-
-          if (/\.mdx?$/.test(file)) {
-            let mdxResult = await processMDX(
-              xdm,
-              remarkFrontmatter,
-              config,
-              args.path,
-              routeFile
-            );
-            if (!mdxResult.contents || mdxResult.errors?.length) {
-              return mdxResult;
-            }
-
-            let transform = removeServerExports();
-            mdxResult.contents = transform(
-              mdxResult.contents,
-              // Trick babel into allowing JSX syntax.
-              args.path + ".jsx"
-            );
-            return mdxResult;
-          }
-
-          let sourceCode = fs.readFileSync(routeFile, "utf8");
-
-          let value = cache.get(file);
-          if (!value || value.sourceCode !== sourceCode) {
-            let transform = removeServerExports();
-            let contents = transform(sourceCode, routeFile);
-
-            if (options.mode === "development" && config.future.unstable_dev) {
-              contents = await applyHMR(
-                contents,
-                {
-                  ...args,
-                  path: routeFile,
-                },
-                config,
-                !!build.initialOptions.sourcemap
-              );
-            }
-            value = {
-              sourceCode,
-              output: {
-                contents,
-                loader: getLoaderForFile(routeFile),
-                resolveDir: path.dirname(routeFile),
-              },
-            };
-            cache.set(file, value);
-          }
-
-          return value.output;
-        }
-      );
-    },
-  };
-}
-
-function resolvePath(path: string) {
-  if (fs.existsSync(path) && fs.statSync(path).isFile()) {
-    return path;
-  }
-
-  for (let ext of routeModuleExts) {
-    let withExt = path + ext;
-    if (fs.existsSync(withExt) && fs.statSync(withExt).isFile()) {
-      return withExt;
-    }
-  }
-
-  return path;
-}
diff --git a/packages/remix-dev/compiler/options.ts b/packages/remix-dev/compiler/options.ts
index 165b89cba..d128116d7 100644
--- a/packages/remix-dev/compiler/options.ts
+++ b/packages/remix-dev/compiler/options.ts
@@ -6,10 +6,9 @@ export type Options = {
   onWarning?: (message: string, key: string) => void;
 
   // TODO: required in v2
-  devHttpOrigin?: {
+  devOrigin?: {
     scheme: string;
     host: string;
     port: number;
   };
-  devWebSocketPort?: number;
 };
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/server/compiler.ts b/packages/remix-dev/compiler/server/compiler.ts
index 3dd99e80f..47af42302 100644
--- a/packages/remix-dev/compiler/server/compiler.ts
+++ b/packages/remix-dev/compiler/server/compiler.ts
@@ -103,7 +103,7 @@ const createEsbuildConfig = (
         ctx.config.devServerPort
       ),
       "process.env.REMIX_DEV_HTTP_ORIGIN": JSON.stringify(
-        ctx.options.devHttpOrigin ?? "" // TODO: remove nullish check in v2
+        ctx.options.devOrigin ?? "" // TODO: remove nullish check in v2
       ),
     },
     jsx: "automatic",
diff --git a/packages/remix-dev/compiler/server/plugins/entry.ts b/packages/remix-dev/compiler/server/plugins/entry.ts
index 3d64193e3..4966dd3ad 100644
--- a/packages/remix-dev/compiler/server/plugins/entry.ts
+++ b/packages/remix-dev/compiler/server/plugins/entry.ts
@@ -51,9 +51,9 @@ ${Object.keys(config.routes)
   export const publicPath = ${JSON.stringify(config.publicPath)};
   export const entry = { module: entryServer };
   ${
-    options.devWebSocketPort
+    options.devOrigin
       ? `export const dev = ${JSON.stringify({
-          websocketPort: options.devWebSocketPort,
+          port: options.devOrigin.port,
         })}`
       : ""
   }
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..68e91d486 100644
--- a/packages/remix-dev/config.ts
+++ b/packages/remix-dev/config.ts
@@ -37,15 +37,13 @@ export type ServerModuleFormat = "esm" | "cjs";
 export type ServerPlatform = "node" | "neutral";
 
 type Dev = {
-  port?: number; // TODO: remove in v2
-
   command?: string;
-  httpScheme?: string;
-  httpHost?: string;
-  httpPort?: number;
-  webSocketPort?: number;
+  scheme?: string;
+  host?: string;
+  port?: number;
   restart?: boolean;
-  publicDirectory?: string;
+  tlsKey?: string;
+  tlsCert?: string;
 };
 
 interface FutureConfig {
@@ -55,6 +53,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 +441,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 +741,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 +932,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..1bbf1de6a 100644
--- a/packages/remix-dev/devServer_unstable/index.ts
+++ b/packages/remix-dev/devServer_unstable/index.ts
@@ -1,5 +1,7 @@
 import * as path from "node:path";
 import * as stream from "node:stream";
+import * as http from "node:http";
+import * as https from "node:https";
 import fs from "fs-extra";
 import prettyMs from "pretty-ms";
 import execa from "execa";
@@ -15,6 +17,9 @@ 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";
+import invariant from "../invariant";
 
 type Origin = {
   scheme: string;
@@ -38,46 +43,102 @@ let detectBin = async (): Promise<string> => {
 export let serve = async (
   initialConfig: RemixConfig,
   options: {
-    command: string;
-    httpScheme: string;
-    httpHost: string;
-    httpPort: number;
-    webSocketPort: number;
+    command?: string;
+    scheme: string;
+    host: string;
+    port: number;
     restart: boolean;
+    tlsKey?: string;
+    tlsCert?: string;
   }
 ) => {
   await loadEnv(initialConfig.rootDirectory);
-  let websocket = Socket.serve({ port: options.webSocketPort });
-  let httpOrigin: Origin = {
-    scheme: options.httpScheme,
-    host: options.httpHost,
-    port: options.httpPort,
-  };
-
   let state: {
     appServer?: execa.ExecaChildProcess;
     manifest?: Manifest;
     prevManifest?: Manifest;
     appReady?: Channel.Type<void>;
-    hdr?: Promise<Record<string, string>>;
+    loaderChanges?: Promise<Result<Record<string, string>>>;
     prevLoaderHashes?: Record<string, string>;
   } = {};
 
-  let bin = await detectBin();
-  let startAppServer = (command: string) => {
-    console.log(`> ${command}`);
-    let newAppServer = execa.command(command, {
-      stdio: "pipe",
-      env: {
-        NODE_ENV: "development",
-        PATH:
-          bin + (process.platform === "win32" ? ";" : ":") + process.env.PATH,
-        REMIX_DEV_HTTP_ORIGIN: stringifyOrigin(httpOrigin),
-      },
-      // https://github.com/sindresorhus/execa/issues/433
-      windowsHide: false,
+  let app = express()
+    // handle `broadcastDevReady` messages
+    .use(express.json())
+    .post("/ping", (req, res) => {
+      let { buildHash } = req.body;
+      if (typeof buildHash !== "string") {
+        console.warn(`Unrecognized payload: ${req.body}`);
+        res.sendStatus(400);
+      }
+      if (buildHash === state.manifest?.version) {
+        state.appReady?.ok();
+      }
+      res.sendStatus(200);
     });
 
+  let server =
+    options.tlsKey && options.tlsCert
+      ? https.createServer(
+          {
+            key: fs.readFileSync(options.tlsKey),
+            cert: fs.readFileSync(options.tlsCert),
+          },
+          app
+        )
+      : http.createServer(app);
+  let websocket = Socket.serve(server);
+
+  let origin: Origin = {
+    scheme: options.scheme,
+    host: options.host,
+    port: options.port,
+  };
+
+  let bin = await detectBin();
+  let startAppServer = (command?: string) => {
+    let cmd =
+      command ??
+      `remix-serve ${path.relative(
+        process.cwd(),
+        initialConfig.serverBuildPath
+      )}`;
+    console.log(`> ${cmd}`);
+    let newAppServer = execa
+      .command(cmd, {
+        stdio: "pipe",
+        env: {
+          NODE_ENV: "development",
+          PATH:
+            bin + (process.platform === "win32" ? ";" : ":") + process.env.PATH,
+          REMIX_DEV_HTTP_ORIGIN: stringifyOrigin(origin),
+          FORCE_COLOR: process.env.NO_COLOR === undefined ? "1" : "0",
+        },
+        // https://github.com/sindresorhus/execa/issues/433
+        windowsHide: false,
+      })
+      .on("error", (e) => {
+        // patch execa error types
+        invariant("errno" in e && typeof e.errno === "number", "errno missing");
+        invariant("code" in e && typeof e.code === "string", "code missing");
+        invariant("path" in e && typeof e.path === "string", "path missing");
+
+        if (command === undefined) {
+          console.error(
+            [
+              "",
+              `┏ [error] command not found: ${e.path}`,
+              `┃ \`remix dev\` did not receive \`--command\` nor \`-c\`, defaulting to \`${cmd}\`.`,
+              "┃ You probably meant to use `-c` for your app server command.",
+              "┗ For example: `remix dev -c 'node ./server.js'`",
+              "",
+            ].join("\n")
+          );
+          process.exit(1);
+        }
+        throw e;
+      });
+
     if (newAppServer.stdin)
       process.stdin.pipe(newAppServer.stdin, { end: true });
     if (newAppServer.stderr)
@@ -116,63 +177,72 @@ export let serve = async (
         mode: "development",
         sourcemap: true,
         onWarning: warnOnce,
-        devHttpOrigin: httpOrigin,
-        devWebSocketPort: options.webSocketPort,
+        devOrigin: origin,
       },
     },
     {
       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 (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)}`),
@@ -183,28 +253,14 @@ export let serve = async (
     }
   );
 
-  let httpServer = express()
-    // handle `broadcastDevReady` messages
-    .use(express.json())
-    .post("/ping", (req, res) => {
-      let { buildHash } = req.body;
-      if (typeof buildHash !== "string") {
-        console.warn(`Unrecognized payload: ${req.body}`);
-        res.sendStatus(400);
-      }
-      if (buildHash === state.manifest?.version) {
-        state.appReady?.ok();
-      }
-      res.sendStatus(200);
-    })
-    .listen(httpOrigin.port, () => {
-      console.log("Remix dev server ready");
-    });
+  server.listen(origin.port, () => {
+    console.log("Remix dev server ready");
+  });
 
   return new Promise(() => {}).finally(async () => {
     await kill(state.appServer);
     websocket.close();
-    httpServer.close();
+    server.close();
     await dispose();
   });
 };
diff --git a/packages/remix-dev/devServer_unstable/socket.ts b/packages/remix-dev/devServer_unstable/socket.ts
index 83ea95fb5..a66ca95a0 100644
--- a/packages/remix-dev/devServer_unstable/socket.ts
+++ b/packages/remix-dev/devServer_unstable/socket.ts
@@ -1,4 +1,5 @@
 import WebSocket from "ws";
+import type { Server as HTTPServer } from "http";
 
 import { type Manifest } from "../manifest";
 import type * as HMR from "./hmr";
@@ -14,8 +15,8 @@ type Message =
 
 type Broadcast = (message: Message) => void;
 
-export let serve = (options: { port: number }) => {
-  let wss = new WebSocket.Server({ port: options.port });
+export let serve = (server: HTTPServer) => {
+  let wss = new WebSocket.Server({ server });
 
   let broadcast: Broadcast = (message) => {
     wss.clients.forEach((client) => {
diff --git a/packages/remix-react/browser.tsx b/packages/remix-react/browser.tsx
index c81a6ed80..821731a3d 100644
--- a/packages/remix-react/browser.tsx
+++ b/packages/remix-react/browser.tsx
@@ -24,7 +24,7 @@ declare global {
     // The number of active deferred keys rendered on the server
     a?: number;
     dev?: {
-      websocketPort?: number;
+      port?: number;
       hmrRuntime?: string;
     };
   };
diff --git a/packages/remix-react/components.tsx b/packages/remix-react/components.tsx
index 89fa1b7e9..6e364e1e7 100644
--- a/packages/remix-react/components.tsx
+++ b/packages/remix-react/components.tsx
@@ -364,19 +364,19 @@ export function composeEventHandlers<
 
 let linksWarning =
   "⚠️ REMIX FUTURE CHANGE: The behavior of links `imagesizes` and `imagesrcset` will be changing in v2. " +
-  "Only the React camel case versions will be valid. Please change to `imageSizes` and `imageSrcSet`." +
+  "Only the React camel case versions will be valid. Please change to `imageSizes` and `imageSrcSet`. " +
   "For instructions on making this change see " +
   "https://remix.run/docs/en/v1.15.0/pages/v2#links-imagesizes-and-imagesrcset";
 
 let useTransitionWarning =
   "⚠️ REMIX FUTURE CHANGE: `useTransition` will be removed in v2 in favor of `useNavigation`. " +
-  "You can prepare for this change at your convenience by updating to `useNavigation`." +
+  "You can prepare for this change at your convenience by updating to `useNavigation`. " +
   "For instructions on making this change see " +
   "https://remix.run/docs/en/v1.15.0/pages/v2#usetransition";
 
 let fetcherTypeWarning =
   "⚠️ REMIX FUTURE CHANGE: `fetcher.type` will be removed in v2. " +
-  "Please use `fetcher.state`, `fetcher.formData`, and `fetcher.data` to achieve the same UX." +
+  "Please use `fetcher.state`, `fetcher.formData`, and `fetcher.data` to achieve the same UX. " +
   "For instructions on making this change see " +
   "https://remix.run/docs/en/v1.15.0/pages/v2#usefetcher";
 
@@ -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}
     </>
   );
 }
@@ -1719,7 +1714,7 @@ export const LiveReload =
     ? () => null
     : function LiveReload({
         // TODO: remove REMIX_DEV_SERVER_WS_PORT in v2
-        port = Number(process.env.REMIX_DEV_SERVER_WS_PORT || 8002),
+        port,
         timeoutMs = 1000,
         nonce = undefined,
       }: {
@@ -1737,9 +1732,9 @@ export const LiveReload =
                 function remixLiveReloadConnect(config) {
                   let protocol = location.protocol === "https:" ? "wss:" : "ws:";
                   let host = location.hostname;
-                  let port = (window.__remixContext && window.__remixContext.dev && window.__remixContext.dev.websocketPort) || ${String(
-                    port
-                  )};
+                  let port = ${port} || (window.__remixContext && window.__remixContext.dev && window.__remixContext.dev.port) || ${Number(
+                process.env.REMIX_DEV_SERVER_WS_PORT || 8002
+              )};
                   let socketPath = protocol + "//" + host + ":" + port + "/socket";
                   let ws = new WebSocket(socketPath);
                   ws.onmessage = async (message) => {
diff --git a/packages/remix-react/entry.ts b/packages/remix-react/entry.ts
index fde03eca1..cfef8123a 100644
--- a/packages/remix-react/entry.ts
+++ b/packages/remix-react/entry.ts
@@ -10,7 +10,7 @@ export interface RemixContextObject {
   serverHandoffString?: string;
   future: FutureConfig;
   abortDelay?: number;
-  dev?: { websocketPort: number };
+  dev?: { port: number };
 }
 
 // Additional React-Router information needed at runtime, but not hydrated
@@ -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/build.ts b/packages/remix-server-runtime/build.ts
index 4e1b831b1..dfa5e2810 100644
--- a/packages/remix-server-runtime/build.ts
+++ b/packages/remix-server-runtime/build.ts
@@ -15,7 +15,7 @@ export interface ServerBuild {
   publicPath: string;
   assetsBuildDirectory: string;
   future: FutureConfig;
-  dev?: { websocketPort: number };
+  dev?: { port: number };
 }
 
 export interface HandleDocumentRequestFunction {
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..12bd2d66d 100644
--- a/packages/remix-server-runtime/headers.ts
+++ b/packages/remix-server-runtime/headers.ts
@@ -37,6 +37,31 @@ export function getDocumentHeadersRR(
     let routeModule = build.routes[id].module;
     let loaderHeaders = context.loaderHeaders[id] || new Headers();
     let actionHeaders = context.actionHeaders[id] || new Headers();
+
+    // Only expose errorHeaders to the leaf headers() function to
+    // avoid duplication via parentHeaders
+    let includeErrorHeaders =
+      errorHeaders != undefined && idx === matches.length - 1;
+    // Only prepend cookies from errorHeaders at the leaf renderable route
+    // when it's not the same as loaderHeaders/actionHeaders to avoid
+    // duplicate cookies
+    let includeErrorCookies =
+      includeErrorHeaders &&
+      errorHeaders !== loaderHeaders &&
+      errorHeaders !== actionHeaders;
+
+    // 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 = new Headers(parentHeaders);
+      if (includeErrorCookies) {
+        prependCookies(errorHeaders!, headers);
+      }
+      prependCookies(actionHeaders, headers);
+      prependCookies(loaderHeaders, headers);
+      return headers;
+    }
+
     let headers = new Headers(
       routeModule.headers
         ? typeof routeModule.headers === "function"
@@ -44,17 +69,17 @@ export function getDocumentHeadersRR(
               loaderHeaders,
               parentHeaders,
               actionHeaders,
-              // Only expose errorHeaders to the leaf headers() function to
-              // avoid duplication via parentHeaders
-              errorHeaders:
-                idx === matches.length - 1 ? errorHeaders : undefined,
+              errorHeaders: includeErrorHeaders ? errorHeaders : undefined,
             })
           : routeModule.headers
         : undefined
     );
 
-    // Automatically preserve Set-Cookie headers that were set either by the
-    // loader or by a parent route.
+    // Automatically preserve Set-Cookie headers from bubbled responses,
+    // loaders, errors, and parent routes
+    if (includeErrorCookies) {
+      prependCookies(errorHeaders!, headers);
+    }
     prependCookies(actionHeaders, headers);
     prependCookies(loaderHeaders, headers);
     prependCookies(parentHeaders, headers);
diff --git a/packages/remix-server-runtime/serialize.ts b/packages/remix-server-runtime/serialize.ts
index d11cbeb16..7e2f9c98f 100644
--- a/packages/remix-server-runtime/serialize.ts
+++ b/packages/remix-server-runtime/serialize.ts
@@ -1,6 +1,9 @@
 import type { AppData } from "./data";
 import type { TypedDeferredData, TypedResponse } from "./responses";
 
+// force Typescript to simplify the type
+type Pretty<T> = { [K in keyof T]: T[K] } & {};
+
 type JsonPrimitive =
   | string
   | number
@@ -18,7 +21,7 @@ type NonJsonPrimitive = undefined | Function | symbol;
 type IsAny<T> = 0 extends 1 & T ? true : false;
 
 // prettier-ignore
-type Serialize<T> =
+type Serialize<T> = Pretty<
   IsAny<T> extends true ? any :
   T extends TypedDeferredData<infer U> ? SerializeDeferred<U> :
   T extends JsonPrimitive ? T :
@@ -28,7 +31,8 @@ type Serialize<T> =
   T extends [unknown, ...unknown[]] ? SerializeTuple<T> :
   T extends ReadonlyArray<infer U> ? (U extends NonJsonPrimitive ? null : Serialize<U>)[] :
   T extends object ? SerializeObject<UndefinedToOptional<T>> :
-  never;
+  never
+>;
 
 /** JSON serialize [tuples](https://www.typescriptlang.org/docs/handbook/2/objects.html#tuple-types) */
 type SerializeTuple<T extends [unknown, ...unknown[]]> = {
@@ -42,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";
 
diff --git a/packages/remix-server-runtime/serverHandoff.ts b/packages/remix-server-runtime/serverHandoff.ts
index edc4d4b30..0ea04f244 100644
--- a/packages/remix-server-runtime/serverHandoff.ts
+++ b/packages/remix-server-runtime/serverHandoff.ts
@@ -20,7 +20,7 @@ export function createServerHandoffString<T>(serverHandoff: {
   // we'd end up including duplicate info
   state: ValidateShape<T, HydrationState>;
   future: FutureConfig;
-  dev?: { websocketPort: number };
+  dev?: { port: number };
 }): string {
   // Uses faster alternative of jsesc to escape data returned from the loaders.
   // This string is inserted directly into the HTML in the `<Scripts>` element.
diff --git a/scripts/playground/template/.gitignore b/scripts/playground/template/.gitignore
index 9c2162006..8e3c3201b 100644
--- a/scripts/playground/template/.gitignore
+++ b/scripts/playground/template/.gitignore
@@ -2,5 +2,3 @@
 
 **/prisma/data.db
 **/prisma/data.db-journal
-
-**/app/styles/tailwind.css
diff --git a/scripts/playground/template/app/root.tsx b/scripts/playground/template/app/root.tsx
index a9d74ef8a..a9df87e5c 100644
--- a/scripts/playground/template/app/root.tsx
+++ b/scripts/playground/template/app/root.tsx
@@ -8,12 +8,16 @@ import {
   Scripts,
   ScrollRestoration,
 } from "@remix-run/react";
+import { cssBundleHref } from "@remix-run/css-bundle";
 
 import tailwindStylesheetUrl from "./styles/tailwind.css";
 import { getUser } from "./session.server";
 
 export const links: LinksFunction = () => {
-  return [{ rel: "stylesheet", href: tailwindStylesheetUrl }];
+  return [
+    { rel: "stylesheet", href: tailwindStylesheetUrl },
+    ...(cssBundleHref ? [{ rel: "stylesheet", href: cssBundleHref }] : []),
+  ];
 };
 
 export async function loader({ request }: LoaderArgs) {
diff --git a/scripts/playground/template/app/styles/tailwind.css b/scripts/playground/template/app/styles/tailwind.css
new file mode 100644
index 000000000..b5c61c956
--- /dev/null
+++ b/scripts/playground/template/app/styles/tailwind.css
@@ -0,0 +1,3 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
diff --git a/scripts/playground/template/package.json b/scripts/playground/template/package.json
index 99c88c029..619bb7db4 100644
--- a/scripts/playground/template/package.json
+++ b/scripts/playground/template/package.json
@@ -3,14 +3,10 @@
   "private": true,
   "sideEffects": false,
   "scripts": {
-    "build": "run-s \"build:*\"",
-    "build:css": "npm run generate:css -- --minify",
-    "build:remix": "node ./node_modules/@remix-run/dev/dist/cli build",
+    "build": "node ./node_modules/@remix-run/dev/dist/cli build",
     "dev": "run-p \"dev:*\"",
-    "dev:css": "cross-env NODE_ENV=development npm run generate:css -- --watch",
     "dev:remix": "cross-env NODE_ENV=development node ./node_modules/@remix-run/dev/dist/cli watch",
     "dev:server": "cross-env NODE_ENV=development node --inspect --require ./node_modules/dotenv/config ./server.js",
-    "generate:css": "tailwindcss -o ./app/styles/tailwind.css",
     "setup": "prisma generate && prisma migrate deploy && prisma db seed",
     "start": "cross-env NODE_ENV=production node --inspect --require ./node_modules/dotenv/config ./server.js"
   },
@@ -21,6 +17,7 @@
   ],
   "dependencies": {
     "@prisma/client": "^3.15.2",
+    "@remix-run/css-bundle": "*",
     "@remix-run/express": "*",
     "@remix-run/node": "*",
     "@remix-run/react": "*",
diff --git a/scripts/playground/template/remix.config.js b/scripts/playground/template/remix.config.js
index d88ac0096..4cf3e14cb 100644
--- a/scripts/playground/template/remix.config.js
+++ b/scripts/playground/template/remix.config.js
@@ -2,6 +2,7 @@
 module.exports = {
   cacheDirectory: "./node_modules/.cache/remix",
   ignoredRouteFiles: ["**/.*", "**/*.css", "**/*.test.{js,jsx,ts,tsx}"],
+  tailwind: true,
   future: {
     v2_meta: true,
   },


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..9b1fed79a 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/> }
@@ -171,6 +160,43 @@ test.describe("headers export", () => {
 
           export default function Component() { return <div/> }
         `,
+
+        "app/routes/cookie.jsx": js`
+          import { json } from "@remix-run/server-runtime";
+          import { Outlet } from "@remix-run/react";
+
+          export function loader({ request }) {
+            if (new URL(request.url).searchParams.has("parent-throw")) {
+              throw json(null, { headers: { "Set-Cookie": "parent-thrown-cookie=true" } });
+            }
+            return null
+          };
+
+          export default function Parent() {
+            return <Outlet />;
+          }
+
+          export function ErrorBoundary() {
+            return <h1>Caught!</h1>;
+          }
+        `,
+
+        "app/routes/cookie.child.jsx": js`
+          import { json } from "@remix-run/node";
+
+          export function loader({ request }) {
+            if (new URL(request.url).searchParams.has("throw")) {
+              throw json(null, { headers: { "Set-Cookie": "thrown-cookie=true" } });
+            }
+            return json(null, {
+              headers: { "Set-Cookie": "normal-cookie=true" },
+            });
+          };
+
+          export default function Child() {
+            return <p>Child</p>;
+          }
+        `,
       },
     });
   });
@@ -256,7 +282,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 +309,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"],
       ])
     );
@@ -364,4 +387,182 @@ test.describe("headers export", () => {
       ])
     );
   });
+
+  test("automatically includes cookie headers from normal responses", async () => {
+    let response = await appFixture.requestDocument("/cookie/child");
+    expect(JSON.stringify(Array.from(response.headers.entries()))).toBe(
+      JSON.stringify([
+        ["content-type", "text/html"],
+        ["set-cookie", "normal-cookie=true"],
+      ])
+    );
+  });
+
+  test("automatically includes cookie headers from thrown responses", async () => {
+    let response = await appFixture.requestDocument("/cookie/child?throw");
+    expect(JSON.stringify(Array.from(response.headers.entries()))).toBe(
+      JSON.stringify([
+        ["content-type", "text/html"],
+        ["set-cookie", "thrown-cookie=true"],
+      ])
+    );
+  });
+
+  test("does not duplicate thrown cookie headers from boundary route", async () => {
+    let response = await appFixture.requestDocument("/cookie?parent-throw");
+    expect(JSON.stringify(Array.from(response.headers.entries()))).toBe(
+      JSON.stringify([
+        ["content-type", "text/html"],
+        ["set-cookie", "parent-thrown-cookie=true"],
+      ])
+    );
+  });
+});
+
+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/> }
+        `,
+
+        "app/routes/cookie.jsx": js`
+          import { json } from "@remix-run/server-runtime";
+          import { Outlet } from "@remix-run/react";
+
+          export function loader({ request }) {
+            if (new URL(request.url).searchParams.has("parent-throw")) {
+              throw json(null, { headers: { "Set-Cookie": "parent-thrown-cookie=true" } });
+            }
+            return null
+          };
+
+          export default function Parent() {
+            return <Outlet />;
+          }
+
+          export function ErrorBoundary() {
+            return <h1>Caught!</h1>;
+          }
+        `,
+
+        "app/routes/cookie.child.jsx": js`
+          import { json } from "@remix-run/node";
+
+          export function loader({ request }) {
+            if (new URL(request.url).searchParams.has("throw")) {
+              throw json(null, { headers: { "Set-Cookie": "thrown-cookie=true" } });
+            }
+            return json(null, {
+              headers: { "Set-Cookie": "normal-cookie=true" },
+            });
+          };
+
+          export default function Child() {
+            return <p>Child</p>;
+          }
+        `,
+      },
+    });
+  });
+
+  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"]])
+    );
+  });
+
+  test("automatically includes cookie headers from normal responses", async () => {
+    let response = await appFixture.requestDocument("/cookie/child");
+    expect(JSON.stringify(Array.from(response.headers.entries()))).toBe(
+      JSON.stringify([
+        ["content-type", "text/html"],
+        ["set-cookie", "normal-cookie=true"],
+      ])
+    );
+  });
+
+  test("automatically includes cookie headers from thrown responses", async () => {
+    let response = await appFixture.requestDocument("/cookie/child?throw");
+    expect(JSON.stringify(Array.from(response.headers.entries()))).toBe(
+      JSON.stringify([
+        ["content-type", "text/html"],
+        ["set-cookie", "thrown-cookie=true"],
+      ])
+    );
+  });
+
+  test("does not duplicate thrown cookie headers from boundary route", async () => {
+    let response = await appFixture.requestDocument("/cookie?parent-throw");
+    expect(JSON.stringify(Array.from(response.headers.entries()))).toBe(
+      JSON.stringify([
+        ["content-type", "text/html"],
+        ["set-cookie", "parent-thrown-cookie=true"],
+      ])
+    );
+  });
 });
diff --git a/integration/hmr-log-test.ts b/integration/hmr-log-test.ts
index cdb02556e..2c3b62ff5 100644
--- a/integration/hmr-log-test.ts
+++ b/integration/hmr-log-test.ts
@@ -9,11 +9,7 @@ import { createFixtureProject, css, js, json } from "./helpers/create-fixture";
 
 test.setTimeout(120_000);
 
-let fixture = (options: {
-  appServerPort: number;
-  httpPort: number;
-  webSocketPort: number;
-}) => ({
+let fixture = (options: { appPort: number; devPort: number }) => ({
   files: {
     "remix.config.js": js`
       module.exports = {
@@ -21,13 +17,13 @@ let fixture = (options: {
         tailwind: true,
         future: {
           unstable_dev: {
-            httpPort: ${options.httpPort},
-            webSocketPort: ${options.webSocketPort},
+            port: ${options.devPort},
           },
           v2_routeConvention: true,
           v2_errorBoundary: true,
           v2_normalizeFormMethod: true,
           v2_meta: true,
+          v2_headers: true,
         },
       };
     `,
@@ -79,7 +75,7 @@ let fixture = (options: {
         })
       );
 
-      let port = ${options.appServerPort};
+      let port = ${options.appPort};
       app.listen(port, () => {
         let build = require(BUILD_DIR);
         console.log('✅ app ready: http://localhost:' + port);
@@ -234,12 +230,28 @@ let bufferize = (stream: Readable): (() => string) => {
   return () => buffer;
 };
 
+let logConsoleError = (error: Error) => {
+  console.error(`[console] ${error.name}: ${error.message}`);
+};
+
+let expectConsoleError = (
+  isExpected: (error: Error) => boolean,
+  unexpected = logConsoleError
+) => {
+  return (error: Error) => {
+    if (isExpected(error)) {
+      return;
+    }
+    unexpected(error);
+  };
+};
+
 let HMR_TIMEOUT_MS = 10_000;
 
 test("HMR", async ({ page }) => {
   // uncomment for debugging
   // page.on("console", (msg) => console.log(msg.text()));
-  page.on("pageerror", (err) => console.log(err.message));
+  page.on("pageerror", logConsoleError);
   let dataRequests = 0;
   page.on("request", (request) => {
     let url = new URL(request.url());
@@ -249,12 +261,9 @@ test("HMR", async ({ page }) => {
   });
 
   let portRange = makeRange(4080, 4099);
-  let appServerPort = await getPort({ port: portRange });
-  let httpPort = await getPort({ port: portRange });
-  let webSocketPort = await getPort({ port: portRange });
-  let projectDir = await createFixtureProject(
-    fixture({ appServerPort, httpPort, webSocketPort })
-  );
+  let appPort = await getPort({ port: portRange });
+  let devPort = await getPort({ port: portRange });
+  let projectDir = await createFixtureProject(fixture({ appPort, devPort }));
 
   // spin up dev server
   let dev = execa("npm", ["run", "dev"], { cwd: projectDir });
@@ -269,7 +278,7 @@ test("HMR", async ({ page }) => {
       { timeoutMs: 10_000 }
     );
 
-    await page.goto(`http://localhost:${appServerPort}`, {
+    await page.goto(`http://localhost:${appPort}`, {
       waitUntil: "networkidle",
     });
 
@@ -463,6 +472,81 @@ 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");
+
+    let stderr = devStderr();
+    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()
+          .replace(stderr, "")
+          .includes('Expected ";" but found "efault"'),
+      {
+        timeoutMs: HMR_TIMEOUT_MS,
+      }
+    );
+
+    // React Router integration w/ React Refresh has a bug where sometimes rerenders happen with old UI and new data
+    // in this case causing `TypeError: Cannot destructure property`.
+    // Need to fix that bug, but it only shows a harmless console error in the browser in dev
+    page.removeListener("pageerror", logConsoleError);
+    let expectedErrorCount = 0;
+    let expectDestructureTypeError = expectConsoleError((error) => {
+      let expectedMessage = new Set([
+        // chrome, edge
+        "Cannot destructure property 'hello' of 'useLoaderData(...)' as it is null.",
+        // firefox
+        "(intermediate value)() is null",
+        // webkit
+        "Right side of assignment cannot be destructured",
+      ]);
+      let isExpected =
+        error.name === "TypeError" && expectedMessage.has(error.message);
+      if (isExpected) expectedErrorCount += 1;
+      return isExpected;
+    });
+    page.on("pageerror", expectDestructureTypeError);
+
+    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 });
+
+    // Restore normal console error handling
+    page.removeListener("pageerror", expectDestructureTypeError);
+    expect(expectedErrorCount).toBe(2);
+    page.addListener("pageerror", logConsoleError);
   } catch (e) {
     console.log("stdout begin -----------------------");
     console.log(devStdout());
diff --git a/integration/hmr-test.ts b/integration/hmr-test.ts
index b3c418906..446317995 100644
--- a/integration/hmr-test.ts
+++ b/integration/hmr-test.ts
@@ -9,11 +9,7 @@ import { createFixtureProject, css, js, json } from "./helpers/create-fixture";
 
 test.setTimeout(120_000);
 
-let fixture = (options: {
-  appServerPort: number;
-  httpPort: number;
-  webSocketPort: number;
-}) => ({
+let fixture = (options: { appPort: number; devPort: number }) => ({
   files: {
     "remix.config.js": js`
       module.exports = {
@@ -21,13 +17,13 @@ let fixture = (options: {
         tailwind: true,
         future: {
           unstable_dev: {
-            httpPort: ${options.httpPort},
-            webSocketPort: ${options.webSocketPort},
+            port: ${options.devPort},
           },
           v2_routeConvention: true,
           v2_errorBoundary: true,
           v2_normalizeFormMethod: true,
           v2_meta: true,
+          v2_headers: true,
         },
       };
     `,
@@ -68,18 +64,17 @@ let fixture = (options: {
       const app = express();
       app.use(express.static("public", { immutable: true, maxAge: "1y" }));
 
-      const MODE = process.env.NODE_ENV;
       const BUILD_DIR = path.join(process.cwd(), "build");
 
       app.all(
         "*",
         createRequestHandler({
           build: require(BUILD_DIR),
-          mode: MODE,
+          mode: process.env.NODE_ENV,
         })
       );
 
-      let port = ${options.appServerPort};
+      let port = ${options.appPort};
       app.listen(port, () => {
         let build = require(BUILD_DIR);
         console.log('✅ app ready: http://localhost:' + port);
@@ -233,12 +228,28 @@ let bufferize = (stream: Readable): (() => string) => {
   return () => buffer;
 };
 
+let logConsoleError = (error: Error) => {
+  console.error(`[console] ${error.name}: ${error.message}`);
+};
+
+let expectConsoleError = (
+  isExpected: (error: Error) => boolean,
+  unexpected = logConsoleError
+) => {
+  return (error: Error) => {
+    if (isExpected(error)) {
+      return;
+    }
+    unexpected(error);
+  };
+};
+
 let HMR_TIMEOUT_MS = 10_000;
 
 test("HMR", async ({ page }) => {
   // uncomment for debugging
   // page.on("console", (msg) => console.log(msg.text()));
-  page.on("pageerror", (err) => console.log(err.message));
+  page.on("pageerror", logConsoleError);
   let dataRequests = 0;
   page.on("request", (request) => {
     let url = new URL(request.url());
@@ -248,12 +259,9 @@ test("HMR", async ({ page }) => {
   });
 
   let portRange = makeRange(3080, 3099);
-  let appServerPort = await getPort({ port: portRange });
-  let httpPort = await getPort({ port: portRange });
-  let webSocketPort = await getPort({ port: portRange });
-  let projectDir = await createFixtureProject(
-    fixture({ appServerPort, httpPort, webSocketPort })
-  );
+  let appPort = await getPort({ port: portRange });
+  let devPort = await getPort({ port: portRange });
+  let projectDir = await createFixtureProject(fixture({ appPort, devPort }));
 
   // spin up dev server
   let dev = execa("npm", ["run", "dev"], { cwd: projectDir });
@@ -268,7 +276,7 @@ test("HMR", async ({ page }) => {
       { timeoutMs: 10_000 }
     );
 
-    await page.goto(`http://localhost:${appServerPort}`, {
+    await page.goto(`http://localhost:${appPort}`, {
       waitUntil: "networkidle",
     });
 
@@ -462,6 +470,81 @@ 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");
+
+    let stderr = devStderr();
+    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()
+          .replace(stderr, "")
+          .includes('Expected ";" but found "efault"'),
+      {
+        timeoutMs: HMR_TIMEOUT_MS,
+      }
+    );
+
+    // React Router integration w/ React Refresh has a bug where sometimes rerenders happen with old UI and new data
+    // in this case causing `TypeError: Cannot destructure property`.
+    // Need to fix that bug, but it only shows a harmless console error in the browser in dev
+    page.removeListener("pageerror", logConsoleError);
+    let expectedErrorCount = 0;
+    let expectDestructureTypeError = expectConsoleError((error) => {
+      let expectedMessage = new Set([
+        // chrome, edge
+        "Cannot destructure property 'hello' of 'useLoaderData(...)' as it is null.",
+        // firefox
+        "(intermediate value)() is null",
+        // webkit
+        "Right side of assignment cannot be destructured",
+      ]);
+      let isExpected =
+        error.name === "TypeError" && expectedMessage.has(error.message);
+      if (isExpected) expectedErrorCount += 1;
+      return isExpected;
+    });
+    page.on("pageerror", expectDestructureTypeError);
+
+    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 });
+
+    // Restore normal console error handling
+    page.removeListener("pageerror", expectDestructureTypeError);
+    expect(expectedErrorCount).toBe(2);
+    page.addListener("pageerror", logConsoleError);
   } 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__/cli-test.ts b/packages/remix-dev/__tests__/cli-test.ts
index 0ce720933..91d8fea10 100644
--- a/packages/remix-dev/__tests__/cli-test.ts
+++ b/packages/remix-dev/__tests__/cli-test.ts
@@ -118,11 +118,12 @@ describe("remix CLI", () => {
 
             [unstable_dev]
             --command, -c       Command used to run your app server
-            --http-scheme       HTTP(S) scheme for the dev server. Default: http
-            --http-host         HTTP(S) host for the dev server. Default: localhost
-            --http-port         HTTP(S) port for the dev server. Default: any open port
+            --scheme            Scheme for the dev server. Default: http
+            --host              Host for the dev server. Default: localhost
+            --port              Port for the dev server. Default: any open port
             --no-restart        Do not restart the app server when rebuilds occur.
-            --websocket-port    WebSocket port for the dev server. Default: any open port
+            --tls-key           Path to TLS key (key.pem)
+            --tls-cert          Path to TLS certificate (cert.pem)
           \`init\` Options:
             --no-delete         Skip deleting the \`remix.init\` script
           \`routes\` Options:
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-react/__tests__/components-test.tsx b/packages/remix-react/__tests__/components-test.tsx
index b088805a4..6cb621925 100644
--- a/packages/remix-react/__tests__/components-test.tsx
+++ b/packages/remix-react/__tests__/components-test.tsx
@@ -47,14 +47,14 @@ describe("<LiveReload />", () => {
       LiveReload = require("../components").LiveReload;
       let { container } = render(<LiveReload />);
       expect(container.querySelector("script")).toHaveTextContent(
-        "let port = (window.__remixContext && window.__remixContext.dev && window.__remixContext.dev.websocketPort) || 8002;"
+        "let port = undefined || (window.__remixContext && window.__remixContext.dev && window.__remixContext.dev.port) || 8002;"
       );
     });
 
     it("can set the port explicitly", () => {
       let { container } = render(<LiveReload port={4321} />);
       expect(container.querySelector("script")).toHaveTextContent(
-        "let port = (window.__remixContext && window.__remixContext.dev && window.__remixContext.dev.websocketPort) || 4321;"
+        "let port = 4321 || (window.__remixContext && window.__remixContext.dev && window.__remixContext.dev.port) || 8002;"
       );
     });
 
@@ -62,7 +62,7 @@ describe("<LiveReload />", () => {
       process.env.REMIX_DEV_SERVER_WS_PORT = "1234";
       let { container } = render(<LiveReload />);
       expect(container.querySelector("script")).toHaveTextContent(
-        "let port = (window.__remixContext && window.__remixContext.dev && window.__remixContext.dev.websocketPort) || 1234;"
+        "let port = undefined || (window.__remixContext && window.__remixContext.dev && window.__remixContext.dev.port) || 1234;"
       );
     });
 
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,