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
Hello B!
}
`,
+
+ "app/routes/replace.a.tsx": js`
+ import { Link } from "@remix-run/react";
+ export default function () {
+ return <>A
Go to B>;
+ }
+ `,
+
+ "app/routes/replace.b.tsx": js`
+ import { Link } from "@remix-run/react";
+ export default function () {
+ return <>B
Go to C>
+ }
+ `,
+
+ "app/routes/replace.c.tsx": js`
+ import { replace } from "@remix-run/node";
+ export const loader = () => replace("/replace/d");
+ export default function () {
+ return C
+ }
+ `,
+
+ "app/routes/replace.d.tsx": js`
+ export default function () {
+ return D
+ }
+ `,
},
});
@@ -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 Hello B!
}
`,
+
+ "app/routes/replace.a.tsx": js`
+ import { Link } from "@remix-run/react";
+ export default function () {
+ return <>A
Go to B>;
+ }
+ `,
+
+ "app/routes/replace.b.tsx": js`
+ import { Link } from "@remix-run/react";
+ export default function () {
+ return <>B
Go to C>
+ }
+ `,
+
+ "app/routes/replace.c.tsx": js`
+ import { replace } from "@remix-run/node";
+ export const loader = () => replace("/replace/d");
+ export default function () {
+ return C
+ }
+ `,
+
+ "app/routes/replace.d.tsx": js`
+ export default function () {
+ return D
+ }
+ `,
},
});
@@ -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 Target
+ }
+ `,
+ },
+ });
+
+ 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;
};
+/**
+ * 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;
+};
+
/**
* 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