Repository: remix-run/remix
Pull request #3930 (2022-08-05 11:21:31)

feat(templates): update to React v18
Follow-up of #3917 

I didn't update Cloudflare & Deno templates as those weren't updated in #3917 by @jacob-ebey as well (see https://github.com/remix-run/remix/pull/3917#issuecomment-1206331413 for more questions about the reasoning behind this)

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

Latest commit: d9d133a64b3526b387f7b9b4e9bb5ae916e2bb04

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

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

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

</details>

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

[Click here if you're a maintainer who wants to add a changeset to this PR](https://github.com/MichaelDeBoey/remix/new/update-templates-to-react-18?filename=.changeset/blue-walls-provide.md&value=---%0A%0A---%0A%0Afeat(templates)%3A%20update%20to%20React%20v18%0A)



Patch:
diff --git a/.changeset/purple-ligers-drive.md b/.changeset/purple-ligers-drive.md
new file mode 100644
index 000000000..51ac8ce48
--- /dev/null
+++ b/.changeset/purple-ligers-drive.md
@@ -0,0 +1,5 @@
+---
+"@remix-run/dev": patch
+---
+
+Make MDX builds deterministic
diff --git a/.changeset/slow-apples-pretend.md b/.changeset/slow-apples-pretend.md
new file mode 100644
index 000000000..7547330d3
--- /dev/null
+++ b/.changeset/slow-apples-pretend.md
@@ -0,0 +1,5 @@
+---
+"@remix-run/react": patch
+---
+
+Add subscribe method to transition manager to allow subscribing and un-subscribing for React 18 strict mode compliance.
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/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-react/components.tsx b/packages/remix-react/components.tsx
index 3a6bef315..4627a44e0 100644
--- a/packages/remix-react/components.tsx
+++ b/packages/remix-react/components.tsx
@@ -49,7 +49,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 +122,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(() => {
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/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/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/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/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/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-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,