From 2c8eecd3788f38ad13c65cdb31fa4e29f16fb33a Mon Sep 17 00:00:00 2001
From: Matt Brophy <matt@brophy.org>
Date: Thu, 18 Jul 2024 09:39:53 -0400
Subject: [PATCH] Add support for replace() redirects (#9764)

---
 .changeset/curvy-vans-remember.md             |  9 +++
 integration/package.json                      |  2 +-
 integration/redirects-test.ts                 | 80 +++++++++++++++++++
 integration/single-fetch-test.ts              | 65 +++++++++++++++
 packages/remix-cloudflare/index.ts            |  1 +
 packages/remix-deno/index.ts                  |  1 +
 packages/remix-dev/package.json               |  2 +-
 packages/remix-node/index.ts                  |  1 +
 packages/remix-react/index.tsx                |  1 +
 packages/remix-react/package.json             |  6 +-
 packages/remix-react/routes.tsx               |  4 +
 packages/remix-react/single-fetch.tsx         |  3 +
 packages/remix-server-runtime/index.ts        |  2 +-
 packages/remix-server-runtime/package.json    |  2 +-
 packages/remix-server-runtime/responses.ts    | 11 +++
 packages/remix-server-runtime/single-fetch.ts |  2 +
 packages/remix-testing/package.json           |  4 +-
 pnpm-lock.yaml                                | 50 ++++++------
 18 files changed, 212 insertions(+), 34 deletions(-)
 create mode 100644 .changeset/curvy-vans-remember.md

diff --git a/.changeset/curvy-vans-remember.md b/.changeset/curvy-vans-remember.md
new file mode 100644
index 00000000000..9ce451a7ae6
--- /dev/null
+++ b/.changeset/curvy-vans-remember.md
@@ -0,0 +1,9 @@
+---
+"@remix-run/cloudflare": minor
+"@remix-run/deno": minor
+"@remix-run/node": minor
+"@remix-run/react": minor
+"@remix-run/server-runtime": minor
+---
+
+Add a new `replace(url, init?)` alternative to `redirect(url, init?)` that performs a `history.replaceState` instead of a `history.pushState` on client-side navigation redirects
diff --git a/integration/package.json b/integration/package.json
index afe282f095a..db21212335a 100644
--- a/integration/package.json
+++ b/integration/package.json
@@ -14,7 +14,7 @@
     "@remix-run/dev": "workspace:*",
     "@remix-run/express": "workspace:*",
     "@remix-run/node": "workspace:*",
-    "@remix-run/router": "1.18.0",
+    "@remix-run/router": "0.0.0-experimental-cffa549a1",
     "@remix-run/server-runtime": "workspace:*",
     "@types/express": "^4.17.9",
     "@vanilla-extract/css": "^1.10.0",
diff --git a/integration/redirects-test.ts b/integration/redirects-test.ts
index e9723bb8709..77f2fe49903 100644
--- a/integration/redirects-test.ts
+++ b/integration/redirects-test.ts
@@ -98,6 +98,34 @@ test.describe("redirects", () => {
             return <h1>Hello B!</h1>
           }
         `,
+
+        "app/routes/replace.a.tsx": js`
+          import { Link } from "@remix-run/react";
+          export default function () {
+            return <><h1 id="a">A</h1><Link to="/replace/b">Go to B</Link></>;
+          }
+        `,
+
+        "app/routes/replace.b.tsx": js`
+          import { Link } from "@remix-run/react";
+          export default function () {
+            return <><h1 id="b">B</h1><Link to="/replace/c">Go to C</Link></>
+          }
+        `,
+
+        "app/routes/replace.c.tsx": js`
+          import { replace } from "@remix-run/node";
+          export const loader = () => replace("/replace/d");
+          export default function () {
+            return <h1 id="c">C</h1>
+          }
+        `,
+
+        "app/routes/replace.d.tsx": js`
+          export default function () {
+            return <h1 id="d">D</h1>
+          }
+        `,
       },
     });
 
@@ -143,6 +171,18 @@ test.describe("redirects", () => {
     // Hard reload resets client side react state
     expect(await app.getHtml("button")).toMatch("Count:0");
   });
+
+  test("supports replace redirects within the app", async ({ page }) => {
+    let app = new PlaywrightFixture(appFixture, page);
+    await app.goto("/replace/a", true);
+    await page.waitForSelector("#a"); // [/a]
+    await app.clickLink("/replace/b");
+    await page.waitForSelector("#b"); // [/a, /b]
+    await app.clickLink("/replace/c");
+    await page.waitForSelector("#d"); // [/a, /d]
+    await page.goBack();
+    await page.waitForSelector("#a"); // [/a]
+  });
 });
 
 // Duplicate suite of the tests above running with single fetch enabled
@@ -243,6 +283,34 @@ test.describe("single fetch", () => {
               return <h1>Hello B!</h1>
             }
           `,
+
+          "app/routes/replace.a.tsx": js`
+            import { Link } from "@remix-run/react";
+            export default function () {
+              return <><h1 id="a">A</h1><Link to="/replace/b">Go to B</Link></>;
+            }
+          `,
+
+          "app/routes/replace.b.tsx": js`
+            import { Link } from "@remix-run/react";
+            export default function () {
+              return <><h1 id="b">B</h1><Link to="/replace/c">Go to C</Link></>
+            }
+          `,
+
+          "app/routes/replace.c.tsx": js`
+            import { replace } from "@remix-run/node";
+            export const loader = () => replace("/replace/d");
+            export default function () {
+              return <h1 id="c">C</h1>
+            }
+          `,
+
+          "app/routes/replace.d.tsx": js`
+            export default function () {
+              return <h1 id="d">D</h1>
+            }
+          `,
         },
       });
 
@@ -290,5 +358,17 @@ test.describe("single fetch", () => {
       // Hard reload resets client side react state
       expect(await app.getHtml("button")).toMatch("Count:0");
     });
+
+    test("supports replace redirects within the app", async ({ page }) => {
+      let app = new PlaywrightFixture(appFixture, page);
+      await app.goto("/replace/a", true);
+      await page.waitForSelector("#a"); // [/a]
+      await app.clickLink("/replace/b");
+      await page.waitForSelector("#b"); // [/a, /b]
+      await app.clickLink("/replace/c");
+      await page.waitForSelector("#d"); // [/a, /d]
+      await page.goBack();
+      await page.waitForSelector("#a"); // [/a]
+    });
   });
 });
diff --git a/integration/single-fetch-test.ts b/integration/single-fetch-test.ts
index 67d2189e205..7655d09d9eb 100644
--- a/integration/single-fetch-test.ts
+++ b/integration/single-fetch-test.ts
@@ -1009,6 +1009,7 @@ test.describe("single-fetch", () => {
         status: 302,
         redirect: "/target",
         reload: false,
+        replace: false,
         revalidate: false,
       },
     });
@@ -1148,6 +1149,7 @@ test.describe("single-fetch", () => {
         status: 302,
         redirect: "/target",
         reload: false,
+        replace: false,
         revalidate: false,
       },
     });
@@ -1280,6 +1282,7 @@ test.describe("single-fetch", () => {
         status: 302,
         redirect: "/target",
         reload: false,
+        replace: false,
         revalidate: false,
       },
     });
@@ -1329,6 +1332,62 @@ test.describe("single-fetch", () => {
         status: 302,
         redirect: "/target",
         reload: false,
+        replace: false,
+        revalidate: false,
+      },
+    });
+    expect(status).toBe(202);
+
+    let appFixture = await createAppFixture(fixture);
+    let app = new PlaywrightFixture(appFixture, page);
+    await app.goto("/");
+    await app.clickLink("/data");
+    await page.waitForSelector("#target");
+    expect(await app.getHtml("#target")).toContain("Target");
+  });
+
+  test("processes thrown loader replace redirects via Response", async ({
+    page,
+  }) => {
+    let fixture = await createFixture({
+      config: {
+        future: {
+          unstable_singleFetch: true,
+        },
+      },
+      files: {
+        ...files,
+        "app/routes/data.tsx": js`
+          import { replace } from '@remix-run/node';
+          export function loader() {
+            throw replace('/target');
+          }
+          export default function Component() {
+            return null
+          }
+        `,
+        "app/routes/target.tsx": js`
+          export default function Component() {
+            return <h1 id="target">Target</h1>
+          }
+        `,
+      },
+    });
+
+    console.error = () => {};
+
+    let res = await fixture.requestDocument("/data");
+    expect(res.status).toBe(302);
+    expect(res.headers.get("Location")).toBe("/target");
+    expect(await res.text()).toBe("");
+
+    let { status, data } = await fixture.requestSingleFetchData("/data.data");
+    expect(data).toEqual({
+      [SingleFetchRedirectSymbol]: {
+        status: 302,
+        redirect: "/target",
+        reload: false,
+        replace: true,
         revalidate: false,
       },
     });
@@ -1393,6 +1452,7 @@ test.describe("single-fetch", () => {
       status: 302,
       redirect: "/target",
       reload: false,
+      replace: false,
       revalidate: false,
     });
     expect(status).toBe(202);
@@ -1551,6 +1611,7 @@ test.describe("single-fetch", () => {
       status: 302,
       redirect: "/target",
       reload: false,
+      replace: false,
       revalidate: false,
     });
     expect(status).toBe(202);
@@ -1702,6 +1763,7 @@ test.describe("single-fetch", () => {
       status: 302,
       redirect: "/target",
       reload: false,
+      replace: false,
       revalidate: false,
     });
     expect(status).toBe(202);
@@ -1759,6 +1821,7 @@ test.describe("single-fetch", () => {
       status: 302,
       redirect: "/target",
       reload: false,
+      replace: false,
       revalidate: false,
     });
     expect(status).toBe(202);
@@ -1858,6 +1921,7 @@ test.describe("single-fetch", () => {
         status: 302,
         redirect: "/target",
         reload: false,
+        replace: false,
         revalidate: false,
       },
     });
@@ -1960,6 +2024,7 @@ test.describe("single-fetch", () => {
       status: 302,
       redirect: "/target",
       reload: false,
+      replace: false,
       revalidate: false,
     });
     expect(status).toBe(202);
diff --git a/packages/remix-cloudflare/index.ts b/packages/remix-cloudflare/index.ts
index a21c55b7516..22b878fd7f3 100644
--- a/packages/remix-cloudflare/index.ts
+++ b/packages/remix-cloudflare/index.ts
@@ -23,6 +23,7 @@ export {
   MaxPartSizeExceededError,
   redirect,
   redirectDocument,
+  replace,
   unstable_composeUploadHandlers,
   unstable_createMemoryUploadHandler,
   unstable_parseMultipartFormData,
diff --git a/packages/remix-deno/index.ts b/packages/remix-deno/index.ts
index 97fccb83d79..5652d74ac06 100644
--- a/packages/remix-deno/index.ts
+++ b/packages/remix-deno/index.ts
@@ -24,6 +24,7 @@ export {
   MaxPartSizeExceededError,
   redirect,
   redirectDocument,
+  replace,
   unstable_composeUploadHandlers,
   unstable_createMemoryUploadHandler,
   unstable_defineAction,
diff --git a/packages/remix-dev/package.json b/packages/remix-dev/package.json
index 26f81834309..3aa01012e73 100644
--- a/packages/remix-dev/package.json
+++ b/packages/remix-dev/package.json
@@ -32,7 +32,7 @@
     "@mdx-js/mdx": "^2.3.0",
     "@npmcli/package-json": "^4.0.1",
     "@remix-run/node": "workspace:*",
-    "@remix-run/router": "1.18.0",
+    "@remix-run/router": "0.0.0-experimental-cffa549a1",
     "@remix-run/server-runtime": "workspace:*",
     "@types/mdx": "^2.0.5",
     "@vanilla-extract/integration": "^6.2.0",
diff --git a/packages/remix-node/index.ts b/packages/remix-node/index.ts
index eaf64599f3c..262b1a67446 100644
--- a/packages/remix-node/index.ts
+++ b/packages/remix-node/index.ts
@@ -35,6 +35,7 @@ export {
   MaxPartSizeExceededError,
   redirect,
   redirectDocument,
+  replace,
   unstable_composeUploadHandlers,
   unstable_createMemoryUploadHandler,
   unstable_parseMultipartFormData,
diff --git a/packages/remix-react/index.tsx b/packages/remix-react/index.tsx
index 0211ee04297..5d1dda5bcb8 100644
--- a/packages/remix-react/index.tsx
+++ b/packages/remix-react/index.tsx
@@ -65,6 +65,7 @@ export {
   json,
   redirect,
   redirectDocument,
+  replace,
 } from "@remix-run/server-runtime";
 
 export type { RemixBrowserProps } from "./browser";
diff --git a/packages/remix-react/package.json b/packages/remix-react/package.json
index b15bcaf608e..1baa7a6c2f1 100644
--- a/packages/remix-react/package.json
+++ b/packages/remix-react/package.json
@@ -19,10 +19,10 @@
     "tsc": "tsc"
   },
   "dependencies": {
-    "@remix-run/router": "1.18.0",
+    "@remix-run/router": "0.0.0-experimental-cffa549a1",
     "@remix-run/server-runtime": "workspace:*",
-    "react-router": "6.25.0",
-    "react-router-dom": "6.25.0",
+    "react-router": "0.0.0-experimental-cffa549a1",
+    "react-router-dom": "0.0.0-experimental-cffa549a1",
     "turbo-stream": "2.2.0"
   },
   "devDependencies": {
diff --git a/packages/remix-react/routes.tsx b/packages/remix-react/routes.tsx
index bff888a6f26..0cd09619129 100644
--- a/packages/remix-react/routes.tsx
+++ b/packages/remix-react/routes.tsx
@@ -604,6 +604,10 @@ function getRedirect(response: Response): Response {
   if (reloadDocument) {
     headers["X-Remix-Reload-Document"] = reloadDocument;
   }
+  let replace = response.headers.get("X-Remix-Replace");
+  if (replace) {
+    headers["X-Remix-Replace"] = replace;
+  }
   return redirect(url, { status, headers });
 }
 
diff --git a/packages/remix-react/single-fetch.tsx b/packages/remix-react/single-fetch.tsx
index 1543dab05cf..e6c156efa95 100644
--- a/packages/remix-react/single-fetch.tsx
+++ b/packages/remix-react/single-fetch.tsx
@@ -399,6 +399,9 @@ function unwrapSingleFetchResult(result: SingleFetchResult, routeId: string) {
     if (result.reload) {
       headers["X-Remix-Reload-Document"] = "yes";
     }
+    if (result.replace) {
+      headers["X-Remix-Replace"] = "yes";
+    }
     return redirect(result.redirect, { status: result.status, headers });
   } else if ("data" in result) {
     return result.data;
diff --git a/packages/remix-server-runtime/index.ts b/packages/remix-server-runtime/index.ts
index 7a4d4cf9c14..c1fbfd564b2 100644
--- a/packages/remix-server-runtime/index.ts
+++ b/packages/remix-server-runtime/index.ts
@@ -4,7 +4,7 @@ export {
   composeUploadHandlers as unstable_composeUploadHandlers,
   parseMultipartFormData as unstable_parseMultipartFormData,
 } from "./formData";
-export { defer, json, redirect, redirectDocument } from "./responses";
+export { defer, json, redirect, redirectDocument, replace } from "./responses";
 
 export {
   SingleFetchRedirectSymbol as UNSAFE_SingleFetchRedirectSymbol,
diff --git a/packages/remix-server-runtime/package.json b/packages/remix-server-runtime/package.json
index 0e5634c1f82..148fd0f0460 100644
--- a/packages/remix-server-runtime/package.json
+++ b/packages/remix-server-runtime/package.json
@@ -19,7 +19,7 @@
     "tsc": "tsc"
   },
   "dependencies": {
-    "@remix-run/router": "1.18.0",
+    "@remix-run/router": "0.0.0-experimental-cffa549a1",
     "@types/cookie": "^0.6.0",
     "@web3-storage/multipart-parser": "^1.0.0",
     "cookie": "^0.6.0",
diff --git a/packages/remix-server-runtime/responses.ts b/packages/remix-server-runtime/responses.ts
index 32a609cd5cb..57b13d7e680 100644
--- a/packages/remix-server-runtime/responses.ts
+++ b/packages/remix-server-runtime/responses.ts
@@ -2,6 +2,7 @@ import {
   defer as routerDefer,
   json as routerJson,
   redirect as routerRedirect,
+  replace as routerReplace,
   redirectDocument as routerRedirectDocument,
   type UNSAFE_DeferredData as DeferredData,
   type TrackedPromise,
@@ -70,6 +71,16 @@ export const redirect: RedirectFunction = (url, init = 302) => {
   return routerRedirect(url, init) as TypedResponse<never>;
 };
 
+/**
+ * A redirect response. Sets the status code and the `Location` header.
+ * Defaults to "302 Found".
+ *
+ * @see https://remix.run/utils/redirect
+ */
+export const replace: RedirectFunction = (url, init = 302) => {
+  return routerReplace(url, init) as TypedResponse<never>;
+};
+
 /**
  * A redirect response that will force a document reload to the new location.
  * Sets the status code and the `Location` header.
diff --git a/packages/remix-server-runtime/single-fetch.ts b/packages/remix-server-runtime/single-fetch.ts
index 4c31135c573..9fba362d23d 100644
--- a/packages/remix-server-runtime/single-fetch.ts
+++ b/packages/remix-server-runtime/single-fetch.ts
@@ -27,6 +27,7 @@ type SingleFetchRedirectResult = {
   status: number;
   revalidate: boolean;
   reload: boolean;
+  replace: boolean;
 };
 export type SingleFetchResult =
   | { data: unknown }
@@ -464,6 +465,7 @@ export function getSingleFetchRedirect(
       // TODO(v3): Consider removing or making this official public API
       headers.has("X-Remix-Revalidate") || headers.has("Set-Cookie"),
     reload: headers.has("X-Remix-Reload-Document"),
+    replace: headers.has("X-Remix-Replace"),
   };
 }
 
diff --git a/packages/remix-testing/package.json b/packages/remix-testing/package.json
index bbdff38fb78..cfab24a48fd 100644
--- a/packages/remix-testing/package.json
+++ b/packages/remix-testing/package.json
@@ -21,8 +21,8 @@
   "dependencies": {
     "@remix-run/node": "workspace:*",
     "@remix-run/react": "workspace:*",
-    "@remix-run/router": "1.18.0",
-    "react-router-dom": "6.25.0"
+    "@remix-run/router": "0.0.0-experimental-cffa549a1",
+    "react-router-dom": "0.0.0-experimental-cffa549a1"
   },
   "devDependencies": {
     "@remix-run/server-runtime": "workspace:*",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 5c752534cdc..78decb38b4c 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -323,8 +323,8 @@ importers:
         specifier: workspace:*
         version: link:../packages/remix-node
       '@remix-run/router':
-        specifier: 1.18.0
-        version: 1.18.0
+        specifier: 0.0.0-experimental-cffa549a1
+        version: 0.0.0-experimental-cffa549a1
       '@remix-run/server-runtime':
         specifier: workspace:*
         version: link:../packages/remix-server-runtime
@@ -871,8 +871,8 @@ importers:
         specifier: ^2.10.3
         version: link:../remix-react
       '@remix-run/router':
-        specifier: 1.18.0
-        version: 1.18.0
+        specifier: 0.0.0-experimental-cffa549a1
+        version: 0.0.0-experimental-cffa549a1
       '@remix-run/server-runtime':
         specifier: workspace:*
         version: link:../remix-server-runtime
@@ -1217,17 +1217,17 @@ importers:
   packages/remix-react:
     dependencies:
       '@remix-run/router':
-        specifier: 1.18.0
-        version: 1.18.0
+        specifier: 0.0.0-experimental-cffa549a1
+        version: 0.0.0-experimental-cffa549a1
       '@remix-run/server-runtime':
         specifier: workspace:*
         version: link:../remix-server-runtime
       react-router:
-        specifier: 6.25.0
-        version: 6.25.0(react@18.2.0)
+        specifier: 0.0.0-experimental-cffa549a1
+        version: 0.0.0-experimental-cffa549a1(react@18.2.0)
       react-router-dom:
-        specifier: 6.25.0
-        version: 6.25.0(react-dom@18.2.0)(react@18.2.0)
+        specifier: 0.0.0-experimental-cffa549a1
+        version: 0.0.0-experimental-cffa549a1(react-dom@18.2.0)(react@18.2.0)
       turbo-stream:
         specifier: 2.2.0
         version: 2.2.0
@@ -1303,8 +1303,8 @@ importers:
   packages/remix-server-runtime:
     dependencies:
       '@remix-run/router':
-        specifier: 1.18.0
-        version: 1.18.0
+        specifier: 0.0.0-experimental-cffa549a1
+        version: 0.0.0-experimental-cffa549a1
       '@types/cookie':
         specifier: ^0.6.0
         version: 0.6.0
@@ -1340,11 +1340,11 @@ importers:
         specifier: workspace:*
         version: link:../remix-react
       '@remix-run/router':
-        specifier: 1.18.0
-        version: 1.18.0
+        specifier: 0.0.0-experimental-cffa549a1
+        version: 0.0.0-experimental-cffa549a1
       react-router-dom:
-        specifier: 6.25.0
-        version: 6.25.0(react-dom@18.2.0)(react@18.2.0)
+        specifier: 0.0.0-experimental-cffa549a1
+        version: 0.0.0-experimental-cffa549a1(react-dom@18.2.0)(react@18.2.0)
     devDependencies:
       '@remix-run/server-runtime':
         specifier: workspace:*
@@ -4201,8 +4201,8 @@ packages:
       - encoding
     dev: false
 
-  /@remix-run/router@1.18.0:
-    resolution: {integrity: sha512-L3jkqmqoSVBVKHfpGZmLrex0lxR5SucGA0sUfFzGctehw+S/ggL9L/0NnC5mw6P8HUWpFZ3nQw3cRApjjWx9Sw==}
+  /@remix-run/router@0.0.0-experimental-cffa549a1:
+    resolution: {integrity: sha512-Pn7hkGb4NL91+wMKidAvVUxLjjWeidhBe66rfQG04BDQHoCsBvncM54KtymGprCdjM1ki06c9kcNeR3fz9rDsA==}
     engines: {node: '>=14.0.0'}
     dev: false
 
@@ -12786,26 +12786,26 @@ packages:
     engines: {node: '>=0.10.0'}
     dev: false
 
-  /react-router-dom@6.25.0(react-dom@18.2.0)(react@18.2.0):
-    resolution: {integrity: sha512-BhcczgDWWgvGZxjDDGuGHrA8HrsSudilqTaRSBYLWDayvo1ClchNIDVt5rldqp6e7Dro5dEFx9Mzc+r292lN0w==}
+  /react-router-dom@0.0.0-experimental-cffa549a1(react-dom@18.2.0)(react@18.2.0):
+    resolution: {integrity: sha512-qnObsw+nV5pgoObJ6e+PHG8pltAvpeuqtHQX/Z8VtjQTPcXhLhXysvaE2JlQGxUNnE7OnJCLLbtk2722UvK1bQ==}
     engines: {node: '>=14.0.0'}
     peerDependencies:
       react: '>=16.8'
       react-dom: '>=16.8'
     dependencies:
-      '@remix-run/router': 1.18.0
+      '@remix-run/router': 0.0.0-experimental-cffa549a1
       react: 18.2.0
       react-dom: 18.2.0(react@18.2.0)
-      react-router: 6.25.0(react@18.2.0)
+      react-router: 0.0.0-experimental-cffa549a1(react@18.2.0)
     dev: false
 
-  /react-router@6.25.0(react@18.2.0):
-    resolution: {integrity: sha512-bziKjCcDbcxgWS9WlWFcQIVZ2vJHnCP6DGpQDT0l+0PFDasfJKgzf9CM22eTyhFsZkjk8ApCdKjJwKtzqH80jQ==}
+  /react-router@0.0.0-experimental-cffa549a1(react@18.2.0):
+    resolution: {integrity: sha512-KAdzysntJa81nnnXkm06YowOjt62hNbLph+IH7CLltLFKKdq420fdSUxZ79olJpgWEKG9fjeqLr4X/pJCEyUrg==}
     engines: {node: '>=14.0.0'}
     peerDependencies:
       react: '>=16.8'
     dependencies:
-      '@remix-run/router': 1.18.0
+      '@remix-run/router': 0.0.0-experimental-cffa549a1
       react: 18.2.0
     dev: false