Repository: remix-run/remix
Pull request #3953 (2022-08-07 13:05:02)

fix(remix-react): switch `name` & `content` position in `meta` tags
When we visualize the html meta tags on a browswer inspector, I think it's better to see
```html
<meta name="twitter:card" content="summary">
```
than
```html
<meta content="summary" name="twitter:card">
```

<!--

👋 Hey, thanks for your interest in contributing to Remix!

If this is a simple docs change, go ahead and delete all this.

**Please ask first before starting work on any significant new features.**

It's never a fun experience to have your pull request declined after investing a
lot of time and effort into a new feature. To avoid this from happening, we
request that contributors create a
[Feature Request discussion](https://github.com/remix-run/remix/discussions/new?category=ideas)
to first discuss any significant new features.

https://github.com/remix-run/remix/blob/main/CONTRIBUTING.md

Please fill in or delete each item below:

-->

Closes: #

- [ ] Docs
- [ ] Tests

Testing Strategy:

<!--
Please explain how you tested this. For example:

> This test covers this code: <link to test>

Or

> I opened up my windows machine and ran this script:
>
> ```
> npx create-remix@0.0.0-experimental-7e420ee3 --template remix my-test
> cd my-test
> npm run dev
> ```
-->



Patch:
diff --git a/.changeset/afraid-peaches-accept.md b/.changeset/afraid-peaches-accept.md
deleted file mode 100644
index 8774b45b8..000000000
--- a/.changeset/afraid-peaches-accept.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@remix-run/dev": patch
----
-
-Add support for .mjs and .cjs remix.config files.
diff --git a/.changeset/four-ladybugs-wait.md b/.changeset/four-ladybugs-wait.md
deleted file mode 100644
index 1e20b75c8..000000000
--- a/.changeset/four-ladybugs-wait.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@remix-run/dev": patch
----
-
-Allow importing `.sql` files as text
diff --git a/.changeset/great-pumas-act.md b/.changeset/great-pumas-act.md
deleted file mode 100644
index dbc214cca..000000000
--- a/.changeset/great-pumas-act.md
+++ /dev/null
@@ -1,16 +0,0 @@
----
-"@remix-run/netlify": minor
-"@remix-run/server-runtime": minor
----
-
-Type safety for load context.
-
-Change `AppLoadContext` to be an interface mapping `string` to `unknown`, allowing users to extend it via:
-
-```ts
-declare module "@remix-run/server-runtime" {
-  interface AppLoadContext {
-    // add custom properties here!
-  }
-}
-```
diff --git a/.changeset/nasty-cars-care.md b/.changeset/nasty-cars-care.md
deleted file mode 100644
index d89994d6c..000000000
--- a/.changeset/nasty-cars-care.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@remix-run/react": patch
----
-
-React 18 gets more strict with types, this adds a runtime cast to a string for the `<title>` tag value.
diff --git a/.github/workflows/postrelease.yml b/.github/workflows/postrelease.yml
index d0b00b442..d95399b8a 100644
--- a/.github/workflows/postrelease.yml
+++ b/.github/workflows/postrelease.yml
@@ -1,4 +1,4 @@
-name: 🕊 Release
+name: 🕊 Post-release
 
 on:
   push:
diff --git a/.github/workflows/release-experimental.yml b/.github/workflows/release-experimental.yml
index 63d048f41..354909afa 100644
--- a/.github/workflows/release-experimental.yml
+++ b/.github/workflows/release-experimental.yml
@@ -22,7 +22,7 @@
 # - Create a new release for the tag on GitHub to trigger the CI workflow that
 #   will publish the release to npm
 
-name: 🧪 Release (experimental)
+name: 🚀 Release (experimental)
 on:
   push:
     tags:
diff --git a/.github/workflows/stacks.yml b/.github/workflows/stacks.yml
index f20535400..e6d49a1d1 100644
--- a/.github/workflows/stacks.yml
+++ b/.github/workflows/stacks.yml
@@ -13,6 +13,7 @@ jobs:
     if: github.repository == 'remix-run/remix'
     runs-on: ubuntu-latest
     strategy:
+      fail-fast: false
       matrix:
         stack:
           - repo: "remix-run/indie-stack"
@@ -69,6 +70,7 @@ jobs:
     needs: [setup]
     runs-on: ubuntu-latest
     strategy:
+      fail-fast: false
       matrix:
         stack:
           - repo: "remix-run/indie-stack"
@@ -111,6 +113,7 @@ jobs:
     if: github.repository == 'remix-run/remix'
     runs-on: ubuntu-latest
     strategy:
+      fail-fast: false
       matrix:
         stack:
           - repo: "remix-run/indie-stack"
@@ -153,6 +156,7 @@ jobs:
     needs: [setup]
     runs-on: ubuntu-latest
     strategy:
+      fail-fast: false
       matrix:
         stack:
           - repo: "remix-run/indie-stack"
@@ -195,6 +199,7 @@ jobs:
     needs: [setup]
     runs-on: ubuntu-latest
     strategy:
+      fail-fast: false
       matrix:
         stack:
           - repo: "remix-run/indie-stack"
diff --git a/contributors.yml b/contributors.yml
index dbafe37c1..08d205c14 100644
--- a/contributors.yml
+++ b/contributors.yml
@@ -33,6 +33,7 @@
 - archwebio
 - arganaphangquestian
 - AriGunawan
+- arnaudambro
 - arvigeus
 - arvindell
 - ascorbic
@@ -158,10 +159,12 @@
 - ianduvall
 - illright
 - imzshh
+- ionut-botizan
 - isaacrmoreno
 - ishan-me
 - IshanKBG
 - itsMapleLeaf
+- jacargentina
 - jacob-ebey
 - JacobParis
 - jakewtaylor
@@ -186,6 +189,7 @@
 - jo-ninja
 - joaosamouco
 - jodygeraldo
+- joelazar
 - johannesbraeunig
 - johnpolacek
 - johnson444
@@ -265,6 +269,7 @@
 - matthova
 - mattmazzola
 - mattstobbs
+- maxprilutskiy
 - mbarto
 - mcansh
 - medayz
@@ -301,6 +306,7 @@
 - niwsa
 - nobeeakon
 - nordiauwu
+- nrako
 - nurul3101
 - nvh95
 - nwalters512
@@ -312,6 +318,7 @@
 - pcattori
 - penspinner
 - penx
+- philandstuff
 - phishy
 - plastic041
 - plondon
@@ -397,6 +404,7 @@
 - vmosyaykin
 - vorcigernix
 - weavdale
+- willin
 - wKovacs64
 - wladiston
 - XiNiHa
diff --git a/docs/api/remix.md b/docs/api/remix.md
index 85460607c..b8039756f 100644
--- a/docs/api/remix.md
+++ b/docs/api/remix.md
@@ -752,7 +752,7 @@ It is common for Remix newcomers to see this hook and think it is the primary wa
 - [`useActionData`][useactiondata]
 - [`useTransition`][usetransition]
 
-If you're building a highly interactive, "app like" user interface, you will `useFetcher` often.
+If you're building a highly interactive, "app like" user interface, you will use `useFetcher` often.
 
 ```tsx
 import { useFetcher } from "@remix-run/react";
diff --git a/docs/pages/contributing.md b/docs/pages/contributing.md
index a25614839..66c1fdbc8 100644
--- a/docs/pages/contributing.md
+++ b/docs/pages/contributing.md
@@ -143,6 +143,14 @@ This repo maintains separate branches for different purposes. They will look som
 
 There may be other branches for various features and experimentation, but all of the magic happens from these branches.
 
+## How the heck do nightly releases work?
+
+Nightly releases will run the action files from the `main` branch as scheduled workflows will always use the latest commit to the default branch, signified by [this comment on the nightly action file][nightly-action-comment] and the explicit branch appended to the reuasable workflows in the [postrelease action][postrelease-action], however they checkout the `dev` branch during their set up as that's where we want our nightly releases to be cut from. From there, we check if the git sha is the same and only cut a new nightly if something has changed.
+
+## End to end testing
+
+For every release of Remix (stable, experimental, nightly, and pre-releases), we will do a complete end-to-end test of Remix apps on each of our official adapters from `create-remix`, all the way to deploying them to production. We do this by by utilizing the default [templates][templates] and the CLIs for Fly, Vercel, Netlify, and Arc. We'll then run some simple Cypress assertions to make sure everything is running properly for both development and the deployed app.
+
 [cla]: https://github.com/remix-run/remix/blob/main/CLA.md
 [this-page]: https://github.com/remix-run/remix
 [yarn-version-1]: https://classic.yarnpkg.com/lang/en/docs/install
@@ -150,3 +158,6 @@ There may be other branches for various features and experimentation, but all of
 [pull-request]: https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/creating-a-pull-request
 [yarn-workspaces]: https://classic.yarnpkg.com/en/docs/workspaces
 [vscode-playwright]: https://playwright.dev/docs/intro#using-the-vs-code-extension
+[nightly-action-comment]: https://github.com/remix-run/remix/blob/main/.github/workflows/nightly.yml#L8-L12
+[postrelease-action]: https://github.com/remix-run/remix/blob/main/.github/workflows/postrelease.yml
+[templates]: /templates
diff --git a/docs/tutorials/blog.md b/docs/tutorials/blog.md
index 9de7d64b2..02d72ce4a 100644
--- a/docs/tutorials/blog.md
+++ b/docs/tutorials/blog.md
@@ -897,7 +897,7 @@ export const action = async ({ request }) => {
 
 That's it. Remix (and the browser) will take care of the rest. Click the submit button and watch the sidebar that lists our posts update automatically.
 
-In HTML an input's `name` attribute is sent over the network and available by the same name on the request's `formData`. Oh, and don't forget, the `request` and `formData` objects are both straight out of the web specification. So if you want to learn more about either of them, head over to MDN! [mdn.io/request][mdn-io-request] [mdn.io/request.formData][mdn-io-request-form-data].
+In HTML an input's `name` attribute is sent over the network and available by the same name on the request's `formData`. Oh, and don't forget, the `request` and `formData` objects are both straight out of the web specification. So if you want to learn more about either of them, head over to MDN! [mdn.io/Request][mdn-io-request] [mdn.io/Request.formData][mdn-io-request-form-data].
 
 TypeScript is mad again, let's add some types.
 
@@ -1175,8 +1175,8 @@ We hope you love Remix! 💿 👋
 [the-styling-guide]: /guides/styling
 [prisma]: https://prisma.io
 [http-localhost-3000-posts-admin]: http://localhost:3000/posts/admin
-[mdn-io-request]: https://mdn.io/request
-[mdn-io-request-form-data]: https://mdn.io/request.formData
+[mdn-io-request]: https://mdn.io/Request
+[mdn-io-request-form-data]: https://mdn.io/Request.formData
 [disable-java-script]: https://developer.chrome.com/docs/devtools/javascript/disable
 [the-optimistic-ui-guide]: /guides/optimistic-ui
 [somewhere]: https://www.youtube.com/watch?v=dQw4w9WgXcQ
diff --git a/examples/client-side-validation/README.md b/examples/client-side-validation/README.md
index 1b2d37611..ccc7bd697 100644
--- a/examples/client-side-validation/README.md
+++ b/examples/client-side-validation/README.md
@@ -14,7 +14,7 @@ Open this example on [CodeSandbox](https://codesandbox.com):
 
 This example shows how to use the built-in HTML5 validation features.
 
-Check [app/routes/example.tsx](app/root.tsx) to see the client-side validation in action.
+Check [app/root.tsx](app/root.tsx) to see the client-side validation in action.
 
 ## Related Links
 
diff --git a/integration/helpers/node-template/app/entry.client.tsx b/integration/helpers/node-template/app/entry.client.tsx
index d7ef6febb..ffef95f66 100644
--- a/integration/helpers/node-template/app/entry.client.tsx
+++ b/integration/helpers/node-template/app/entry.client.tsx
@@ -1,4 +1,4 @@
-import { hydrateRoot } from "react-dom/client";
 import { RemixBrowser } from "@remix-run/react";
+import { hydrateRoot } from "react-dom/client";
 
 hydrateRoot(document, <RemixBrowser />);
diff --git a/integration/helpers/node-template/app/entry.server.tsx b/integration/helpers/node-template/app/entry.server.tsx
index 2a3e66789..92ac9fc51 100644
--- a/integration/helpers/node-template/app/entry.server.tsx
+++ b/integration/helpers/node-template/app/entry.server.tsx
@@ -1,8 +1,8 @@
 import { PassThrough } from "stream";
-import { renderToPipeableStream } from "react-dom/server";
 import type { EntryContext } from "@remix-run/node";
 import { Response } from "@remix-run/node";
 import { RemixServer } from "@remix-run/react";
+import { renderToPipeableStream } from "react-dom/server";
 
 const ABORT_DELAY = 5000;
 
@@ -18,28 +18,31 @@ export default function handleRequest(
     let { pipe, abort } = renderToPipeableStream(
       <RemixServer context={remixContext} url={request.url} />,
       {
-        onShellReady() {
+        onShellReady: () => {
           let body = new PassThrough();
 
           responseHeaders.set("Content-Type", "text/html");
 
           resolve(
             new Response(body, {
-              status: didError ? 500 : responseStatusCode,
               headers: responseHeaders,
+              status: didError ? 500 : responseStatusCode,
             })
           );
+
           pipe(body);
         },
-        onShellError(err) {
+        onShellError: (err) => {
           reject(err);
         },
-        onError(error) {
+        onError: (error) => {
           didError = true;
+
           console.error(error);
         },
       }
     );
+
     setTimeout(abort, ABORT_DELAY);
   });
 }
diff --git a/integration/playwright.config.ts b/integration/playwright.config.ts
index 42379bda7..0e125404f 100644
--- a/integration/playwright.config.ts
+++ b/integration/playwright.config.ts
@@ -5,14 +5,14 @@ const config: PlaywrightTestConfig = {
   testDir: ".",
   testMatch: ["**/*-test.ts"],
   /* Maximum time one test can run for. */
-  timeout: 30_000,
+  timeout: process.platform === "win32" ? 60_000 : 30_000,
+  fullyParallel: true,
   expect: {
     /* Maximum time expect() should wait for the condition to be met. */
     timeout: 5_000,
   },
   forbidOnly: !!process.env.CI,
-  retries: process.env.CI ? 2 : 0,
-  workers: process.env.CI ? 1 : undefined,
+  retries: 3,
   reporter: process.env.CI ? "github" : [["html", { open: "never" }]],
   use: { actionTimeout: 0 },
 
diff --git a/packages/create-remix/CHANGELOG.md b/packages/create-remix/CHANGELOG.md
index 5d5edbe20..1929c9977 100644
--- a/packages/create-remix/CHANGELOG.md
+++ b/packages/create-remix/CHANGELOG.md
@@ -1,5 +1,12 @@
 # `create-remix`
 
+## 1.6.8
+
+### Patch Changes
+
+- Updated dependencies:
+  - `@remix-run/dev@1.6.8`
+
 ## 1.6.7
 
 ### Patch Changes
diff --git a/packages/create-remix/package.json b/packages/create-remix/package.json
index d77694e0e..459af74de 100644
--- a/packages/create-remix/package.json
+++ b/packages/create-remix/package.json
@@ -1,6 +1,6 @@
 {
   "name": "create-remix",
-  "version": "1.6.7",
+  "version": "1.6.8",
   "description": "Create a new Remix app",
   "homepage": "https://remix.run",
   "bugs": {
@@ -17,7 +17,7 @@
     "create-remix": "dist/cli.js"
   },
   "dependencies": {
-    "@remix-run/dev": "1.6.7"
+    "@remix-run/dev": "1.6.8"
   },
   "engines": {
     "node": ">=14"
diff --git a/packages/remix-architect/CHANGELOG.md b/packages/remix-architect/CHANGELOG.md
index 7fd8bc615..89ad0b8da 100644
--- a/packages/remix-architect/CHANGELOG.md
+++ b/packages/remix-architect/CHANGELOG.md
@@ -1,5 +1,12 @@
 # `@remix-run/architect`
 
+## 1.6.8
+
+### Patch Changes
+
+- Updated dependencies:
+  - `@remix-run/node@1.6.8`
+
 ## 1.6.7
 
 ### Patch Changes
diff --git a/packages/remix-architect/package.json b/packages/remix-architect/package.json
index 5c6d6f1f5..aa4108e77 100644
--- a/packages/remix-architect/package.json
+++ b/packages/remix-architect/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@remix-run/architect",
-  "version": "1.6.7",
+  "version": "1.6.8",
   "description": "Architect server request handler for Remix",
   "bugs": {
     "url": "https://github.com/remix-run/remix/issues"
@@ -15,7 +15,7 @@
   "typings": "dist/index.d.ts",
   "dependencies": {
     "@architect/functions": "^5.0.2",
-    "@remix-run/node": "1.6.7",
+    "@remix-run/node": "1.6.8",
     "@types/aws-lambda": "^8.10.82"
   },
   "devDependencies": {
diff --git a/packages/remix-cloudflare-pages/CHANGELOG.md b/packages/remix-cloudflare-pages/CHANGELOG.md
index f4bb9d9ed..f0014676e 100644
--- a/packages/remix-cloudflare-pages/CHANGELOG.md
+++ b/packages/remix-cloudflare-pages/CHANGELOG.md
@@ -1,5 +1,12 @@
 # `@remix-run/cloudflare-pages`
 
+## 1.6.8
+
+### Patch Changes
+
+- Updated dependencies:
+  - `@remix-run/cloudflare@1.6.8`
+
 ## 1.6.7
 
 ### Patch Changes
diff --git a/packages/remix-cloudflare-pages/package.json b/packages/remix-cloudflare-pages/package.json
index f2e0651cf..bcc84961c 100644
--- a/packages/remix-cloudflare-pages/package.json
+++ b/packages/remix-cloudflare-pages/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@remix-run/cloudflare-pages",
-  "version": "1.6.7",
+  "version": "1.6.8",
   "description": "Cloudflare Pages request handler for Remix",
   "bugs": {
     "url": "https://github.com/remix-run/remix/issues"
@@ -15,7 +15,7 @@
   "typings": "dist/index.d.ts",
   "module": "dist/esm/index.js",
   "dependencies": {
-    "@remix-run/cloudflare": "1.6.7"
+    "@remix-run/cloudflare": "1.6.8"
   },
   "devDependencies": {
     "@cloudflare/workers-types": "^3.4.0",
diff --git a/packages/remix-cloudflare-workers/CHANGELOG.md b/packages/remix-cloudflare-workers/CHANGELOG.md
index 8e949bbd7..9ee9fc72b 100644
--- a/packages/remix-cloudflare-workers/CHANGELOG.md
+++ b/packages/remix-cloudflare-workers/CHANGELOG.md
@@ -1,5 +1,12 @@
 # `@remix-run/cloudflare-workers`
 
+## 1.6.8
+
+### Patch Changes
+
+- Updated dependencies:
+  - `@remix-run/cloudflare@1.6.8`
+
 ## 1.6.7
 
 ### Patch Changes
diff --git a/packages/remix-cloudflare-workers/package.json b/packages/remix-cloudflare-workers/package.json
index dca6ef713..8bc113a5e 100644
--- a/packages/remix-cloudflare-workers/package.json
+++ b/packages/remix-cloudflare-workers/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@remix-run/cloudflare-workers",
-  "version": "1.6.7",
+  "version": "1.6.8",
   "description": "Cloudflare worker request handler for Remix",
   "bugs": {
     "url": "https://github.com/remix-run/remix/issues"
@@ -16,7 +16,7 @@
   "module": "dist/esm/index.js",
   "dependencies": {
     "@cloudflare/kv-asset-handler": "^0.1.3",
-    "@remix-run/cloudflare": "1.6.7"
+    "@remix-run/cloudflare": "1.6.8"
   },
   "devDependencies": {
     "@cloudflare/workers-types": "^3.4.0"
diff --git a/packages/remix-cloudflare/CHANGELOG.md b/packages/remix-cloudflare/CHANGELOG.md
index e7e260142..1bbcf89fc 100644
--- a/packages/remix-cloudflare/CHANGELOG.md
+++ b/packages/remix-cloudflare/CHANGELOG.md
@@ -1,5 +1,12 @@
 # `@remix-run/cloudflare`
 
+## 1.6.8
+
+### Patch Changes
+
+- Updated dependencies:
+  - `@remix-run/server-runtime@1.6.8`
+
 ## 1.6.7
 
 ### Patch Changes
diff --git a/packages/remix-cloudflare/package.json b/packages/remix-cloudflare/package.json
index 5985fb717..16e4508e8 100644
--- a/packages/remix-cloudflare/package.json
+++ b/packages/remix-cloudflare/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@remix-run/cloudflare",
-  "version": "1.6.7",
+  "version": "1.6.8",
   "description": "Cloudflare platform abstractions for Remix",
   "bugs": {
     "url": "https://github.com/remix-run/remix/issues"
@@ -15,7 +15,7 @@
   "typings": "dist/index.d.ts",
   "dependencies": {
     "@cloudflare/kv-asset-handler": "^0.1.3",
-    "@remix-run/server-runtime": "1.6.7"
+    "@remix-run/server-runtime": "1.6.8"
   },
   "devDependencies": {
     "@cloudflare/workers-types": "^3.4.0"
diff --git a/packages/remix-deno/CHANGELOG.md b/packages/remix-deno/CHANGELOG.md
index 35e019c95..0402d119a 100644
--- a/packages/remix-deno/CHANGELOG.md
+++ b/packages/remix-deno/CHANGELOG.md
@@ -1,5 +1,12 @@
 # `@remix-run/deno`
 
+## 1.6.8
+
+### Patch Changes
+
+- Updated dependencies:
+  - `@remix-run/server-runtime@1.6.8`
+
 ## 1.6.7
 
 ### Patch Changes
diff --git a/packages/remix-deno/package.json b/packages/remix-deno/package.json
index 1f8d04766..4982aebe3 100644
--- a/packages/remix-deno/package.json
+++ b/packages/remix-deno/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@remix-run/deno",
-  "version": "1.6.7",
+  "version": "1.6.8",
   "description": "Deno platform abstractions for Remix",
   "homepage": "https://remix.run",
   "main": "./index.ts",
@@ -15,7 +15,7 @@
   "license": "MIT",
   "sideEffects": false,
   "dependencies": {
-    "@remix-run/server-runtime": "1.6.7",
+    "@remix-run/server-runtime": "1.6.8",
     "mime": "^3.0.0"
   },
   "engines": {
diff --git a/packages/remix-dev/CHANGELOG.md b/packages/remix-dev/CHANGELOG.md
index 65e9adec4..1fff77535 100644
--- a/packages/remix-dev/CHANGELOG.md
+++ b/packages/remix-dev/CHANGELOG.md
@@ -1,5 +1,16 @@
 # `@remix-run/dev`
 
+## 1.6.8
+
+### Patch Changes
+
+- Added support for `.mjs` and `.cjs` file extensions for `remix.config` ([#3675](https://github.com/remix-run/remix/pull/3675))
+- Added support for importing `.sql` files as text content ([#3190](https://github.com/remix-run/remix/pull/3190))
+- Updated the compiler to make MDX builds deterministic (and a little faster!) ([#3966](https://github.com/remix-run/remix/pull/3966))
+- Updated dependencies:
+  - `@remix-run/server-runtime@1.6.8`
+  - `@remix-run/serve@1.6.8`
+
 ## 1.6.7
 
 ### Patch Changes
diff --git a/packages/remix-dev/cli/checkGitStatus.ts b/packages/remix-dev/cli/checkGitStatus.ts
index 4af5f9ef4..59a0485ec 100644
--- a/packages/remix-dev/cli/checkGitStatus.ts
+++ b/packages/remix-dev/cli/checkGitStatus.ts
@@ -10,7 +10,7 @@ export const checkGitStatus = (projectDir: string, { force = false }) => {
     clean = isGitClean(projectDir);
     errorMessage = "Git directory is not clean";
   } catch (err: any) {
-    if (err?.stderr.indexOf("Not a git repository") >= 0) {
+    if (err?.stderr?.indexOf("Not a git repository") >= 0) {
       clean = true;
     }
   }
diff --git a/packages/remix-dev/cli/run.ts b/packages/remix-dev/cli/run.ts
index 868d4071a..130956de7 100644
--- a/packages/remix-dev/cli/run.ts
+++ b/packages/remix-dev/cli/run.ts
@@ -24,6 +24,7 @@ ${colors.logoBlue("R")} ${colors.logoGreen("E")} ${colors.logoYellow(
     $ remix build [${colors.arg("projectDir")}]
     $ remix dev [${colors.arg("projectDir")}]
     $ remix routes [${colors.arg("projectDir")}]
+    $ remix watch [${colors.arg("projectDir")}]
     $ remix setup [${colors.arg("remixPlatform")}]
     $ remix migrate [-m ${colors.arg("migration")}] [${colors.arg(
   "projectDir"
@@ -103,6 +104,14 @@ ${colors.logoBlue("R")} ${colors.logoGreen("E")} ${colors.logoYellow(
     $ remix dev my-app
     $ remix dev --debug
 
+  ${colors.heading("Start your server separately and watch for changes")}:
+
+    # custom server start command, for example:
+    $ remix watch
+
+    # in a separate tab:
+    $ node --inspect --require ./node_modules/dotenv/config --require ./mocks ./build/server.js
+
   ${colors.heading("Show all routes in your app")}:
 
     $ remix routes
diff --git a/packages/remix-dev/compiler/plugins/mdx.ts b/packages/remix-dev/compiler/plugins/mdx.ts
index 2b0ec22e8..4ff210ec9 100644
--- a/packages/remix-dev/compiler/plugins/mdx.ts
+++ b/packages/remix-dev/compiler/plugins/mdx.ts
@@ -39,14 +39,15 @@ export function mdxPlugin(config: RemixConfig): esbuild.Plugin {
         let resolved = path.resolve(args.resolveDir, resolvedPath);
 
         return {
-          path: resolved,
+          path: path.relative(config.appDirectory, resolved),
           namespace: "mdx",
         };
       });
 
       build.onLoad({ filter: /\.mdx?$/ }, async (args) => {
+        let absolutePath = path.join(config.appDirectory, args.path);
         try {
-          let fileContents = await fsp.readFile(args.path, "utf-8");
+          let fileContents = await fsp.readFile(absolutePath, "utf-8");
 
           let rehypePlugins = [];
           let remarkPlugins = [
@@ -115,7 +116,7 @@ ${remixExports}`;
             errors: errors.length ? errors : undefined,
             warnings: warnings.length ? warnings : undefined,
             contents,
-            resolveDir: path.dirname(args.path),
+            resolveDir: path.dirname(absolutePath),
             loader: getLoaderForFile(args.path),
           };
         } catch (err: any) {
diff --git a/packages/remix-dev/config.ts b/packages/remix-dev/config.ts
index 0ac1ce518..6c2a110ed 100644
--- a/packages/remix-dev/config.ts
+++ b/packages/remix-dev/config.ts
@@ -1,4 +1,5 @@
 import * as path from "path";
+import { pathToFileURL } from "url";
 import * as fse from "fs-extra";
 import getPort from "get-port";
 
@@ -291,9 +292,19 @@ export async function readConfig(
 
   let appConfig: AppConfig = {};
   if (configFile) {
+    let appConfigModule: any;
     try {
-      let appConfigModule = await import(configFile);
-      appConfig = appConfigModule?.default || appConfig;
+      // shout out to next
+      // https://github.com/vercel/next.js/blob/b15a976e11bf1dc867c241a4c1734757427d609c/packages/next/server/config.ts#L748-L765
+      if (process.env.NODE_ENV === "test") {
+        // dynamic import does not currently work inside of vm which
+        // jest relies on so we fall back to require for this case
+        // https://github.com/nodejs/node/issues/35889
+        appConfigModule = require(configFile);
+      } else {
+        appConfigModule = await import(pathToFileURL(configFile).href);
+      }
+      appConfig = appConfigModule?.default || appConfigModule;
     } catch (error) {
       throw new Error(
         `Error loading Remix config at ${configFile}\n${String(error)}`
diff --git a/packages/remix-dev/package.json b/packages/remix-dev/package.json
index 34e194a1b..77cdb2b71 100644
--- a/packages/remix-dev/package.json
+++ b/packages/remix-dev/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@remix-run/dev",
-  "version": "1.6.7",
+  "version": "1.6.8",
   "description": "Dev tools and CLI for Remix",
   "homepage": "https://remix.run",
   "bugs": {
@@ -24,7 +24,7 @@
     "@babel/preset-typescript": "^7.18.6",
     "@esbuild-plugins/node-modules-polyfill": "^0.1.4",
     "@npmcli/package-json": "^2.0.0",
-    "@remix-run/server-runtime": "1.6.7",
+    "@remix-run/server-runtime": "1.6.8",
     "@yarnpkg/esbuild-plugin-pnp": "3.0.0-rc.11",
     "arg": "^5.0.1",
     "cacache": "^15.0.5",
@@ -59,7 +59,7 @@
     "xdm": "^2.0.0"
   },
   "devDependencies": {
-    "@remix-run/serve": "1.6.7",
+    "@remix-run/serve": "1.6.8",
     "@types/cacache": "^15.0.0",
     "@types/gunzip-maybe": "^1.4.0",
     "@types/inquirer": "^8.2.0",
@@ -77,7 +77,7 @@
     "type-fest": "^2.16.0"
   },
   "peerDependencies": {
-    "@remix-run/serve": "1.6.7"
+    "@remix-run/serve": "1.6.8"
   },
   "peerDependenciesMeta": {
     "@remix-run/serve": {
diff --git a/packages/remix-eslint-config/CHANGELOG.md b/packages/remix-eslint-config/CHANGELOG.md
index 227778422..fc64b7d50 100644
--- a/packages/remix-eslint-config/CHANGELOG.md
+++ b/packages/remix-eslint-config/CHANGELOG.md
@@ -1,5 +1,7 @@
 # `@remix-run/eslint-config`
 
+## 1.6.8
+
 ## 1.6.7
 
 No significant changes to this package were made in this release. [See the releases page on GitHub](https://github.com/remix-run/remix/releases/tag/remix%401.6.7) for an overview of all changes in v1.6.7.
diff --git a/packages/remix-eslint-config/package.json b/packages/remix-eslint-config/package.json
index f05ce1f88..46f8db3d8 100644
--- a/packages/remix-eslint-config/package.json
+++ b/packages/remix-eslint-config/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@remix-run/eslint-config",
-  "version": "1.6.7",
+  "version": "1.6.8",
   "description": "ESLint configuration for Remix projects",
   "bugs": {
     "url": "https://github.com/remix-run/remix/issues"
diff --git a/packages/remix-express/CHANGELOG.md b/packages/remix-express/CHANGELOG.md
index 159679170..a4a03a936 100644
--- a/packages/remix-express/CHANGELOG.md
+++ b/packages/remix-express/CHANGELOG.md
@@ -1,5 +1,12 @@
 # `@remix-run/express`
 
+## 1.6.8
+
+### Patch Changes
+
+- Updated dependencies:
+  - `@remix-run/node@1.6.8`
+
 ## 1.6.7
 
 ### Patch Changes
diff --git a/packages/remix-express/package.json b/packages/remix-express/package.json
index 26949460d..8f1d2cf0e 100644
--- a/packages/remix-express/package.json
+++ b/packages/remix-express/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@remix-run/express",
-  "version": "1.6.7",
+  "version": "1.6.8",
   "description": "Express server request handler for Remix",
   "bugs": {
     "url": "https://github.com/remix-run/remix/issues"
@@ -14,7 +14,7 @@
   "main": "dist/index.js",
   "typings": "dist/index.d.ts",
   "dependencies": {
-    "@remix-run/node": "1.6.7"
+    "@remix-run/node": "1.6.8"
   },
   "devDependencies": {
     "@types/express": "^4.17.9",
diff --git a/packages/remix-netlify/CHANGELOG.md b/packages/remix-netlify/CHANGELOG.md
index e829042a1..62a92b686 100644
--- a/packages/remix-netlify/CHANGELOG.md
+++ b/packages/remix-netlify/CHANGELOG.md
@@ -1,5 +1,22 @@
 # `@remix-run/netlify`
 
+## 1.6.8
+
+### Patch Changes
+
+- We've added type safety for load context. `AppLoadContext` is now an an interface mapping `string` to `unknown`, allowing users to extend it via module augmentation: ([#1876](https://github.com/remix-run/remix/pull/1876))
+
+  ```ts
+  declare module "@remix-run/server-runtime" {
+    interface AppLoadContext {
+      // add custom properties here!
+    }
+  }
+  ```
+
+- Updated dependencies:
+  - `@remix-run/node@1.6.8`
+
 ## 1.6.7
 
 ### Patch Changes
diff --git a/packages/remix-netlify/package.json b/packages/remix-netlify/package.json
index 12735096d..0f13318e7 100644
--- a/packages/remix-netlify/package.json
+++ b/packages/remix-netlify/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@remix-run/netlify",
-  "version": "1.6.7",
+  "version": "1.6.8",
   "description": "Netlify server request handler for Remix",
   "bugs": {
     "url": "https://github.com/remix-run/remix/issues"
@@ -14,7 +14,7 @@
   "main": "dist/index.js",
   "typings": "dist/index.d.ts",
   "dependencies": {
-    "@remix-run/node": "1.6.7"
+    "@remix-run/node": "1.6.8"
   },
   "devDependencies": {
     "@netlify/functions": "^1.0.0"
diff --git a/packages/remix-node/CHANGELOG.md b/packages/remix-node/CHANGELOG.md
index ac7e6b7e7..418eede4b 100644
--- a/packages/remix-node/CHANGELOG.md
+++ b/packages/remix-node/CHANGELOG.md
@@ -1,5 +1,12 @@
 # `@remix-run/node`
 
+## 1.6.8
+
+### Patch Changes
+
+- Updated dependencies:
+  - `@remix-run/server-runtime@1.6.8`
+
 ## 1.6.7
 
 ### Patch Changes
diff --git a/packages/remix-node/package.json b/packages/remix-node/package.json
index 8e5d55f12..37c365e65 100644
--- a/packages/remix-node/package.json
+++ b/packages/remix-node/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@remix-run/node",
-  "version": "1.6.7",
+  "version": "1.6.8",
   "description": "Node.js platform abstractions for Remix",
   "bugs": {
     "url": "https://github.com/remix-run/remix/issues"
@@ -15,7 +15,7 @@
   "typings": "dist/index.d.ts",
   "sideEffects": false,
   "dependencies": {
-    "@remix-run/server-runtime": "1.6.7",
+    "@remix-run/server-runtime": "1.6.8",
     "@remix-run/web-fetch": "^4.1.3",
     "@remix-run/web-file": "^3.0.2",
     "@remix-run/web-stream": "^1.0.3",
diff --git a/packages/remix-react/CHANGELOG.md b/packages/remix-react/CHANGELOG.md
index 24586c7f9..064302c83 100644
--- a/packages/remix-react/CHANGELOG.md
+++ b/packages/remix-react/CHANGELOG.md
@@ -1,5 +1,13 @@
 # `@remix-run/react`
 
+## 1.6.8
+
+### Patch Changes
+
+- Previously, if an `action` was omitted from `<Form>` or `useFormAction`, the action value would default to `"."`. This is incorrect, as `"."` should resolve based on the current _path_, but an empty action resolves relative to the current _URL_ (including the search and hash values). We've fixed this to differentiate between the two, meaning that the resolved action will preserve the full URL. ([#3697](https://github.com/remix-run/remix/pull/3697))
+- Enhanced some types to work more seamlessly with React 18 ([#3917](https://github.com/remix-run/remix/pull/3917))
+- Added a subscribe method to the transition manager, which allows subscribing and unsubscribing for React 18 strict mode compliance ([#3964](https://github.com/remix-run/remix/pull/3964))
+
 ## 1.6.7
 
 ### Patch Changes
diff --git a/packages/remix-react/components.tsx b/packages/remix-react/components.tsx
index 3a6bef315..a759b2e68 100644
--- a/packages/remix-react/components.tsx
+++ b/packages/remix-react/components.tsx
@@ -21,6 +21,7 @@ import {
 } from "react-router-dom";
 import type { LinkProps, NavLinkProps } from "react-router-dom";
 import type { Merge } from "type-fest";
+import { createPath } from "history";
 
 import type { AppData, FormEncType, FormMethod } from "./data";
 import type { EntryContext, AssetsManifest } from "./entry";
@@ -49,7 +50,12 @@ import type { RouteMatch as BaseRouteMatch } from "./routeMatching";
 import { matchClientRoutes } from "./routeMatching";
 import type { RouteModules, HtmlMetaDescriptor } from "./routeModules";
 import { createTransitionManager } from "./transition";
-import type { Transition, Fetcher, Submission } from "./transition";
+import type {
+  Transition,
+  TransitionManagerState,
+  Fetcher,
+  Submission,
+} from "./transition";
 
 ////////////////////////////////////////////////////////////////////////////////
 // RemixEntry
@@ -117,20 +123,25 @@ export function RemixEntry({
       catch: entryComponentDidCatchEmulator.catch,
       catchBoundaryId: entryComponentDidCatchEmulator.catchBoundaryRouteId,
       onRedirect: _navigator.replace,
-      onChange: (state) => {
-        setClientState({
-          catch: state.catch,
-          error: state.error,
-          catchBoundaryRouteId: state.catchBoundaryId,
-          loaderBoundaryRouteId: state.errorBoundaryId,
-          renderBoundaryRouteId: null,
-          trackBoundaries: false,
-          trackCatchBoundaries: false,
-        });
-      },
     });
   });
 
+  React.useEffect(() => {
+    let subscriber = (state: TransitionManagerState) => {
+      setClientState({
+        catch: state.catch,
+        error: state.error,
+        catchBoundaryRouteId: state.catchBoundaryId,
+        loaderBoundaryRouteId: state.errorBoundaryId,
+        renderBoundaryRouteId: null,
+        trackBoundaries: false,
+        trackCatchBoundaries: false,
+      });
+    };
+
+    return transitionManager.subscribe(subscriber);
+  }, [transitionManager]);
+
   // Ensures pushes interrupting pending navigations use replace
   // TODO: Move this to React Router
   let navigator: Navigator = React.useMemo(() => {
@@ -729,15 +740,15 @@ export function Meta() {
           if (isOpenGraphTag) {
             return (
               <meta
+                property={name}
                 content={content as string}
                 key={name + content}
-                property={name}
               />
             );
           }
 
           if (typeof content === "string") {
-            return <meta content={content} name={name} key={name + content} />;
+            return <meta name={name} content={content} key={name + content} />;
           }
 
           return <meta key={name + JSON.stringify(content)} {...content} />;
@@ -934,7 +945,7 @@ let FormImpl = React.forwardRef<HTMLFormElement, FormImplProps>(
       reloadDocument = false,
       replace = false,
       method = "get",
-      action = ".",
+      action,
       encType = "application/x-www-form-urlencoded",
       fetchKey,
       onSubmit,
@@ -989,20 +1000,31 @@ type HTMLFormSubmitter = HTMLButtonElement | HTMLInputElement;
  * @see https://remix.run/api/remix#useformaction
  */
 export function useFormAction(
-  action = ".",
+  action?: string,
   // TODO: Remove method param in v2 as it's no longer needed and is a breaking change
   method: FormMethod = "get"
 ): string {
   let { id } = useRemixRouteContext();
-  let path = useResolvedPath(action);
-  let search = path.search;
-  let isIndexRoute = id.endsWith("/index");
+  let resolvedPath = useResolvedPath(action ?? ".");
 
-  if (action === "." && isIndexRoute) {
+  // Previously we set the default action to ".". The problem with this is that
+  // `useResolvedPath(".")` excludes search params and the hash of the resolved
+  // URL. This is the intended behavior of when "." is specifically provided as
+  // the form action, but inconsistent w/ browsers when the action is omitted.
+  // https://github.com/remix-run/remix/issues/927
+  let location = useLocation();
+  let { search, hash } = resolvedPath;
+  if (action == null) {
+    search = location.search;
+    hash = location.hash;
+  }
+
+  let isIndexRoute = id.endsWith("/index");
+  if ((action == null || action === ".") && isIndexRoute) {
     search = search ? search.replace(/^\?/, "?index&") : "?index";
   }
 
-  return path.pathname + search;
+  return createPath({ pathname: resolvedPath.pathname, search, hash });
 }
 
 export interface SubmitOptions {
@@ -1139,7 +1161,7 @@ export function useSubmitImpl(key?: string): SubmitFunction {
 
         // Include name + value from a <button>
         if (target.name) {
-          formData.set(target.name, target.value);
+          formData.append(target.name, target.value);
         }
       } else {
         if (isHtmlElement(target)) {
diff --git a/packages/remix-react/package.json b/packages/remix-react/package.json
index 7a7c23d85..7d8d08d01 100644
--- a/packages/remix-react/package.json
+++ b/packages/remix-react/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@remix-run/react",
-  "version": "1.6.7",
+  "version": "1.6.8",
   "description": "React DOM bindings for Remix",
   "bugs": {
     "url": "https://github.com/remix-run/remix/issues"
diff --git a/packages/remix-react/transition.ts b/packages/remix-react/transition.ts
index 346fd5d76..0249db948 100644
--- a/packages/remix-react/transition.ts
+++ b/packages/remix-react/transition.ts
@@ -97,10 +97,13 @@ export interface TransitionManagerInit {
   error?: Error;
   catchBoundaryId?: null | string;
   errorBoundaryId?: null | string;
-  onChange: (state: TransitionManagerState) => void;
   onRedirect: (to: string, state?: any) => void;
 }
 
+export type TransitionManagerSubscriber = (
+  state: TransitionManagerState
+) => void;
+
 export interface Submission {
   action: string;
   method: string;
@@ -422,6 +425,7 @@ export function createTransitionManager(init: TransitionManagerInit) {
   let navigationLoadId = -1;
   let fetchReloadIds = new Map<string, number>();
   let fetchRedirectIds = new Set<string>();
+  let subscribers = new Set<TransitionManagerSubscriber>();
 
   let matches = matchClientRoutes(routes, init.location);
 
@@ -462,7 +466,10 @@ export function createTransitionManager(init: TransitionManagerInit) {
     }
 
     state = Object.assign({}, state, updates);
-    init.onChange(state);
+
+    for (let subscriber of subscribers.values()) {
+      subscriber(state);
+    }
   }
 
   function getState(): TransitionManagerState {
@@ -1382,7 +1389,15 @@ export function createTransitionManager(init: TransitionManagerInit) {
     markFetchersDone(doneKeys);
   }
 
+  function subscribe(subscriber: TransitionManagerSubscriber) {
+    subscribers.add(subscriber);
+    return () => {
+      subscribers.delete(subscriber);
+    };
+  }
+
   return {
+    subscribe,
     send,
     getState,
     getFetcher,
diff --git a/packages/remix-serve/CHANGELOG.md b/packages/remix-serve/CHANGELOG.md
index 4b5f4f55f..974e24f55 100644
--- a/packages/remix-serve/CHANGELOG.md
+++ b/packages/remix-serve/CHANGELOG.md
@@ -1,5 +1,12 @@
 # `@remix-run/serve`
 
+## 1.6.8
+
+### Patch Changes
+
+- Updated dependencies:
+  - `@remix-run/express@1.6.8`
+
 ## 1.6.7
 
 ### Patch Changes
diff --git a/packages/remix-serve/package.json b/packages/remix-serve/package.json
index 60d4ffff4..c22763312 100644
--- a/packages/remix-serve/package.json
+++ b/packages/remix-serve/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@remix-run/serve",
-  "version": "1.6.7",
+  "version": "1.6.8",
   "description": "Production application server for Remix",
   "bugs": {
     "url": "https://github.com/remix-run/remix/issues"
@@ -17,7 +17,7 @@
     "remix-serve": "dist/cli.js"
   },
   "dependencies": {
-    "@remix-run/express": "1.6.7",
+    "@remix-run/express": "1.6.8",
     "compression": "^1.7.4",
     "express": "^4.17.1",
     "morgan": "^1.10.0"
diff --git a/packages/remix-server-runtime/CHANGELOG.md b/packages/remix-server-runtime/CHANGELOG.md
index 77178c138..a38b9875b 100644
--- a/packages/remix-server-runtime/CHANGELOG.md
+++ b/packages/remix-server-runtime/CHANGELOG.md
@@ -1,5 +1,19 @@
 # `@remix-run/server-runtime`
 
+## 1.6.8
+
+### Patch Changes
+
+- We've added type safety for load context. `AppLoadContext` is now an an interface mapping `string` to `unknown`, allowing users to extend it via module augmentation: ([#1876](https://github.com/remix-run/remix/pull/1876))
+
+  ```ts
+  declare module "@remix-run/server-runtime" {
+    interface AppLoadContext {
+      // add custom properties here!
+    }
+  }
+  ```
+
 ## 1.6.7
 
 No significant changes to this package were made in this release. [See the releases page on GitHub](https://github.com/remix-run/remix/releases/tag/remix%401.6.7) for an overview of all changes in v1.6.7.
diff --git a/packages/remix-server-runtime/package.json b/packages/remix-server-runtime/package.json
index 98814f245..16218f518 100644
--- a/packages/remix-server-runtime/package.json
+++ b/packages/remix-server-runtime/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@remix-run/server-runtime",
-  "version": "1.6.7",
+  "version": "1.6.8",
   "description": "Server runtime for Remix",
   "bugs": {
     "url": "https://github.com/remix-run/remix/issues"
diff --git a/packages/remix-vercel/CHANGELOG.md b/packages/remix-vercel/CHANGELOG.md
index 8ab1d6db7..3d16c6488 100644
--- a/packages/remix-vercel/CHANGELOG.md
+++ b/packages/remix-vercel/CHANGELOG.md
@@ -1,5 +1,12 @@
 # `@remix-run/vercel`
 
+## 1.6.8
+
+### Patch Changes
+
+- Updated dependencies:
+  - `@remix-run/node@1.6.8`
+
 ## 1.6.7
 
 ### Patch Changes
diff --git a/packages/remix-vercel/package.json b/packages/remix-vercel/package.json
index c041fed96..a358f9122 100644
--- a/packages/remix-vercel/package.json
+++ b/packages/remix-vercel/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@remix-run/vercel",
-  "version": "1.6.7",
+  "version": "1.6.8",
   "description": "Vercel server request handler for Remix",
   "bugs": {
     "url": "https://github.com/remix-run/remix/issues"
@@ -14,7 +14,7 @@
   "main": "dist/index.js",
   "typings": "dist/index.d.ts",
   "dependencies": {
-    "@remix-run/node": "1.6.7"
+    "@remix-run/node": "1.6.8"
   },
   "devDependencies": {
     "@types/supertest": "^2.0.10",
diff --git a/packages/remix/package.json b/packages/remix/package.json
index be07ca22a..c85bd9a64 100644
--- a/packages/remix/package.json
+++ b/packages/remix/package.json
@@ -1,6 +1,6 @@
 {
   "name": "remix",
-  "version": "1.6.7",
+  "version": "1.6.8",
   "description": "A framework for building better websites",
   "homepage": "https://remix.run",
   "bugs": {
diff --git a/templates/arc/app/entry.client.tsx b/templates/arc/app/entry.client.tsx
index 3eec1fd0a..ffef95f66 100644
--- a/templates/arc/app/entry.client.tsx
+++ b/templates/arc/app/entry.client.tsx
@@ -1,4 +1,4 @@
 import { RemixBrowser } from "@remix-run/react";
-import { hydrate } from "react-dom";
+import { hydrateRoot } from "react-dom/client";
 
-hydrate(<RemixBrowser />, document);
+hydrateRoot(document, <RemixBrowser />);
diff --git a/templates/arc/app/entry.server.tsx b/templates/arc/app/entry.server.tsx
index aa0aa0978..92ac9fc51 100644
--- a/templates/arc/app/entry.server.tsx
+++ b/templates/arc/app/entry.server.tsx
@@ -1,6 +1,10 @@
+import { PassThrough } from "stream";
 import type { EntryContext } from "@remix-run/node";
+import { Response } from "@remix-run/node";
 import { RemixServer } from "@remix-run/react";
-import { renderToString } from "react-dom/server";
+import { renderToPipeableStream } from "react-dom/server";
+
+const ABORT_DELAY = 5000;
 
 export default function handleRequest(
   request: Request,
@@ -8,14 +12,37 @@ export default function handleRequest(
   responseHeaders: Headers,
   remixContext: EntryContext
 ) {
-  let markup = renderToString(
-    <RemixServer context={remixContext} url={request.url} />
-  );
+  return new Promise((resolve, reject) => {
+    let didError = false;
+
+    let { pipe, abort } = renderToPipeableStream(
+      <RemixServer context={remixContext} url={request.url} />,
+      {
+        onShellReady: () => {
+          let body = new PassThrough();
+
+          responseHeaders.set("Content-Type", "text/html");
+
+          resolve(
+            new Response(body, {
+              headers: responseHeaders,
+              status: didError ? 500 : responseStatusCode,
+            })
+          );
+
+          pipe(body);
+        },
+        onShellError: (err) => {
+          reject(err);
+        },
+        onError: (error) => {
+          didError = true;
 
-  responseHeaders.set("Content-Type", "text/html");
+          console.error(error);
+        },
+      }
+    );
 
-  return new Response("<!DOCTYPE html>" + markup, {
-    status: responseStatusCode,
-    headers: responseHeaders,
+    setTimeout(abort, ABORT_DELAY);
   });
 }
diff --git a/templates/arc/package.json b/templates/arc/package.json
index e6d67492d..6f5a82ecf 100644
--- a/templates/arc/package.json
+++ b/templates/arc/package.json
@@ -13,15 +13,15 @@
     "@remix-run/node": "*",
     "@remix-run/react": "*",
     "cross-env": "^7.0.3",
-    "react": "^17.0.2",
-    "react-dom": "^17.0.2"
+    "react": "^18.2.0",
+    "react-dom": "^18.2.0"
   },
   "devDependencies": {
     "@architect/architect": "^10.3.4",
     "@remix-run/dev": "*",
     "@remix-run/eslint-config": "*",
-    "@types/react": "^17.0.47",
-    "@types/react-dom": "^17.0.17",
+    "@types/react": "^18.0.15",
+    "@types/react-dom": "^18.0.6",
     "eslint": "^8.20.0",
     "npm-run-all": "^4.1.5",
     "typescript": "^4.7.4"
diff --git a/templates/deno/.vscode/settings.json b/templates/deno/.vscode/settings.json
index 2fe1308e4..e1533c2bb 100644
--- a/templates/deno/.vscode/settings.json
+++ b/templates/deno/.vscode/settings.json
@@ -1,5 +1,4 @@
 {
   "deno.enable": true,
-  "deno.importMap": "./.vscode/resolve_npm_imports.json",
   "deno.lint": true
 }
diff --git a/templates/deno/deno.json b/templates/deno/deno.json
new file mode 100644
index 000000000..67973e8d5
--- /dev/null
+++ b/templates/deno/deno.json
@@ -0,0 +1,6 @@
+{
+  "importMap": ".vscode/resolve_npm_imports.json",
+  "compilerOptions": {
+    "lib": ["dom", "dom.iterable", "dom.asynciterable", "deno.ns"]
+  }
+}
diff --git a/templates/express/app/entry.client.tsx b/templates/express/app/entry.client.tsx
index 3eec1fd0a..ffef95f66 100644
--- a/templates/express/app/entry.client.tsx
+++ b/templates/express/app/entry.client.tsx
@@ -1,4 +1,4 @@
 import { RemixBrowser } from "@remix-run/react";
-import { hydrate } from "react-dom";
+import { hydrateRoot } from "react-dom/client";
 
-hydrate(<RemixBrowser />, document);
+hydrateRoot(document, <RemixBrowser />);
diff --git a/templates/express/app/entry.server.tsx b/templates/express/app/entry.server.tsx
index aa0aa0978..92ac9fc51 100644
--- a/templates/express/app/entry.server.tsx
+++ b/templates/express/app/entry.server.tsx
@@ -1,6 +1,10 @@
+import { PassThrough } from "stream";
 import type { EntryContext } from "@remix-run/node";
+import { Response } from "@remix-run/node";
 import { RemixServer } from "@remix-run/react";
-import { renderToString } from "react-dom/server";
+import { renderToPipeableStream } from "react-dom/server";
+
+const ABORT_DELAY = 5000;
 
 export default function handleRequest(
   request: Request,
@@ -8,14 +12,37 @@ export default function handleRequest(
   responseHeaders: Headers,
   remixContext: EntryContext
 ) {
-  let markup = renderToString(
-    <RemixServer context={remixContext} url={request.url} />
-  );
+  return new Promise((resolve, reject) => {
+    let didError = false;
+
+    let { pipe, abort } = renderToPipeableStream(
+      <RemixServer context={remixContext} url={request.url} />,
+      {
+        onShellReady: () => {
+          let body = new PassThrough();
+
+          responseHeaders.set("Content-Type", "text/html");
+
+          resolve(
+            new Response(body, {
+              headers: responseHeaders,
+              status: didError ? 500 : responseStatusCode,
+            })
+          );
+
+          pipe(body);
+        },
+        onShellError: (err) => {
+          reject(err);
+        },
+        onError: (error) => {
+          didError = true;
 
-  responseHeaders.set("Content-Type", "text/html");
+          console.error(error);
+        },
+      }
+    );
 
-  return new Response("<!DOCTYPE html>" + markup, {
-    status: responseStatusCode,
-    headers: responseHeaders,
+    setTimeout(abort, ABORT_DELAY);
   });
 }
diff --git a/templates/express/package.json b/templates/express/package.json
index 006b83160..a9aeea77c 100644
--- a/templates/express/package.json
+++ b/templates/express/package.json
@@ -16,14 +16,14 @@
     "cross-env": "^7.0.3",
     "express": "^4.18.1",
     "morgan": "^1.10.0",
-    "react": "^17.0.2",
-    "react-dom": "^17.0.2"
+    "react": "^18.2.0",
+    "react-dom": "^18.2.0"
   },
   "devDependencies": {
     "@remix-run/dev": "*",
     "@remix-run/eslint-config": "*",
-    "@types/react": "^17.0.47",
-    "@types/react-dom": "^17.0.17",
+    "@types/react": "^18.0.15",
+    "@types/react-dom": "^18.0.6",
     "eslint": "^8.20.0",
     "nodemon": "^2.0.19",
     "npm-run-all": "^4.1.5",
diff --git a/templates/fly/app/entry.client.tsx b/templates/fly/app/entry.client.tsx
index 3eec1fd0a..ffef95f66 100644
--- a/templates/fly/app/entry.client.tsx
+++ b/templates/fly/app/entry.client.tsx
@@ -1,4 +1,4 @@
 import { RemixBrowser } from "@remix-run/react";
-import { hydrate } from "react-dom";
+import { hydrateRoot } from "react-dom/client";
 
-hydrate(<RemixBrowser />, document);
+hydrateRoot(document, <RemixBrowser />);
diff --git a/templates/fly/app/entry.server.tsx b/templates/fly/app/entry.server.tsx
index aa0aa0978..92ac9fc51 100644
--- a/templates/fly/app/entry.server.tsx
+++ b/templates/fly/app/entry.server.tsx
@@ -1,6 +1,10 @@
+import { PassThrough } from "stream";
 import type { EntryContext } from "@remix-run/node";
+import { Response } from "@remix-run/node";
 import { RemixServer } from "@remix-run/react";
-import { renderToString } from "react-dom/server";
+import { renderToPipeableStream } from "react-dom/server";
+
+const ABORT_DELAY = 5000;
 
 export default function handleRequest(
   request: Request,
@@ -8,14 +12,37 @@ export default function handleRequest(
   responseHeaders: Headers,
   remixContext: EntryContext
 ) {
-  let markup = renderToString(
-    <RemixServer context={remixContext} url={request.url} />
-  );
+  return new Promise((resolve, reject) => {
+    let didError = false;
+
+    let { pipe, abort } = renderToPipeableStream(
+      <RemixServer context={remixContext} url={request.url} />,
+      {
+        onShellReady: () => {
+          let body = new PassThrough();
+
+          responseHeaders.set("Content-Type", "text/html");
+
+          resolve(
+            new Response(body, {
+              headers: responseHeaders,
+              status: didError ? 500 : responseStatusCode,
+            })
+          );
+
+          pipe(body);
+        },
+        onShellError: (err) => {
+          reject(err);
+        },
+        onError: (error) => {
+          didError = true;
 
-  responseHeaders.set("Content-Type", "text/html");
+          console.error(error);
+        },
+      }
+    );
 
-  return new Response("<!DOCTYPE html>" + markup, {
-    status: responseStatusCode,
-    headers: responseHeaders,
+    setTimeout(abort, ABORT_DELAY);
   });
 }
diff --git a/templates/fly/package.json b/templates/fly/package.json
index f496f398e..9f1b5e1fe 100644
--- a/templates/fly/package.json
+++ b/templates/fly/package.json
@@ -11,14 +11,14 @@
     "@remix-run/node": "*",
     "@remix-run/react": "*",
     "@remix-run/serve": "*",
-    "react": "^17.0.2",
-    "react-dom": "^17.0.2"
+    "react": "^18.2.0",
+    "react-dom": "^18.2.0"
   },
   "devDependencies": {
     "@remix-run/dev": "*",
     "@remix-run/eslint-config": "*",
-    "@types/react": "^17.0.47",
-    "@types/react-dom": "^17.0.17",
+    "@types/react": "^18.0.15",
+    "@types/react-dom": "^18.0.6",
     "eslint": "^8.20.0",
     "typescript": "^4.7.4"
   },
diff --git a/templates/netlify/app/entry.client.tsx b/templates/netlify/app/entry.client.tsx
index 3eec1fd0a..ffef95f66 100644
--- a/templates/netlify/app/entry.client.tsx
+++ b/templates/netlify/app/entry.client.tsx
@@ -1,4 +1,4 @@
 import { RemixBrowser } from "@remix-run/react";
-import { hydrate } from "react-dom";
+import { hydrateRoot } from "react-dom/client";
 
-hydrate(<RemixBrowser />, document);
+hydrateRoot(document, <RemixBrowser />);
diff --git a/templates/netlify/app/entry.server.tsx b/templates/netlify/app/entry.server.tsx
index aa0aa0978..92ac9fc51 100644
--- a/templates/netlify/app/entry.server.tsx
+++ b/templates/netlify/app/entry.server.tsx
@@ -1,6 +1,10 @@
+import { PassThrough } from "stream";
 import type { EntryContext } from "@remix-run/node";
+import { Response } from "@remix-run/node";
 import { RemixServer } from "@remix-run/react";
-import { renderToString } from "react-dom/server";
+import { renderToPipeableStream } from "react-dom/server";
+
+const ABORT_DELAY = 5000;
 
 export default function handleRequest(
   request: Request,
@@ -8,14 +12,37 @@ export default function handleRequest(
   responseHeaders: Headers,
   remixContext: EntryContext
 ) {
-  let markup = renderToString(
-    <RemixServer context={remixContext} url={request.url} />
-  );
+  return new Promise((resolve, reject) => {
+    let didError = false;
+
+    let { pipe, abort } = renderToPipeableStream(
+      <RemixServer context={remixContext} url={request.url} />,
+      {
+        onShellReady: () => {
+          let body = new PassThrough();
+
+          responseHeaders.set("Content-Type", "text/html");
+
+          resolve(
+            new Response(body, {
+              headers: responseHeaders,
+              status: didError ? 500 : responseStatusCode,
+            })
+          );
+
+          pipe(body);
+        },
+        onShellError: (err) => {
+          reject(err);
+        },
+        onError: (error) => {
+          didError = true;
 
-  responseHeaders.set("Content-Type", "text/html");
+          console.error(error);
+        },
+      }
+    );
 
-  return new Response("<!DOCTYPE html>" + markup, {
-    status: responseStatusCode,
-    headers: responseHeaders,
+    setTimeout(abort, ABORT_DELAY);
   });
 }
diff --git a/templates/netlify/package.json b/templates/netlify/package.json
index c3498efce..0fe9398d1 100644
--- a/templates/netlify/package.json
+++ b/templates/netlify/package.json
@@ -12,15 +12,15 @@
     "@remix-run/node": "*",
     "@remix-run/react": "*",
     "cross-env": "^7.0.3",
-    "react": "^17.0.2",
-    "react-dom": "^17.0.2"
+    "react": "^18.2.0",
+    "react-dom": "^18.2.0"
   },
   "devDependencies": {
     "@remix-run/dev": "*",
     "@remix-run/eslint-config": "*",
     "@remix-run/serve": "*",
-    "@types/react": "^17.0.47",
-    "@types/react-dom": "^17.0.17",
+    "@types/react": "^18.0.15",
+    "@types/react-dom": "^18.0.6",
     "eslint": "^8.20.0",
     "typescript": "^4.7.4"
   },
diff --git a/templates/remix/app/entry.client.tsx b/templates/remix/app/entry.client.tsx
index 3eec1fd0a..ffef95f66 100644
--- a/templates/remix/app/entry.client.tsx
+++ b/templates/remix/app/entry.client.tsx
@@ -1,4 +1,4 @@
 import { RemixBrowser } from "@remix-run/react";
-import { hydrate } from "react-dom";
+import { hydrateRoot } from "react-dom/client";
 
-hydrate(<RemixBrowser />, document);
+hydrateRoot(document, <RemixBrowser />);
diff --git a/templates/remix/app/entry.server.tsx b/templates/remix/app/entry.server.tsx
index aa0aa0978..92ac9fc51 100644
--- a/templates/remix/app/entry.server.tsx
+++ b/templates/remix/app/entry.server.tsx
@@ -1,6 +1,10 @@
+import { PassThrough } from "stream";
 import type { EntryContext } from "@remix-run/node";
+import { Response } from "@remix-run/node";
 import { RemixServer } from "@remix-run/react";
-import { renderToString } from "react-dom/server";
+import { renderToPipeableStream } from "react-dom/server";
+
+const ABORT_DELAY = 5000;
 
 export default function handleRequest(
   request: Request,
@@ -8,14 +12,37 @@ export default function handleRequest(
   responseHeaders: Headers,
   remixContext: EntryContext
 ) {
-  let markup = renderToString(
-    <RemixServer context={remixContext} url={request.url} />
-  );
+  return new Promise((resolve, reject) => {
+    let didError = false;
+
+    let { pipe, abort } = renderToPipeableStream(
+      <RemixServer context={remixContext} url={request.url} />,
+      {
+        onShellReady: () => {
+          let body = new PassThrough();
+
+          responseHeaders.set("Content-Type", "text/html");
+
+          resolve(
+            new Response(body, {
+              headers: responseHeaders,
+              status: didError ? 500 : responseStatusCode,
+            })
+          );
+
+          pipe(body);
+        },
+        onShellError: (err) => {
+          reject(err);
+        },
+        onError: (error) => {
+          didError = true;
 
-  responseHeaders.set("Content-Type", "text/html");
+          console.error(error);
+        },
+      }
+    );
 
-  return new Response("<!DOCTYPE html>" + markup, {
-    status: responseStatusCode,
-    headers: responseHeaders,
+    setTimeout(abort, ABORT_DELAY);
   });
 }
diff --git a/templates/remix/package.json b/templates/remix/package.json
index 7769d3692..ede065d16 100644
--- a/templates/remix/package.json
+++ b/templates/remix/package.json
@@ -10,14 +10,14 @@
     "@remix-run/node": "*",
     "@remix-run/react": "*",
     "@remix-run/serve": "*",
-    "react": "^17.0.2",
-    "react-dom": "^17.0.2"
+    "react": "^18.2.0",
+    "react-dom": "^18.2.0"
   },
   "devDependencies": {
     "@remix-run/dev": "*",
     "@remix-run/eslint-config": "*",
-    "@types/react": "^17.0.47",
-    "@types/react-dom": "^17.0.17",
+    "@types/react": "^18.0.15",
+    "@types/react-dom": "^18.0.6",
     "eslint": "^8.20.0",
     "typescript": "^4.7.4"
   },
diff --git a/templates/vercel/app/entry.client.tsx b/templates/vercel/app/entry.client.tsx
index 3eec1fd0a..ffef95f66 100644
--- a/templates/vercel/app/entry.client.tsx
+++ b/templates/vercel/app/entry.client.tsx
@@ -1,4 +1,4 @@
 import { RemixBrowser } from "@remix-run/react";
-import { hydrate } from "react-dom";
+import { hydrateRoot } from "react-dom/client";
 
-hydrate(<RemixBrowser />, document);
+hydrateRoot(document, <RemixBrowser />);
diff --git a/templates/vercel/app/entry.server.tsx b/templates/vercel/app/entry.server.tsx
index aa0aa0978..92ac9fc51 100644
--- a/templates/vercel/app/entry.server.tsx
+++ b/templates/vercel/app/entry.server.tsx
@@ -1,6 +1,10 @@
+import { PassThrough } from "stream";
 import type { EntryContext } from "@remix-run/node";
+import { Response } from "@remix-run/node";
 import { RemixServer } from "@remix-run/react";
-import { renderToString } from "react-dom/server";
+import { renderToPipeableStream } from "react-dom/server";
+
+const ABORT_DELAY = 5000;
 
 export default function handleRequest(
   request: Request,
@@ -8,14 +12,37 @@ export default function handleRequest(
   responseHeaders: Headers,
   remixContext: EntryContext
 ) {
-  let markup = renderToString(
-    <RemixServer context={remixContext} url={request.url} />
-  );
+  return new Promise((resolve, reject) => {
+    let didError = false;
+
+    let { pipe, abort } = renderToPipeableStream(
+      <RemixServer context={remixContext} url={request.url} />,
+      {
+        onShellReady: () => {
+          let body = new PassThrough();
+
+          responseHeaders.set("Content-Type", "text/html");
+
+          resolve(
+            new Response(body, {
+              headers: responseHeaders,
+              status: didError ? 500 : responseStatusCode,
+            })
+          );
+
+          pipe(body);
+        },
+        onShellError: (err) => {
+          reject(err);
+        },
+        onError: (error) => {
+          didError = true;
 
-  responseHeaders.set("Content-Type", "text/html");
+          console.error(error);
+        },
+      }
+    );
 
-  return new Response("<!DOCTYPE html>" + markup, {
-    status: responseStatusCode,
-    headers: responseHeaders,
+    setTimeout(abort, ABORT_DELAY);
   });
 }
diff --git a/templates/vercel/package.json b/templates/vercel/package.json
index a54f1db14..dfba761d1 100644
--- a/templates/vercel/package.json
+++ b/templates/vercel/package.json
@@ -10,15 +10,15 @@
     "@remix-run/react": "*",
     "@remix-run/vercel": "*",
     "@vercel/node": "^2.4.4",
-    "react": "^17.0.2",
-    "react-dom": "^17.0.2"
+    "react": "^18.2.0",
+    "react-dom": "^18.2.0"
   },
   "devDependencies": {
     "@remix-run/dev": "*",
     "@remix-run/eslint-config": "*",
     "@remix-run/serve": "*",
-    "@types/react": "^17.0.47",
-    "@types/react-dom": "^17.0.17",
+    "@types/react": "^18.0.15",
+    "@types/react-dom": "^18.0.6",
     "eslint": "^8.20.0",
     "typescript": "^4.7.4"
   },


Test patch:
diff --git a/.github/workflows/release-test.yml b/.github/workflows/release-test.yml
index 2d2f7dc4d..e15c4b4e3 100644
--- a/.github/workflows/release-test.yml
+++ b/.github/workflows/release-test.yml
@@ -1,11 +1,11 @@
-name: 🧪 Test
+name: 🧪 Test (release)
 
 on:
   push:
     branches:
       - release-*
-    tags-ignore:
-      - v*
+    tags:
+      - "v0.0.0-nightly-*"
     paths-ignore:
       - "docs/**"
       - "scripts/**"
diff --git a/.github/workflows/reusable-test.yml b/.github/workflows/reusable-test.yml
index 835b13a38..1bf11c63d 100644
--- a/.github/workflows/reusable-test.yml
+++ b/.github/workflows/reusable-test.yml
@@ -1,4 +1,4 @@
-name: 🧪 Test
+name: 🧪 Test (shared)
 
 on:
   workflow_call:
@@ -137,7 +137,7 @@ jobs:
         id: playwright-version
         shell: bash
         run: |
-          playwright_version=$(npm info @playwright/test version)
+          playwright_version=$(node -e "console.log(require('@playwright/test/package.json').version)")
           echo "::set-output name=version::${playwright_version}"
 
       - name: 🤖 Cache Playwright binaries
diff --git a/integration/deterministic-build-output-test.ts b/integration/deterministic-build-output-test.ts
index f71371749..8ecf888bb 100644
--- a/integration/deterministic-build-output-test.ts
+++ b/integration/deterministic-build-output-test.ts
@@ -3,11 +3,35 @@ import globby from "globby";
 import fs from "fs";
 import path from "path";
 
-import { createFixtureProject } from "./helpers/create-fixture";
+import { createFixtureProject, js } from "./helpers/create-fixture";
 
 test("builds deterministically under different paths", async () => {
-  let dir1 = await createFixtureProject();
-  let dir2 = await createFixtureProject();
+  // This test validates various flavors of remix virtual modules to ensure
+  // we get identical builds regardless of the parent paths. If a virtual
+  // module resolves or imports from absolute paths (e.g. via `path.resolve`),
+  // the build hashes may change even though the output is identical. This
+  // can cause broken apps (i.e. manifest mismatch) if the server and client
+  // are built separately.
+
+  // Virtual modules tested:
+  //  * browserRouteModulesPlugin (implicitly tested by root route)
+  //  * emptyModulesPlugin (via app/routes/foo.tsx' server import)
+  //  * mdx (via app/routes/index.mdx)
+  //  * serverAssetsManifestPlugin (implicitly tested by build)
+  //  * serverEntryModulePlugin (implicitly tested by build)
+  //  * serverRouteModulesPlugin (implicitly tested by build)
+  let init = {
+    files: {
+      "app/routes/index.mdx": "# hello world",
+      "app/routes/foo.tsx": js`
+        export * from "~/foo/bar.server";
+        export default () => "YAY";
+      `,
+      "app/foo/bar.server.ts": "export const meta = () => []",
+    },
+  };
+  let dir1 = await createFixtureProject(init);
+  let dir2 = await createFixtureProject(init);
 
   expect(dir1).not.toEqual(dir2);
 
diff --git a/integration/form-test.ts b/integration/form-test.ts
index ab7cfd5de..e66c2e0d9 100644
--- a/integration/form-test.ts
+++ b/integration/form-test.ts
@@ -20,22 +20,27 @@ test.describe("Forms", () => {
   let ACTION = "action";
   let EAT = "EAT";
 
+  let STATIC_ROUTE_NO_ACTION = "static-route-none";
   let STATIC_ROUTE_ABSOLUTE_ACTION = "static-route-abs";
   let STATIC_ROUTE_CURRENT_ACTION = "static-route-cur";
   let STATIC_ROUTE_PARENT_ACTION = "static-route-parent";
   let STATIC_ROUTE_TOO_MANY_DOTS_ACTION = "static-route-too-many-dots";
+  let INDEX_ROUTE_NO_ACTION = "index-route-none";
   let INDEX_ROUTE_ABSOLUTE_ACTION = "index-route-abs";
   let INDEX_ROUTE_CURRENT_ACTION = "index-route-cur";
   let INDEX_ROUTE_PARENT_ACTION = "index-route-parent";
   let INDEX_ROUTE_TOO_MANY_DOTS_ACTION = "index-route-too-many-dots";
+  let DYNAMIC_ROUTE_NO_ACTION = "dynamic-route-none";
   let DYNAMIC_ROUTE_ABSOLUTE_ACTION = "dynamic-route-abs";
   let DYNAMIC_ROUTE_CURRENT_ACTION = "dynamic-route-cur";
   let DYNAMIC_ROUTE_PARENT_ACTION = "dynamic-route-parent";
   let DYNAMIC_ROUTE_TOO_MANY_DOTS_ACTION = "dynamic-route-too-many-dots";
+  let LAYOUT_ROUTE_NO_ACTION = "layout-route-none";
   let LAYOUT_ROUTE_ABSOLUTE_ACTION = "layout-route-abs";
   let LAYOUT_ROUTE_CURRENT_ACTION = "layout-route-cur";
   let LAYOUT_ROUTE_PARENT_ACTION = "layout-route-parent";
   let LAYOUT_ROUTE_TOO_MANY_DOTS_ACTION = "layout-route-too-many-dots";
+  let SPLAT_ROUTE_NO_ACTION = "splat-route-none";
   let SPLAT_ROUTE_ABSOLUTE_ACTION = "splat-route-abs";
   let SPLAT_ROUTE_CURRENT_ACTION = "splat-route-cur";
   let SPLAT_ROUTE_PARENT_ACTION = "splat-route-parent";
@@ -131,6 +136,9 @@ test.describe("Forms", () => {
           export default function() {
             return (
               <>
+                <Form id="${STATIC_ROUTE_NO_ACTION}">
+                  <button>Submit</button>
+                </Form>
                 <Form id="${STATIC_ROUTE_ABSOLUTE_ACTION}" action="/about">
                   <button>Submit</button>
                 </Form>
@@ -154,6 +162,9 @@ test.describe("Forms", () => {
             return (
               <>
                 <h1>Blog</h1>
+                <Form id="${LAYOUT_ROUTE_NO_ACTION}">
+                  <button>Submit</button>
+                </Form>
                 <Form id="${LAYOUT_ROUTE_ABSOLUTE_ACTION}" action="/about">
                   <button>Submit</button>
                 </Form>
@@ -177,6 +188,9 @@ test.describe("Forms", () => {
           export default function() {
             return (
               <>
+                <Form id="${INDEX_ROUTE_NO_ACTION}">
+                  <button>Submit</button>
+                </Form>
                 <Form id="${INDEX_ROUTE_ABSOLUTE_ACTION}" action="/about">
                   <button>Submit</button>
                 </Form>
@@ -199,6 +213,9 @@ test.describe("Forms", () => {
           export default function() {
             return (
               <>
+                <Form id="${DYNAMIC_ROUTE_NO_ACTION}">
+                  <button>Submit</button>
+                </Form>
                 <Form id="${DYNAMIC_ROUTE_ABSOLUTE_ACTION}" action="/about">
                   <button>Submit</button>
                 </Form>
@@ -239,6 +256,9 @@ test.describe("Forms", () => {
           export default function() {
             return (
               <>
+                <Form id="${SPLAT_ROUTE_NO_ACTION}">
+                  <button>Submit</button>
+                </Form>
                 <Form id="${SPLAT_ROUTE_ABSOLUTE_ACTION}" action="/about">
                   <button>Submit</button>
                 </Form>
@@ -277,6 +297,29 @@ test.describe("Forms", () => {
             )
           }
         `,
+
+        "app/routes/submitter.jsx": js`
+          import { useLoaderData, Form } from "@remix-run/react";
+
+          export function loader({ request }) {
+            let url = new URL(request.url);
+            return url.searchParams.toString()
+          }
+
+          export default function() {
+            let data = useLoaderData();
+            return (
+              <Form>
+                <input type="text" name="tasks" defaultValue="first" />
+                <input type="text" name="tasks" defaultValue="second" />
+                <button type="submit" name="tasks" value="">
+                  Add Task
+                </button>
+                <pre>{data}</pre>
+              </Form>
+            )
+          }
+        `,
       },
     });
 
@@ -366,6 +409,26 @@ test.describe("Forms", () => {
 
   test.describe("<Form> action", () => {
     test.describe("in a static route", () => {
+      test("no action resolves relative to the closest route", async ({
+        page,
+      }) => {
+        let app = new PlaywrightFixture(appFixture, page);
+        await app.goto("/inbox");
+        let html = await app.getHtml();
+        let el = getElement(html, `#${STATIC_ROUTE_NO_ACTION}`);
+        expect(el.attr("action")).toMatch("/inbox");
+      });
+
+      test("no action resolves to URL including search params", async ({
+        page,
+      }) => {
+        let app = new PlaywrightFixture(appFixture, page);
+        await app.goto("/inbox?foo=bar");
+        let html = await app.getHtml();
+        let el = getElement(html, `#${STATIC_ROUTE_NO_ACTION}`);
+        expect(el.attr("action")).toMatch("/inbox?foo=bar");
+      });
+
       test("absolute action resolves relative to the root route", async ({
         page,
       }) => {
@@ -376,7 +439,7 @@ test.describe("Forms", () => {
         expect(el.attr("action")).toMatch("/about");
       });
 
-      test("'.' action resolves relative to the current route", async ({
+      test("'.' action resolves relative to the closest route", async ({
         page,
       }) => {
         let app = new PlaywrightFixture(appFixture, page);
@@ -386,6 +449,14 @@ test.describe("Forms", () => {
         expect(el.attr("action")).toMatch("/inbox");
       });
 
+      test("'.' excludes search params", async ({ page }) => {
+        let app = new PlaywrightFixture(appFixture, page);
+        await app.goto("/inbox?foo=bar");
+        let html = await app.getHtml();
+        let el = getElement(html, `#${STATIC_ROUTE_CURRENT_ACTION}`);
+        expect(el.attr("action")).toMatch("/inbox");
+      });
+
       test("'..' action resolves relative to the parent route", async ({
         page,
       }) => {
@@ -408,6 +479,26 @@ test.describe("Forms", () => {
     });
 
     test.describe("in a dynamic route", () => {
+      test("no action resolves relative to the closest route", async ({
+        page,
+      }) => {
+        let app = new PlaywrightFixture(appFixture, page);
+        await app.goto("/blog/abc");
+        let html = await app.getHtml();
+        let el = getElement(html, `#${DYNAMIC_ROUTE_NO_ACTION}`);
+        expect(el.attr("action")).toMatch("/blog/abc");
+      });
+
+      test("no action resolves to URL including search params", async ({
+        page,
+      }) => {
+        let app = new PlaywrightFixture(appFixture, page);
+        await app.goto("/blog/abc?foo=bar");
+        let html = await app.getHtml();
+        let el = getElement(html, `#${DYNAMIC_ROUTE_NO_ACTION}`);
+        expect(el.attr("action")).toMatch("/blog/abc?foo=bar");
+      });
+
       test("absolute action resolves relative to the root route", async ({
         page,
       }) => {
@@ -418,7 +509,7 @@ test.describe("Forms", () => {
         expect(el.attr("action")).toMatch("/about");
       });
 
-      test("'.' action resolves relative to the current route", async ({
+      test("'.' action resolves relative to the closest route", async ({
         page,
       }) => {
         let app = new PlaywrightFixture(appFixture, page);
@@ -428,6 +519,14 @@ test.describe("Forms", () => {
         expect(el.attr("action")).toMatch("/blog/abc");
       });
 
+      test("'.' excludes search params", async ({ page }) => {
+        let app = new PlaywrightFixture(appFixture, page);
+        await app.goto("/blog/abc?foo=bar");
+        let html = await app.getHtml();
+        let el = getElement(html, `#${DYNAMIC_ROUTE_CURRENT_ACTION}`);
+        expect(el.attr("action")).toMatch("/blog/abc");
+      });
+
       test("'..' action resolves relative to the parent route", async ({
         page,
       }) => {
@@ -450,6 +549,26 @@ test.describe("Forms", () => {
     });
 
     test.describe("in an index route", () => {
+      test("no action resolves relative to the closest route", async ({
+        page,
+      }) => {
+        let app = new PlaywrightFixture(appFixture, page);
+        await app.goto("/blog");
+        let html = await app.getHtml();
+        let el = getElement(html, `#${INDEX_ROUTE_NO_ACTION}`);
+        expect(el.attr("action")).toMatch("/blog");
+      });
+
+      test("no action resolves to URL including search params", async ({
+        page,
+      }) => {
+        let app = new PlaywrightFixture(appFixture, page);
+        await app.goto("/blog?foo=bar");
+        let html = await app.getHtml();
+        let el = getElement(html, `#${INDEX_ROUTE_NO_ACTION}`);
+        expect(el.attr("action")).toMatch("/blog?index&foo=bar");
+      });
+
       test("absolute action resolves relative to the root route", async ({
         page,
       }) => {
@@ -460,7 +579,7 @@ test.describe("Forms", () => {
         expect(el.attr("action")).toMatch("/about");
       });
 
-      test("'.' action resolves relative to the current route", async ({
+      test("'.' action resolves relative to the closest route", async ({
         page,
       }) => {
         let app = new PlaywrightFixture(appFixture, page);
@@ -470,6 +589,14 @@ test.describe("Forms", () => {
         expect(el.attr("action")).toMatch("/blog");
       });
 
+      test("'.' excludes search params", async ({ page }) => {
+        let app = new PlaywrightFixture(appFixture, page);
+        await app.goto("/blog?foo=bar");
+        let html = await app.getHtml();
+        let el = getElement(html, `#${INDEX_ROUTE_CURRENT_ACTION}`);
+        expect(el.attr("action")).toMatch("/blog");
+      });
+
       test("'..' action resolves relative to the parent route", async ({
         page,
       }) => {
@@ -492,6 +619,26 @@ test.describe("Forms", () => {
     });
 
     test.describe("in a layout route", () => {
+      test("no action resolves relative to the closest route", async ({
+        page,
+      }) => {
+        let app = new PlaywrightFixture(appFixture, page);
+        await app.goto("/blog");
+        let html = await app.getHtml();
+        let el = getElement(html, `#${LAYOUT_ROUTE_NO_ACTION}`);
+        expect(el.attr("action")).toMatch("/blog");
+      });
+
+      test("no action resolves to URL including search params", async ({
+        page,
+      }) => {
+        let app = new PlaywrightFixture(appFixture, page);
+        await app.goto("/blog?foo=bar");
+        let html = await app.getHtml();
+        let el = getElement(html, `#${LAYOUT_ROUTE_NO_ACTION}`);
+        expect(el.attr("action")).toMatch("/blog?foo=bar");
+      });
+
       test("absolute action resolves relative to the root route", async ({
         page,
       }) => {
@@ -502,7 +649,7 @@ test.describe("Forms", () => {
         expect(el.attr("action")).toMatch("/about");
       });
 
-      test("'.' action resolves relative to the current route", async ({
+      test("'.' action resolves relative to the closest route", async ({
         page,
       }) => {
         let app = new PlaywrightFixture(appFixture, page);
@@ -512,6 +659,14 @@ test.describe("Forms", () => {
         expect(el.attr("action")).toMatch("/blog");
       });
 
+      test("'.' excludes search params", async ({ page }) => {
+        let app = new PlaywrightFixture(appFixture, page);
+        await app.goto("/blog?foo=bar");
+        let html = await app.getHtml();
+        let el = getElement(html, `#${LAYOUT_ROUTE_CURRENT_ACTION}`);
+        expect(el.attr("action")).toMatch("/blog");
+      });
+
       test("'..' action resolves relative to the parent route", async ({
         page,
       }) => {
@@ -534,6 +689,26 @@ test.describe("Forms", () => {
     });
 
     test.describe("in a splat route", () => {
+      test("no action resolves relative to the closest route", async ({
+        page,
+      }) => {
+        let app = new PlaywrightFixture(appFixture, page);
+        await app.goto("/projects/blarg");
+        let html = await app.getHtml();
+        let el = getElement(html, `#${SPLAT_ROUTE_NO_ACTION}`);
+        expect(el.attr("action")).toMatch("/projects");
+      });
+
+      test("no action resolves to URL including search params", async ({
+        page,
+      }) => {
+        let app = new PlaywrightFixture(appFixture, page);
+        await app.goto("/projects/blarg?foo=bar");
+        let html = await app.getHtml();
+        let el = getElement(html, `#${SPLAT_ROUTE_NO_ACTION}`);
+        expect(el.attr("action")).toMatch("/projects?foo=bar");
+      });
+
       test("absolute action resolves relative to the root route", async ({
         page,
       }) => {
@@ -544,7 +719,7 @@ test.describe("Forms", () => {
         expect(el.attr("action")).toMatch("/about");
       });
 
-      test("'.' action resolves relative to the current route", async ({
+      test("'.' action resolves relative to the closest route", async ({
         page,
       }) => {
         let app = new PlaywrightFixture(appFixture, page);
@@ -554,6 +729,14 @@ test.describe("Forms", () => {
         expect(el.attr("action")).toMatch("/projects");
       });
 
+      test("'.' excludes search params", async ({ page }) => {
+        let app = new PlaywrightFixture(appFixture, page);
+        await app.goto("/projects/blarg?foo=bar");
+        let html = await app.getHtml();
+        let el = getElement(html, `#${SPLAT_ROUTE_CURRENT_ACTION}`);
+        expect(el.attr("action")).toMatch("/projects");
+      });
+
       test("'..' action resolves relative to the parent route", async ({
         page,
       }) => {
@@ -575,4 +758,16 @@ test.describe("Forms", () => {
       });
     });
   });
+
+  test("<Form> submits the submitter's value appended to the form data", async ({
+    page,
+  }) => {
+    let app = new PlaywrightFixture(appFixture, page);
+    await app.goto("/submitter");
+    await app.clickElement("text=Add Task");
+    await page.waitForLoadState("load");
+    expect(await app.getHtml("pre")).toBe(
+      `<pre>tasks=first&amp;tasks=second&amp;tasks=</pre>`
+    );
+  });
 });
diff --git a/integration/hook-useSubmit-test.ts b/integration/hook-useSubmit-test.ts
new file mode 100644
index 000000000..000406a0a
--- /dev/null
+++ b/integration/hook-useSubmit-test.ts
@@ -0,0 +1,64 @@
+import { test, expect } from "@playwright/test";
+
+import { createAppFixture, createFixture, js } from "./helpers/create-fixture";
+import type { Fixture, AppFixture } from "./helpers/create-fixture";
+import { PlaywrightFixture } from "./helpers/playwright-fixture";
+
+test.describe("`useSubmit()` returned function", () => {
+  let fixture: Fixture;
+  let appFixture: AppFixture;
+
+  test.beforeAll(async () => {
+    fixture = await createFixture({
+      files: {
+        "app/routes/index.jsx": js`
+        import { useLoaderData, useSubmit } from "@remix-run/react";
+
+        export function loader({ request }) {
+          let url = new URL(request.url);
+          return url.searchParams.toString()
+        }
+
+        export default function Index() {
+          let submit = useSubmit();
+          let handleClick = event => {
+            event.preventDefault()
+            submit(event.nativeEvent.submitter || event.currentTarget)
+          }
+          let data = useLoaderData();
+          return (
+            <form>
+              <input type="text" name="tasks" defaultValue="first" />
+              <input type="text" name="tasks" defaultValue="second" />
+
+              <button onClick={handleClick} name="tasks" value="third">
+                Prepare Third Task
+              </button>
+
+              <pre>{data}</pre>
+            </form>
+          )
+        }
+      `,
+      },
+    });
+
+    appFixture = await createAppFixture(fixture);
+  });
+
+  test.afterAll(async () => {
+    await appFixture.close();
+  });
+
+  test("submits the submitter's value appended to the form data", async ({
+    page,
+  }) => {
+    let app = new PlaywrightFixture(appFixture, page);
+    await app.goto("/");
+    await app.clickElement("text=Prepare Third Task");
+    await page.waitForLoadState("load");
+    expect(await app.getHtml("pre")).toBe(
+      `<pre>tasks=first&amp;tasks=second&amp;tasks=third</pre>`
+    );
+  });
+});
diff --git a/integration/upload-test.ts b/integration/upload-test.ts
index b3b88909a..208d44241 100644
--- a/integration/upload-test.ts
+++ b/integration/upload-test.ts
@@ -54,7 +54,7 @@ test.beforeAll(async () => {
             return json({ message: "ERROR" }, 500);
           }
         };
-        
+
         export default function FileUpload() {
           let { message, size } = useActionData() || {};
           return (
@@ -71,7 +71,7 @@ test.beforeAll(async () => {
               </Form>
             </main>
           );
-        }      
+        }
       `,
 
       "app/routes/memory-upload-handler.jsx": js`
@@ -109,7 +109,7 @@ test.beforeAll(async () => {
             return json({ message: "ERROR" }, 500);
           }
         };
-        
+
         export default function MemoryUpload() {
           let { message, size } = useActionData() || {};
           return (
@@ -126,7 +126,7 @@ test.beforeAll(async () => {
               </Form>
             </main>
           );
-        }      
+        }
       `,
 
       "app/routes/passthrough-upload-handler.jsx": js`
@@ -150,7 +150,7 @@ test.beforeAll(async () => {
             );
           }
         };
-        
+
         export default function PassthroughUpload() {
           let { message, size } = useActionData() || {};
           return (
@@ -167,7 +167,7 @@ test.beforeAll(async () => {
               </Form>
             </main>
           );
-        }      
+        }
       `,
     },
   });
@@ -175,7 +175,7 @@ test.beforeAll(async () => {
   appFixture = await createAppFixture(fixture);
 });
 
-test.afterAll(async () => appFixture.close());
+test.afterAll(() => appFixture.close());
 
 test("can upload a file with createFileUploadHandler", async ({ page }) => {
   let app = new PlaywrightFixture(appFixture, page);
@@ -190,6 +190,7 @@ test("can upload a file with createFileUploadHandler", async ({ page }) => {
 test("can catch MaxPartSizeExceededError when file is too big with createFileUploadHandler", async ({
   page,
 }) => {
+  test.slow();
   let app = new PlaywrightFixture(appFixture, page);
   await app.goto("/file-upload-handler");
   await app.uploadFile(
diff --git a/packages/remix-dev/__tests__/cli-test.ts b/packages/remix-dev/__tests__/cli-test.ts
index 979617aea..90ed4f9b7 100644
--- a/packages/remix-dev/__tests__/cli-test.ts
+++ b/packages/remix-dev/__tests__/cli-test.ts
@@ -5,6 +5,8 @@ import util from "util";
 import fse from "fs-extra";
 import semver from "semver";
 
+import { jestTimeout } from "./setupAfterEnv";
+
 let DOWN = "\x1B\x5B\x42";
 let ENTER = "\x0D";
 
@@ -94,6 +96,7 @@ describe("remix CLI", () => {
             $ remix build [projectDir]
             $ remix dev [projectDir]
             $ remix routes [projectDir]
+            $ remix watch [projectDir]
             $ remix setup [remixPlatform]
             $ remix migrate [-m migration] [projectDir]
 
@@ -169,6 +172,14 @@ describe("remix CLI", () => {
             $ remix dev my-app
             $ remix dev --debug
 
+          Start your server separately and watch for changes:
+
+            # custom server start command, for example:
+            $ remix watch
+
+            # in a separate tab:
+            $ node --inspect --require ./node_modules/dotenv/config --require ./mocks ./build/server.js
+
           Show all routes in your app:
 
             $ remix routes
@@ -343,7 +354,7 @@ async function interactWithShell(
       proc.kill();
       deferred.reject({ status: "timeout", stdout, stderr });
     }
-  }, 10_000);
+  }, jestTimeout);
 
   await deferred.promise;
   clearTimeout(timeout);
diff --git a/packages/remix-dev/__tests__/fixtures/replace-remix-imports/app/entry.client.tsx b/packages/remix-dev/__tests__/fixtures/replace-remix-imports/app/entry.client.tsx
index a19979b25..ffef95f66 100644
--- a/packages/remix-dev/__tests__/fixtures/replace-remix-imports/app/entry.client.tsx
+++ b/packages/remix-dev/__tests__/fixtures/replace-remix-imports/app/entry.client.tsx
@@ -1,4 +1,4 @@
-import { hydrate } from "react-dom";
-import { RemixBrowser } from "remix";
+import { RemixBrowser } from "@remix-run/react";
+import { hydrateRoot } from "react-dom/client";
 
-hydrate(<RemixBrowser />, document);
+hydrateRoot(document, <RemixBrowser />);
diff --git a/packages/remix-dev/__tests__/fixtures/replace-remix-imports/app/entry.server.tsx b/packages/remix-dev/__tests__/fixtures/replace-remix-imports/app/entry.server.tsx
index cae206749..92ac9fc51 100644
--- a/packages/remix-dev/__tests__/fixtures/replace-remix-imports/app/entry.server.tsx
+++ b/packages/remix-dev/__tests__/fixtures/replace-remix-imports/app/entry.server.tsx
@@ -1,6 +1,10 @@
-import { renderToString } from "react-dom/server";
-import { RemixServer } from "remix";
-import type { EntryContext } from "remix";
+import { PassThrough } from "stream";
+import type { EntryContext } from "@remix-run/node";
+import { Response } from "@remix-run/node";
+import { RemixServer } from "@remix-run/react";
+import { renderToPipeableStream } from "react-dom/server";
+
+const ABORT_DELAY = 5000;
 
 export default function handleRequest(
   request: Request,
@@ -8,14 +12,37 @@ export default function handleRequest(
   responseHeaders: Headers,
   remixContext: EntryContext
 ) {
-  const markup = renderToString(
-    <RemixServer context={remixContext} url={request.url} />
-  );
+  return new Promise((resolve, reject) => {
+    let didError = false;
+
+    let { pipe, abort } = renderToPipeableStream(
+      <RemixServer context={remixContext} url={request.url} />,
+      {
+        onShellReady: () => {
+          let body = new PassThrough();
+
+          responseHeaders.set("Content-Type", "text/html");
+
+          resolve(
+            new Response(body, {
+              headers: responseHeaders,
+              status: didError ? 500 : responseStatusCode,
+            })
+          );
+
+          pipe(body);
+        },
+        onShellError: (err) => {
+          reject(err);
+        },
+        onError: (error) => {
+          didError = true;
 
-  responseHeaders.set("Content-Type", "text/html");
+          console.error(error);
+        },
+      }
+    );
 
-  return new Response("<!DOCTYPE html>" + markup, {
-    status: responseStatusCode,
-    headers: responseHeaders,
+    setTimeout(abort, ABORT_DELAY);
   });
 }
diff --git a/packages/remix-dev/__tests__/migrations/convert-to-javascript-test.ts b/packages/remix-dev/__tests__/migrations/convert-to-javascript-test.ts
index 0fc1c3339..849d39b86 100644
--- a/packages/remix-dev/__tests__/migrations/convert-to-javascript-test.ts
+++ b/packages/remix-dev/__tests__/migrations/convert-to-javascript-test.ts
@@ -53,6 +53,7 @@ beforeEach(() => {
   removeSync(TEMP_DIR);
   ensureDirSync(TEMP_DIR);
 });
+
 afterEach(() => {
   console.log = ORIGINAL_IO.log;
   console.warn = ORIGINAL_IO.warn;
@@ -97,9 +98,7 @@ const checkMigrationRanSuccessfully = async (projectDir: string) => {
 
 const makeApp = () => {
   let projectDir = join(TEMP_DIR, "convert-to-javascript");
-
   copySync(FIXTURE, projectDir);
-
   return projectDir;
 };
 
diff --git a/packages/remix-dev/__tests__/migrations/fixtures/indie-stack/app/entry.client.tsx b/packages/remix-dev/__tests__/migrations/fixtures/indie-stack/app/entry.client.tsx
index 3eec1fd0a..ffef95f66 100644
--- a/packages/remix-dev/__tests__/migrations/fixtures/indie-stack/app/entry.client.tsx
+++ b/packages/remix-dev/__tests__/migrations/fixtures/indie-stack/app/entry.client.tsx
@@ -1,4 +1,4 @@
 import { RemixBrowser } from "@remix-run/react";
-import { hydrate } from "react-dom";
+import { hydrateRoot } from "react-dom/client";
 
-hydrate(<RemixBrowser />, document);
+hydrateRoot(document, <RemixBrowser />);
diff --git a/packages/remix-dev/__tests__/migrations/fixtures/indie-stack/app/entry.server.tsx b/packages/remix-dev/__tests__/migrations/fixtures/indie-stack/app/entry.server.tsx
index 5afa18235..92ac9fc51 100644
--- a/packages/remix-dev/__tests__/migrations/fixtures/indie-stack/app/entry.server.tsx
+++ b/packages/remix-dev/__tests__/migrations/fixtures/indie-stack/app/entry.server.tsx
@@ -1,6 +1,10 @@
+import { PassThrough } from "stream";
 import type { EntryContext } from "@remix-run/node";
+import { Response } from "@remix-run/node";
 import { RemixServer } from "@remix-run/react";
-import { renderToString } from "react-dom/server";
+import { renderToPipeableStream } from "react-dom/server";
+
+const ABORT_DELAY = 5000;
 
 export default function handleRequest(
   request: Request,
@@ -8,14 +12,37 @@ export default function handleRequest(
   responseHeaders: Headers,
   remixContext: EntryContext
 ) {
-  const markup = renderToString(
-    <RemixServer context={remixContext} url={request.url} />
-  );
+  return new Promise((resolve, reject) => {
+    let didError = false;
+
+    let { pipe, abort } = renderToPipeableStream(
+      <RemixServer context={remixContext} url={request.url} />,
+      {
+        onShellReady: () => {
+          let body = new PassThrough();
+
+          responseHeaders.set("Content-Type", "text/html");
+
+          resolve(
+            new Response(body, {
+              headers: responseHeaders,
+              status: didError ? 500 : responseStatusCode,
+            })
+          );
+
+          pipe(body);
+        },
+        onShellError: (err) => {
+          reject(err);
+        },
+        onError: (error) => {
+          didError = true;
 
-  responseHeaders.set("Content-Type", "text/html");
+          console.error(error);
+        },
+      }
+    );
 
-  return new Response("<!DOCTYPE html>" + markup, {
-    status: responseStatusCode,
-    headers: responseHeaders,
+    setTimeout(abort, ABORT_DELAY);
   });
 }
diff --git a/packages/remix-dev/__tests__/replace-remix-imports-test.ts b/packages/remix-dev/__tests__/replace-remix-imports-test.ts
index 3d635afd9..e897c3902 100644
--- a/packages/remix-dev/__tests__/replace-remix-imports-test.ts
+++ b/packages/remix-dev/__tests__/replace-remix-imports-test.ts
@@ -47,6 +47,7 @@ beforeEach(async () => {
   await fse.remove(TEMP_DIR);
   await fse.ensureDir(TEMP_DIR);
 });
+
 afterEach(async () => {
   console.log = ORIGINAL_IO.log;
   console.warn = ORIGINAL_IO.warn;
@@ -87,11 +88,18 @@ describe("`replace-remix-imports` migration", () => {
     );
     expect(packageJson.dependencies).not.toContain("remix");
     expect(packageJson.devDependencies).not.toContain("remix");
-    expect(packageJson.dependencies["@remix-run/react"]).toBe("1.3.4");
-    expect(packageJson.dependencies["@remix-run/node"]).toBe("1.3.4");
-    expect(packageJson.dependencies["@remix-run/serve"]).toBe("1.3.4");
-    expect(packageJson.devDependencies["@remix-run/dev"]).toBe("1.3.4");
-
+    expect(packageJson.dependencies).toEqual(
+      expect.objectContaining({
+        "@remix-run/node": "1.3.4",
+        "@remix-run/react": "1.3.4",
+        "@remix-run/serve": "1.3.4",
+      })
+    );
+    expect(packageJson.devDependencies).toEqual(
+      expect.objectContaining({
+        "@remix-run/dev": "1.3.4",
+      })
+    );
     expect(output).toContain(
       "🗑  I'm removing `remix setup` from your `postinstall` script."
     );
diff --git a/packages/remix-dev/__tests__/setupAfterEnv.ts b/packages/remix-dev/__tests__/setupAfterEnv.ts
index 2b1edd2ba..6a8ce7089 100644
--- a/packages/remix-dev/__tests__/setupAfterEnv.ts
+++ b/packages/remix-dev/__tests__/setupAfterEnv.ts
@@ -1,1 +1,3 @@
-jest.setTimeout(10_000);
+export let jestTimeout = process.platform === "win32" ? 20_000 : 10_000;
+
+jest.setTimeout(jestTimeout);
diff --git a/packages/remix-react/__tests__/transition-test.tsx b/packages/remix-react/__tests__/transition-test.tsx
index c4a37e78b..a0b87ed0e 100644
--- a/packages/remix-react/__tests__/transition-test.tsx
+++ b/packages/remix-react/__tests__/transition-test.tsx
@@ -28,7 +28,6 @@ describe("init", () => {
       actionData: { root: "ACTION DATA" },
       error: new Error("lol"),
       errorBoundaryId: "root",
-      onChange: () => {},
       onRedirect: () => {},
     });
     expect(tm.getState()).toMatchInlineSnapshot(`
@@ -1956,7 +1955,6 @@ function createTestTransitionManager(
     loaderData: { root: "ROOT" },
     location,
     routes: [],
-    onChange() {},
     onRedirect() {},
     ...init,
   });
@@ -2090,11 +2088,11 @@ let setup = ({ url } = { url: "/" }) => {
   ];
 
   let tm = createTestTransitionManager(url, {
-    onChange: handleChange,
     onRedirect: handleRedirect,
     loaderData: { root: "ROOT" },
     routes,
   });
+  tm.subscribe(handleChange);
 
   let navigate_ = (
     location: Location | string,