Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support to force reload on redirect with X-Remix-Reload-Document header #10705

Merged
merged 14 commits into from
Aug 2, 2023
9 changes: 9 additions & 0 deletions .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
1 change: 1 addition & 0 deletions contributors.yml
Expand Up @@ -228,3 +228,4 @@
- smithki
- istarkov
- louis-young
- robbtraister
46 changes: 46 additions & 0 deletions 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
8 changes: 4 additions & 4 deletions package.json
Expand Up @@ -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"
}
}
}
1 change: 1 addition & 0 deletions packages/react-router-dom-v5-compat/index.ts
Expand Up @@ -158,6 +158,7 @@ export {
Form,
json,
redirect,
redirectDocument,
useActionData,
useAsyncError,
useAsyncValue,
Expand Down
1 change: 1 addition & 0 deletions packages/react-router-dom/index.tsx
Expand Up @@ -152,6 +152,7 @@ export {
matchRoutes,
parsePath,
redirect,
redirectDocument,
renderMatches,
resolvePath,
useActionData,
Expand Down
1 change: 1 addition & 0 deletions packages/react-router-native/index.tsx
Expand Up @@ -87,6 +87,7 @@ export {
matchRoutes,
parsePath,
redirect,
redirectDocument,
renderMatches,
resolvePath,
useActionData,
Expand Down
2 changes: 2 additions & 0 deletions packages/react-router/index.ts
Expand Up @@ -39,6 +39,7 @@ import {
matchRoutes,
parsePath,
redirect,
redirectDocument,
resolvePath,
UNSAFE_warning as warning,
} from "@remix-run/router";
Expand Down Expand Up @@ -187,6 +188,7 @@ export {
matchRoutes,
parsePath,
redirect,
redirectDocument,
renderMatches,
resolvePath,
useActionData,
Expand Down
31 changes: 31 additions & 0 deletions packages/router/__tests__/router-test.ts
Expand Up @@ -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 });

Expand Down
1 change: 1 addition & 0 deletions packages/router/index.ts
Expand Up @@ -39,6 +39,7 @@ export {
matchRoutes,
normalizePathname,
redirect,
redirectDocument,
resolvePath,
resolveTo,
stripBasename,
Expand Down
22 changes: 17 additions & 5 deletions packages/router/router.ts
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
};
}

Expand Down
12 changes: 12 additions & 0 deletions packages/router/utils.ts
Expand Up @@ -43,6 +43,7 @@ export interface RedirectResult {
status: number;
location: string;
revalidate: boolean;
reloadDocument?: boolean;
}

/**
Expand Down Expand Up @@ -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
Expand Down