Skip to content

Commit

Permalink
Add support for replace() redirects (#9764)
Browse files Browse the repository at this point in the history
  • Loading branch information
brophdawg11 authored Jul 18, 2024
1 parent 629ae99 commit 2c8eecd
Show file tree
Hide file tree
Showing 18 changed files with 212 additions and 34 deletions.
9 changes: 9 additions & 0 deletions .changeset/curvy-vans-remember.md
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion integration/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
80 changes: 80 additions & 0 deletions integration/redirects-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>
}
`,
},
});

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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>
}
`,
},
});

Expand Down Expand Up @@ -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]
});
});
});
65 changes: 65 additions & 0 deletions integration/single-fetch-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1009,6 +1009,7 @@ test.describe("single-fetch", () => {
status: 302,
redirect: "/target",
reload: false,
replace: false,
revalidate: false,
},
});
Expand Down Expand Up @@ -1148,6 +1149,7 @@ test.describe("single-fetch", () => {
status: 302,
redirect: "/target",
reload: false,
replace: false,
revalidate: false,
},
});
Expand Down Expand Up @@ -1280,6 +1282,7 @@ test.describe("single-fetch", () => {
status: 302,
redirect: "/target",
reload: false,
replace: false,
revalidate: false,
},
});
Expand Down Expand Up @@ -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,
},
});
Expand Down Expand Up @@ -1393,6 +1452,7 @@ test.describe("single-fetch", () => {
status: 302,
redirect: "/target",
reload: false,
replace: false,
revalidate: false,
});
expect(status).toBe(202);
Expand Down Expand Up @@ -1551,6 +1611,7 @@ test.describe("single-fetch", () => {
status: 302,
redirect: "/target",
reload: false,
replace: false,
revalidate: false,
});
expect(status).toBe(202);
Expand Down Expand Up @@ -1702,6 +1763,7 @@ test.describe("single-fetch", () => {
status: 302,
redirect: "/target",
reload: false,
replace: false,
revalidate: false,
});
expect(status).toBe(202);
Expand Down Expand Up @@ -1759,6 +1821,7 @@ test.describe("single-fetch", () => {
status: 302,
redirect: "/target",
reload: false,
replace: false,
revalidate: false,
});
expect(status).toBe(202);
Expand Down Expand Up @@ -1858,6 +1921,7 @@ test.describe("single-fetch", () => {
status: 302,
redirect: "/target",
reload: false,
replace: false,
revalidate: false,
},
});
Expand Down Expand Up @@ -1960,6 +2024,7 @@ test.describe("single-fetch", () => {
status: 302,
redirect: "/target",
reload: false,
replace: false,
revalidate: false,
});
expect(status).toBe(202);
Expand Down
1 change: 1 addition & 0 deletions packages/remix-cloudflare/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export {
MaxPartSizeExceededError,
redirect,
redirectDocument,
replace,
unstable_composeUploadHandlers,
unstable_createMemoryUploadHandler,
unstable_parseMultipartFormData,
Expand Down
1 change: 1 addition & 0 deletions packages/remix-deno/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export {
MaxPartSizeExceededError,
redirect,
redirectDocument,
replace,
unstable_composeUploadHandlers,
unstable_createMemoryUploadHandler,
unstable_defineAction,
Expand Down
2 changes: 1 addition & 1 deletion packages/remix-dev/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/remix-node/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export {
MaxPartSizeExceededError,
redirect,
redirectDocument,
replace,
unstable_composeUploadHandlers,
unstable_createMemoryUploadHandler,
unstable_parseMultipartFormData,
Expand Down
1 change: 1 addition & 0 deletions packages/remix-react/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ export {
json,
redirect,
redirectDocument,
replace,
} from "@remix-run/server-runtime";

export type { RemixBrowserProps } from "./browser";
Expand Down
6 changes: 3 additions & 3 deletions packages/remix-react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
4 changes: 4 additions & 0 deletions packages/remix-react/routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}

Expand Down
3 changes: 3 additions & 0 deletions packages/remix-react/single-fetch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion packages/remix-server-runtime/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion packages/remix-server-runtime/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
11 changes: 11 additions & 0 deletions packages/remix-server-runtime/responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions packages/remix-server-runtime/single-fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ type SingleFetchRedirectResult = {
status: number;
revalidate: boolean;
reload: boolean;
replace: boolean;
};
export type SingleFetchResult =
| { data: unknown }
Expand Down Expand Up @@ -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"),
};
}

Expand Down
Loading

0 comments on commit 2c8eecd

Please sign in to comment.