From 46806a413a0f8b41977b90cff0aeac207cd12984 Mon Sep 17 00:00:00 2001 From: Robb Traister Date: Wed, 2 Aug 2023 12:39:11 -0700 Subject: [PATCH] Add support to force reload on redirect with `X-Remix-Reload-Document` header (#10705) Co-authored-by: Matt Brophy --- .changeset/x-remix-reload-document.md | 9 ++++ contributors.yml | 1 + docs/fetch/redirectDocument.md | 46 ++++++++++++++++++++ package.json | 8 ++-- packages/react-router-dom-v5-compat/index.ts | 1 + packages/react-router-dom/index.tsx | 1 + packages/react-router-native/index.tsx | 1 + packages/react-router/index.ts | 2 + packages/router/__tests__/router-test.ts | 31 +++++++++++++ packages/router/index.ts | 1 + packages/router/router.ts | 22 +++++++--- packages/router/utils.ts | 12 +++++ 12 files changed, 126 insertions(+), 9 deletions(-) create mode 100644 .changeset/x-remix-reload-document.md create mode 100644 docs/fetch/redirectDocument.md diff --git a/.changeset/x-remix-reload-document.md b/.changeset/x-remix-reload-document.md new file mode 100644 index 0000000000..c2759b3d0c --- /dev/null +++ b/.changeset/x-remix-reload-document.md @@ -0,0 +1,9 @@ +--- +"react-router": minor +"react-router-dom": minor +"react-router-dom-v5-compat": minor +"react-router-native": minor +"@remix-run/router": minor +--- + +Add's a new `redirectDocument()` function which allows users to specify that a redirect from a `loader`/`action` should trigger a document reload (via `window.location`) instead of attempting to navigate to the redirected location via React Router diff --git a/contributors.yml b/contributors.yml index 18b4ea1859..ceee68b591 100644 --- a/contributors.yml +++ b/contributors.yml @@ -228,3 +228,4 @@ - smithki - istarkov - louis-young +- robbtraister diff --git a/docs/fetch/redirectDocument.md b/docs/fetch/redirectDocument.md new file mode 100644 index 0000000000..c88d68232b --- /dev/null +++ b/docs/fetch/redirectDocument.md @@ -0,0 +1,46 @@ +--- +title: redirectDocument +new: true +--- + +# `redirectDocument` + +This is a small wrapper around [`redirect`][redirect] that will trigger a document-level redirect to the new location instead of a client-side navigation. + +This is most useful when you have a React Router app living next to a separate app on the same domain and need to redirect from the React Router app to the other app via `window.location` instead of a React Router navigation: + +```jsx +import { redirectDocument } from "react-router-dom"; + +const loader = async () => { + const user = await getUser(); + if (!user) { + return redirectDocument("/otherapp/login"); + } + return null; +}; +``` + +## Type Declaration + +```ts +type RedirectFunction = ( + url: string, + init?: number | ResponseInit +) => Response; +``` + +## `url` + +The URL to redirect to. + +```js +redirectDocument("/otherapp/login"); +``` + +## `init` + +The [Response][response] options to be used in the response. + +[response]: https://developer.mozilla.org/en-US/docs/Web/API/Response/Response +[redirect]: ./redirect diff --git a/package.json b/package.json index 3333cdb849..d53976bc8b 100644 --- a/package.json +++ b/package.json @@ -109,19 +109,19 @@ }, "filesize": { "packages/router/dist/router.umd.min.js": { - "none": "47.2 kB" + "none": "47.5 kB" }, "packages/react-router/dist/react-router.production.min.js": { - "none": "13.8 kB" + "none": "13.9 kB" }, "packages/react-router/dist/umd/react-router.production.min.js": { - "none": "16.2 kB" + "none": "16.3 kB" }, "packages/react-router-dom/dist/react-router-dom.production.min.js": { "none": "12.8 kB" }, "packages/react-router-dom/dist/umd/react-router-dom.production.min.js": { - "none": "18.71 kB" + "none": "18.9 kB" } } } diff --git a/packages/react-router-dom-v5-compat/index.ts b/packages/react-router-dom-v5-compat/index.ts index d38c15c840..446631607a 100644 --- a/packages/react-router-dom-v5-compat/index.ts +++ b/packages/react-router-dom-v5-compat/index.ts @@ -158,6 +158,7 @@ export { Form, json, redirect, + redirectDocument, useActionData, useAsyncError, useAsyncValue, diff --git a/packages/react-router-dom/index.tsx b/packages/react-router-dom/index.tsx index 6150a864dc..5cf5763d29 100644 --- a/packages/react-router-dom/index.tsx +++ b/packages/react-router-dom/index.tsx @@ -152,6 +152,7 @@ export { matchRoutes, parsePath, redirect, + redirectDocument, renderMatches, resolvePath, useActionData, diff --git a/packages/react-router-native/index.tsx b/packages/react-router-native/index.tsx index c70d2c9f1a..b735aef617 100644 --- a/packages/react-router-native/index.tsx +++ b/packages/react-router-native/index.tsx @@ -87,6 +87,7 @@ export { matchRoutes, parsePath, redirect, + redirectDocument, renderMatches, resolvePath, useActionData, diff --git a/packages/react-router/index.ts b/packages/react-router/index.ts index 08979c483d..592d34a598 100644 --- a/packages/react-router/index.ts +++ b/packages/react-router/index.ts @@ -39,6 +39,7 @@ import { matchRoutes, parsePath, redirect, + redirectDocument, resolvePath, UNSAFE_warning as warning, } from "@remix-run/router"; @@ -187,6 +188,7 @@ export { matchRoutes, parsePath, redirect, + redirectDocument, renderMatches, resolvePath, useActionData, diff --git a/packages/router/__tests__/router-test.ts b/packages/router/__tests__/router-test.ts index f602717bf6..64521aada0 100644 --- a/packages/router/__tests__/router-test.ts +++ b/packages/router/__tests__/router-test.ts @@ -6979,6 +6979,37 @@ describe("a router", () => { } }); + it("processes redirects with document reload if header is present (assign)", async () => { + let t = setup({ routes: REDIRECT_ROUTES }); + + let A = await t.navigate("/parent/child", { + formMethod: "post", + formData: createFormData({}), + }); + + await A.actions.child.redirectReturn("/redirect", 301, { + "X-Remix-Reload-Document": "true", + }); + expect(t.window.location.assign).toHaveBeenCalledWith("/redirect"); + expect(t.window.location.replace).not.toHaveBeenCalled(); + }); + + it("processes redirects with document reload if header is present (replace)", async () => { + let t = setup({ routes: REDIRECT_ROUTES }); + + let A = await t.navigate("/parent/child", { + formMethod: "post", + formData: createFormData({}), + replace: true, + }); + + await A.actions.child.redirectReturn("/redirect", 301, { + "X-Remix-Reload-Document": "true", + }); + expect(t.window.location.replace).toHaveBeenCalledWith("/redirect"); + expect(t.window.location.assign).not.toHaveBeenCalled(); + }); + it("properly handles same-origin absolute URLs", async () => { let t = setup({ routes: REDIRECT_ROUTES }); diff --git a/packages/router/index.ts b/packages/router/index.ts index 3d4fea9620..5f58db59c8 100644 --- a/packages/router/index.ts +++ b/packages/router/index.ts @@ -39,6 +39,7 @@ export { matchRoutes, normalizePathname, redirect, + redirectDocument, resolvePath, resolveTo, stripBasename, diff --git a/packages/router/router.ts b/packages/router/router.ts index 9d28f016df..2980986063 100644 --- a/packages/router/router.ts +++ b/packages/router/router.ts @@ -2111,12 +2111,23 @@ export function createRouter(init: RouterInit): Router { redirectLocation, "Expected a location on the redirect navigation" ); - // Check if this an absolute external redirect that goes to a new origin - if (ABSOLUTE_URL_REGEX.test(redirect.location) && isBrowser) { - let url = init.history.createURL(redirect.location); - let isDifferentBasename = stripBasename(url.pathname, basename) == null; - if (routerWindow.location.origin !== url.origin || isDifferentBasename) { + if (isBrowser) { + let isDocumentReload = false; + + if (redirect.reloadDocument) { + // Hard reload if the response contained X-Remix-Reload-Document + isDocumentReload = true; + } else if (ABSOLUTE_URL_REGEX.test(redirect.location)) { + const url = init.history.createURL(redirect.location); + isDocumentReload = + // Hard reload if it's an absolute URL to a new origin + url.origin !== routerWindow.location.origin || + // Hard reload if it's an absolute URL that does not match our basename + stripBasename(url.pathname, basename) == null; + } + + if (isDocumentReload) { if (replace) { routerWindow.location.replace(redirect.location); } else { @@ -3734,6 +3745,7 @@ async function callLoaderOrAction( status, location, revalidate: result.headers.get("X-Remix-Revalidate") !== null, + reloadDocument: result.headers.get("X-Remix-Reload-Document") !== null, }; } diff --git a/packages/router/utils.ts b/packages/router/utils.ts index 9830d3078d..7663f6c514 100644 --- a/packages/router/utils.ts +++ b/packages/router/utils.ts @@ -43,6 +43,7 @@ export interface RedirectResult { status: number; location: string; revalidate: boolean; + reloadDocument?: boolean; } /** @@ -1484,6 +1485,17 @@ export const redirect: RedirectFunction = (url, init = 302) => { }); }; +/** + * A redirect response that will force a document reload to the new location. + * Sets the status code and the `Location` header. + * Defaults to "302 Found". + */ +export const redirectDocument: RedirectFunction = (url, init) => { + let response = redirect(url, init); + response.headers.set("X-Remix-Reload-Document", "true"); + return response; +}; + /** * @private * Utility class we use to hold auto-unwrapped 4xx/5xx Response bodies