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