diff --git a/.changeset/five-bottles-press.md b/.changeset/five-bottles-press.md new file mode 100644 index 0000000000..bc5c9497c6 --- /dev/null +++ b/.changeset/five-bottles-press.md @@ -0,0 +1,7 @@ +--- +"@remix-run/router": minor +"react-router": minor +"react-router-dom": 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/contributors.yml b/contributors.yml index ea24af9bbd..822592936a 100644 --- a/contributors.yml +++ b/contributors.yml @@ -38,6 +38,7 @@ - bhbs - bilalk711 - bobziroll +- Brendonovich - BrianT1414 - brockross - brookslybrand diff --git a/package.json b/package.json index ba030b5f03..e4dbb0d9d2 100644 --- a/package.json +++ b/package.json @@ -111,13 +111,13 @@ "none": "14.9 kB" }, "packages/react-router/dist/umd/react-router.production.min.js": { - "none": "17.3 kB" + "none": "17.4 kB" }, "packages/react-router-dom/dist/react-router-dom.production.min.js": { "none": "17.3 kB" }, "packages/react-router-dom/dist/umd/react-router-dom.production.min.js": { - "none": "23.6 kB" + "none": "23.7 kB" } }, "pnpm": { diff --git a/packages/react-router-dom-v5-compat/index.ts b/packages/react-router-dom-v5-compat/index.ts index bf967ed5f1..1486b8a9f2 100644 --- a/packages/react-router-dom-v5-compat/index.ts +++ b/packages/react-router-dom-v5-compat/index.ts @@ -154,6 +154,7 @@ export { parsePath, redirect, redirectDocument, + replace, renderMatches, resolvePath, unstable_HistoryRouter, diff --git a/packages/react-router-dom/index.tsx b/packages/react-router-dom/index.tsx index 22f903efd7..54cb1a291c 100644 --- a/packages/react-router-dom/index.tsx +++ b/packages/react-router-dom/index.tsx @@ -178,6 +178,7 @@ export { parsePath, redirect, redirectDocument, + replace, renderMatches, resolvePath, useActionData, diff --git a/packages/react-router-native/index.tsx b/packages/react-router-native/index.tsx index 562a298540..d950927223 100644 --- a/packages/react-router-native/index.tsx +++ b/packages/react-router-native/index.tsx @@ -97,6 +97,7 @@ export { parsePath, redirect, redirectDocument, + replace, renderMatches, resolvePath, useActionData, diff --git a/packages/react-router/index.ts b/packages/react-router/index.ts index 5f417b83dd..5368cc6369 100644 --- a/packages/react-router/index.ts +++ b/packages/react-router/index.ts @@ -49,6 +49,7 @@ import { parsePath, redirect, redirectDocument, + replace, resolvePath, UNSAFE_warning as warning, } from "@remix-run/router"; @@ -206,6 +207,7 @@ export { parsePath, redirect, redirectDocument, + replace, renderMatches, resolvePath, useBlocker, diff --git a/packages/router/__tests__/redirects-test.ts b/packages/router/__tests__/redirects-test.ts index 27d1d46a9a..c8c055c8b0 100644 --- a/packages/router/__tests__/redirects-test.ts +++ b/packages/router/__tests__/redirects-test.ts @@ -1,7 +1,13 @@ -import { IDLE_NAVIGATION } from "../index"; +import { + IDLE_NAVIGATION, + createBrowserHistory, + createMemoryHistory, + createRouter, +} from "../index"; +import { replace } from "../utils"; import type { TestRouteObject } from "./utils/data-router-setup"; import { cleanup, setup } from "./utils/data-router-setup"; -import { createFormData } from "./utils/utils"; +import { createFormData, tick } from "./utils/utils"; describe("redirects", () => { afterEach(() => cleanup()); @@ -642,6 +648,70 @@ describe("redirects", () => { }); }); + it("supports replace() redirects", async () => { + let router = createRouter({ + history: createMemoryHistory(), + routes: [ + { + path: "/", + }, + { + path: "/a", + }, + { + path: "/b", + loader: () => replace("/c"), + }, + { + path: "/c", + }, + ], + }); + router.initialize(); + await tick(); + + // ['/'] + expect(router.state).toMatchObject({ + historyAction: "POP", + location: { + pathname: "/", + state: null, + }, + }); + + // Push /a: ['/', '/a'] + await router.navigate("/a"); + expect(router.state).toMatchObject({ + historyAction: "PUSH", + location: { + pathname: "/a", + state: null, + }, + }); + + // Push /b which calls replace('/c'): ['/', '/c'] + await router.navigate("/b"); + expect(router.state).toMatchObject({ + historyAction: "REPLACE", + location: { + pathname: "/c", + state: { + _isRedirect: true, + }, + }, + }); + + // Pop: ['/'] + await router.navigate(-1); + expect(router.state).toMatchObject({ + historyAction: "POP", + location: { + pathname: "/", + state: null, + }, + }); + }); + describe("redirect status code handling", () => { it("should not treat 300 as a redirect", async () => { let t = setup({ routes: REDIRECT_ROUTES }); diff --git a/packages/router/index.ts b/packages/router/index.ts index 172b95644d..86b1c8b237 100644 --- a/packages/router/index.ts +++ b/packages/router/index.ts @@ -48,6 +48,7 @@ export { normalizePathname, redirect, redirectDocument, + replace, resolvePath, resolveTo, stripBasename, diff --git a/packages/router/router.ts b/packages/router/router.ts index 8a26b859b8..d82f5a0b55 100644 --- a/packages/router/router.ts +++ b/packages/router/router.ts @@ -2683,7 +2683,9 @@ export function createRouter(init: RouterInit): Router { pendingNavigationController = null; let redirectHistoryAction = - replace === true ? HistoryAction.Replace : HistoryAction.Push; + replace === true || redirect.response.headers.has("X-Remix-Replace") + ? HistoryAction.Replace + : HistoryAction.Push; // Use the incoming submission if provided, fallback on the active one in // state.navigation diff --git a/packages/router/utils.ts b/packages/router/utils.ts index 14e9fb2973..811eb90a6e 100644 --- a/packages/router/utils.ts +++ b/packages/router/utils.ts @@ -1619,6 +1619,18 @@ export const redirectDocument: RedirectFunction = (url, init) => { return response; }; +/** + * A redirect response that will perform a `history.replaceState` instead of a + * `history.pushState` for client-side navigation redirects. + * Sets the status code and the `Location` header. + * Defaults to "302 Found". + */ +export const replace: RedirectFunction = (url, init) => { + let response = redirect(url, init); + response.headers.set("X-Remix-Replace", "true"); + return response; +}; + export type ErrorResponse = { status: number; statusText: string;