+ }
+ `,
+ },
+ });
+
+ 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
From cfc0c6f14f6934bf9806ea0cd96e61dd51c2e784 Mon Sep 17 00:00:00 2001
From: Matt Brophy
Date: Wed, 24 Jul 2024 16:52:48 -0400
Subject: [PATCH 25/30] Change hydration check from URL to matches (#9695)
---
.changeset/tender-elephants-kneel.md | 6 +++
.../remix-react/__tests__/components-test.tsx | 2 +-
packages/remix-react/browser.tsx | 54 ++++++++++---------
packages/remix-server-runtime/server.ts | 4 +-
.../remix-server-runtime/serverHandoff.ts | 2 +-
5 files changed, 40 insertions(+), 28 deletions(-)
create mode 100644 .changeset/tender-elephants-kneel.md
diff --git a/.changeset/tender-elephants-kneel.md b/.changeset/tender-elephants-kneel.md
new file mode 100644
index 00000000000..9d789d99b69
--- /dev/null
+++ b/.changeset/tender-elephants-kneel.md
@@ -0,0 +1,6 @@
+---
+"@remix-run/react": patch
+"@remix-run/server-runtime": patch
+---
+
+- Change initial hydration route mismatch from a URL check to a matches check to be resistant to URL inconsistenceis
diff --git a/packages/remix-react/__tests__/components-test.tsx b/packages/remix-react/__tests__/components-test.tsx
index f0a30a3a1b1..0f3c353ba3d 100644
--- a/packages/remix-react/__tests__/components-test.tsx
+++ b/packages/remix-react/__tests__/components-test.tsx
@@ -293,7 +293,7 @@ describe("", () => {
describe("", () => {
it("handles empty default export objects from the compiler", () => {
window.__remixContext = {
- url: "/",
+ ssrMatches: ["root", "empty"],
state: {
loaderData: {},
},
diff --git a/packages/remix-react/browser.tsx b/packages/remix-react/browser.tsx
index c0014b1c1e1..f0394dbe2ac 100644
--- a/packages/remix-react/browser.tsx
+++ b/packages/remix-react/browser.tsx
@@ -25,7 +25,7 @@ import { initFogOfWar, useFogOFWarDiscovery } from "./fog-of-war";
/* eslint-disable prefer-let/prefer-let */
declare global {
var __remixContext: {
- url: string;
+ ssrMatches: string[];
basename?: string;
state: HydrationState;
criticalCss?: string;
@@ -194,29 +194,6 @@ if (import.meta && import.meta.hot) {
*/
export function RemixBrowser(_props: RemixBrowserProps): ReactElement {
if (!router) {
- // Hard reload if the path we tried to load is not the current path.
- // This is usually the result of 2 rapid back/forward clicks from an
- // external site into a Remix app, where we initially start the load for
- // one URL and while the JS chunks are loading a second forward click moves
- // us to a new URL. Avoid comparing search params because of CDNs which
- // can be configured to ignore certain params and only pathname is relevant
- // towards determining the route matches.
- let initialPathname = window.__remixContext.url;
- let hydratedPathname = window.location.pathname;
- if (
- initialPathname !== hydratedPathname &&
- !window.__remixContext.isSpaMode
- ) {
- let errorMsg =
- `Initial URL (${initialPathname}) does not match URL at time of hydration ` +
- `(${hydratedPathname}), reloading page...`;
- console.error(errorMsg);
- window.location.reload();
- // Get out of here so the reload can happen - don't create the router
- // since it'll then kick off unnecessary route.lazy() loads
- return <>>;
- }
-
// When single fetch is enabled, we need to suspend until the initial state
// snapshot is decoded into window.__remixContext.state
if (window.__remixContext.future.unstable_singleFetch) {
@@ -270,6 +247,35 @@ export function RemixBrowser(_props: RemixBrowserProps): ReactElement {
window.location,
window.__remixContext.basename
);
+
+ // Hard reload if the matches we rendered on the server aren't the matches
+ // we matched in the client, otherwise we'll try to hydrate without the
+ // right modules and throw a hydration error, which can put React into an
+ // infinite hydration loop when hydrating the full `` document.
+ // This is usually the result of 2 rapid back/forward clicks from an
+ // external site into a Remix app, where we initially start the load for
+ // one URL and while the JS chunks are loading a second forward click moves
+ // us to a new URL.
+ let ssrMatches = window.__remixContext.ssrMatches;
+ let hasDifferentSSRMatches =
+ (initialMatches || []).length !== ssrMatches.length ||
+ !(initialMatches || []).every((m, i) => ssrMatches[i] === m.route.id);
+
+ if (hasDifferentSSRMatches && !window.__remixContext.isSpaMode) {
+ let ssr = ssrMatches.join(",");
+ let client = (initialMatches || []).map((m) => m.route.id).join(",");
+ let errorMsg =
+ `SSR Matches (${ssr}) do not match client matches (${client}) at ` +
+ `time of hydration , reloading page...`;
+ console.error(errorMsg);
+
+ window.location.reload();
+
+ // Get out of here so the reload can happen - don't create the router
+ // since it'll then kick off unnecessary route.lazy() loads
+ return <>>;
+ }
+
if (initialMatches) {
for (let match of initialMatches) {
let routeId = match.route.id;
diff --git a/packages/remix-server-runtime/server.ts b/packages/remix-server-runtime/server.ts
index fd81da453b9..8fa3f85fa05 100644
--- a/packages/remix-server-runtime/server.ts
+++ b/packages/remix-server-runtime/server.ts
@@ -515,7 +515,7 @@ async function handleDocumentRequest(
staticHandlerContext: context,
criticalCss,
serverHandoffString: createServerHandoffString({
- url: context.location.pathname,
+ ssrMatches: context.matches.map((m) => m.route.id),
basename: build.basename,
criticalCss,
future: build.future,
@@ -592,7 +592,7 @@ async function handleDocumentRequest(
...entryContext,
staticHandlerContext: context,
serverHandoffString: createServerHandoffString({
- url: context.location.pathname,
+ ssrMatches: context.matches.map((m) => m.route.id),
basename: build.basename,
future: build.future,
isSpaMode: build.isSpaMode,
diff --git a/packages/remix-server-runtime/serverHandoff.ts b/packages/remix-server-runtime/serverHandoff.ts
index 7328388ac3d..124cb5e0711 100644
--- a/packages/remix-server-runtime/serverHandoff.ts
+++ b/packages/remix-server-runtime/serverHandoff.ts
@@ -20,7 +20,7 @@ export function createServerHandoffString(serverHandoff: {
// we'd end up including duplicate info
state?: ValidateShape;
criticalCss?: string;
- url: string;
+ ssrMatches: string[];
basename: string | undefined;
future: FutureConfig;
isSpaMode: boolean;
From e171af77fe8ed12c91674c9e9f8d779f758cd2eb Mon Sep 17 00:00:00 2001
From: Matt Brophy
Date: Wed, 24 Jul 2024 16:58:05 -0400
Subject: [PATCH 26/30] Trim trailing slashes from single fetch data URLs
(#9792)
---
.changeset/large-donkeys-remember.md | 5 +++++
packages/remix-react/single-fetch.tsx | 8 +++++++-
2 files changed, 12 insertions(+), 1 deletion(-)
create mode 100644 .changeset/large-donkeys-remember.md
diff --git a/.changeset/large-donkeys-remember.md b/.changeset/large-donkeys-remember.md
new file mode 100644
index 00000000000..5499280988d
--- /dev/null
+++ b/.changeset/large-donkeys-remember.md
@@ -0,0 +1,5 @@
+---
+"@remix-run/react": patch
+---
+
+Ensure single fetch calls don't include any trailing slash from the pathname (i.e., /path/.data)
diff --git a/packages/remix-react/single-fetch.tsx b/packages/remix-react/single-fetch.tsx
index e6c156efa95..78c2715a7ec 100644
--- a/packages/remix-react/single-fetch.tsx
+++ b/packages/remix-react/single-fetch.tsx
@@ -304,7 +304,13 @@ export function singleFetchUrl(reqUrl: URL | string) {
typeof reqUrl === "string"
? new URL(reqUrl, window.location.origin)
: reqUrl;
- url.pathname = `${url.pathname === "/" ? "_root" : url.pathname}.data`;
+
+ if (url.pathname === "/") {
+ url.pathname = "_root.data";
+ } else {
+ url.pathname = `${url.pathname.replace(/\/$/, "")}.data`;
+ }
+
return url;
}
From b163e06f66ae3d238fe633889d08bef823868d27 Mon Sep 17 00:00:00 2001
From: Matt Brophy
Date: Thu, 25 Jul 2024 17:27:53 -0400
Subject: [PATCH 27/30] Remove single fetch response stub, add headers (#9769)
---
.changeset/add-unstable-data.md | 9 +
.changeset/remove-response-stub.md | 24 +
docs/guides/single-fetch.md | 134 +-
docs/utils/data.md | 41 +
integration/package.json | 2 +-
integration/single-fetch-test.ts | 1615 ++++-------------
packages/remix-cloudflare/index.ts | 1 +
packages/remix-deno/index.ts | 1 +
packages/remix-dev/package.json | 2 +-
packages/remix-node/index.ts | 1 +
.../remix-react/__tests__/exports-test.tsx | 1 +
packages/remix-react/index.tsx | 1 +
packages/remix-react/package.json | 6 +-
packages/remix-react/single-fetch.tsx | 22 +-
packages/remix-server-runtime/data.ts | 11 -
packages/remix-server-runtime/index.ts | 1 +
packages/remix-server-runtime/package.json | 2 +-
packages/remix-server-runtime/routeModules.ts | 5 -
packages/remix-server-runtime/routes.ts | 5 -
packages/remix-server-runtime/server.ts | 99 +-
packages/remix-server-runtime/single-fetch.ts | 287 +--
packages/remix-testing/package.json | 4 +-
pnpm-lock.yaml | 50 +-
23 files changed, 587 insertions(+), 1737 deletions(-)
create mode 100644 .changeset/add-unstable-data.md
create mode 100644 .changeset/remove-response-stub.md
create mode 100644 docs/utils/data.md
diff --git a/.changeset/add-unstable-data.md b/.changeset/add-unstable-data.md
new file mode 100644
index 00000000000..f1eebbd186a
--- /dev/null
+++ b/.changeset/add-unstable-data.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 `unstable_data()` API for usage with Remix Single Fetch
diff --git a/.changeset/remove-response-stub.md b/.changeset/remove-response-stub.md
new file mode 100644
index 00000000000..09b70ccded3
--- /dev/null
+++ b/.changeset/remove-response-stub.md
@@ -0,0 +1,24 @@
+---
+"@remix-run/server-runtime": minor
+"@remix-run/react": minor
+---
+
+Single Fetch: Remove `responseStub` in favor of `headers`
+
+* Background
+ * The original Single Fetch approach was based on an assumption that an eventual `middleware` implementation would require something like `ResponseStub` so users could mutate `status`/`headers` in `middleware` before/after handlers as well as during handlers
+ * We wanted to align how `headers` got merged between document and data requests
+ * So we made document requests also use `ResponseStub` and removed the usage of `headers` in Single Fetch
+ * The realization/alignment between Michael and Ryan on the recent [roadmap planning](https://www.youtube.com/watch?v=f5z_axCofW0) made us realize that the original assumption was incorrect
+ * `middleware` won't need a stub - users can just mutate the `Response` they get from `await next()` directly
+ * With that gone, and still wanting to align how `headers` get merged, it makes more sense to stick with the current `headers` API and apply that to Single Fetch and avoid introducing a totally new thing in `RepsonseStub` (that always felt a bit awkward to work with anyway)
+
+* With this change:
+ * You are encouraged to stop returning `Response` instances in favor of returning raw data from loaders and actions:
+ * ~~`return json({ data: whatever });`~~
+ * `return { data: whatever };`
+ * In most cases, you can remove your `json()` and `defer()` calls in favor of returning raw data if they weren't setting custom `status`/`headers`
+ * We will be removing both `json` and `defer` in the next major version, but both _should_ still work in Single Fetch in v2 to allow for incremental adoption of the new behavior
+ * If you need custom `status`/`headers`:
+ * We've added a new `unstable_data({...}, responseInit)` utility that will let you send back `status`/`headers` alongside your raw data without having to encode it into a `Response`
+ * The `headers()` function will let you control header merging for both document and data requests
diff --git a/docs/guides/single-fetch.md b/docs/guides/single-fetch.md
index 5d8bfa4fec4..5571be0d4ec 100644
--- a/docs/guides/single-fetch.md
+++ b/docs/guides/single-fetch.md
@@ -6,7 +6,7 @@ title: Single Fetch
This is an unstable API and will continue to change, do not adopt in production
-Single fetch is a new data data loading strategy and streaming format. When you enable Single Fetch, Remix will make a single HTTP call to your server on client-side transitions, instead of multiple HTTP calls in parallel (one per loader). Additionally, Single Fetch also allows you to send down naked objects from your `loader` and `action`, such as `Date`, `Error`, `Promise`, `RegExp`, and more.
+Single Fetch is a new data data loading strategy and streaming format. When you enable Single Fetch, Remix will make a single HTTP call to your server on client-side transitions, instead of multiple HTTP calls in parallel (one per loader). Additionally, Single Fetch also allows you to send down naked objects from your `loader` and `action`, such as `Date`, `Error`, `Promise`, `RegExp`, and more.
## Overview
@@ -51,9 +51,9 @@ Single Fetch requires using [`undici`][undici] as your `fetch` polyfill, or usin
- If you are using miniflare/cloudflare worker with your remix project, ensure your [compatibility flag][compatibility-flag] is set to `2023-03-01` or later as well.
-**3. Remove document-level `headers` implementation (if you have one)**
+**3. Adjust `headers` implementations (if necessary)**
-The [`headers`][headers] export is not longer used when single fetch is enabled. In many cases you may have been just re-returning the headers from your loader `Response` instances to apply them to document requests, and if so, you may can likely just remove the export and those Repsonse headers will apply to document requests automatically. If you were doing more complex logic for document headers in the `headers` function, then you will need to migrate those to the new [Response Stub][responsestub] instance in your `loader` functions.
+With Single Fetch enabled, there will now only be one request made on client-side navigations even when multiple loaders need to run. To handle merging headers for the handlers called, the [`headers`][headers] export will now also apply to `loader`/`action` data requests. In many cases, the logic you already have in there for document requests should be close to sufficient for your new Single Fetch data requests.
**4. Add `nonce` to `` (if you are using a CSP)**
@@ -76,7 +76,7 @@ There are a handful of breaking changes introduced with Single Fetch - some of w
**Changes that need to be addressed up front:**
- **Deprecated `fetch` polyfill**: The old `installGlobals()` polyfill doesn't work for Single Fetch, you must either use the native Node 20 `fetch` API or call `installGlobals({ nativeFetch: true })` in your custom server to get the [undici-based polyfill][undici-polyfill]
-- **Deprecated `headers` export**: The [`headers`][headers] function is no longer used when Single Fetch is enabled, in favor of the new `response` stub passed to your `loader`/`action` functions
+- **`headers` export applied to data requests**: The [`headers`][headers] function will now apply to both document and data requests
**Changes to be aware of that you may need to handle over-time:**
@@ -90,7 +90,7 @@ There are a handful of breaking changes introduced with Single Fetch - some of w
## Adding a New Route with Single Fetch
-With Single Fetch enabled, you can go ahead and author routes that take advantage of the more powerful streaming format and [`response` stub][responsestub].
+With Single Fetch enabled, you can go ahead and author routes that take advantage of the more powerful streaming format.
In order to get proper type inference, you first need to add `@remix-run/react/future/single-fetch.d.ts` to the end of your `tsconfig.json`'s `compilerOptions.types` array. You can read more about this in the [Type Inference section][type-inference-section].
@@ -100,22 +100,18 @@ With Single Fetch you can return the following data types from your loader: `Big
// routes/blog.$slug.tsx
import { unstable_defineLoader as defineLoader } from "@remix-run/node";
-export const loader = defineLoader(
- async ({ params, response }) => {
- const { slug } = params;
+export const loader = defineLoader(async ({ params }) => {
+ const { slug } = params;
- const comments = fetchComments(slug);
- const blogData = await fetchBlogData(slug);
+ const comments = fetchComments(slug);
+ const blogData = await fetchBlogData(slug);
- response.headers.set("Cache-Control", "max-age=300");
-
- return {
- content: blogData.content, // <- string
- published: blogData.date, // <- Date
- comments, // <- Promise
- };
- }
-);
+ return {
+ content: blogData.content, // <- string
+ published: blogData.date, // <- Date
+ comments, // <- Promise
+ };
+});
export default function BlogPost() {
const blogData = useLoaderData();
@@ -292,64 +288,18 @@ export default function Component() {
### Headers
-The [`headers`][headers] function is no longer used when Single Fetch is enabled.
-Instead, your `loader`/`action` functions now receive a mutable `ResponseStub` unique to that execution:
+The [`headers`][headers] function is now used on both document and data requests when Single Fetch is enabled. You should use that function to merge any headers returned from loaders executed in parallel, or to return any given `actionHeaders`.
-- To alter the status of your HTTP Response, set the `status` field directly:
- - `response.status = 201`
-- To set the headers on your HTTP Response, use the standard [`Headers`][mdn-headers] APIs:
- - `response.headers.set(name, value)`
- - `response.headers.append(name, value)`
- - `response.headers.delete(name)`
+### Returned Responses
-```ts
-export const action = defineAction(
- async ({ request, response }) => {
- if (!loggedIn(request)) {
- response.status = 401;
- response.headers.append("Set-Cookie", "foo=bar");
- return { message: "Invalid Submission!" };
- }
- await addItemToDb(request);
- return null;
- }
-);
-```
+With Single Fetch, you no longer need to return `Response` instances and can just return your data directly via naked object returns. Therefore, the `json`/`defer` utilities should be considered deprecated when using Single Fetch. These will remain for the duration of v2 so you don't need to remove them immediately. They will likely be removed in the next major version, so we recommend remove them incrementally between now and then.
-You can also throw these response stubs to short circuit the flow of your loaders and actions:
+For v2, you may still continue returning normal `Response` instances and their `status`/`headers` will take effect the same way they do on document requests (merging headers via the `headers()` function).
-```tsx
-export const loader = defineLoader(
- ({ request, response }) => {
- if (shouldRedirectToHome(request)) {
- response.status = 302;
- response.headers.set("Location", "/");
- throw response;
- }
- // ...
- }
-);
-```
+Over time, you should start eliminating returned Responses from your loaders and actions.
-Each `loader`/`action` receives its own unique `response` instance so you cannot see what other `loader`/`action` functions have set (which would be subject to race conditions). The resulting HTTP Response status and headers are determined as follows:
-
-- Status Code
- - If all status codes are unset or have values <300, the deepest status code will be used for the HTTP response
- - If any status codes are set to a value >=300, the shallowest >=300 value will be used for the HTTP Response
-- Headers
- - Remix tracks header operations and will replay them on a fresh `Headers` instance after all handlers have completed
- - These are replayed in order - action first (if present) followed by loaders in top-down order
- - `headers.set` on any child handler will overwrite values from parent handlers
- - `headers.append` can be used to set the same header from both a parent and child handler
- - `headers.delete` can be used to delete a value set by a parent handler, but not a value set from a child handler
-
-Because Single Fetch supports naked object returns, and you no longer need to return a `Response` instance to set status/headers, the `json`/`redirect`/`redirectDocument`/`defer` utilities should be considered deprecated when using Single Fetch. These will remain for the duration of v2 so you don't need to remove them immediately. They will likely be removed in the next major version, so we recommend remove them incrementally between now and then.
-
-These utilities will remain for the rest of Remix v2, and it's likely that in a future version they'll be available via something like [`remix-utils`][remix-utils] (or they're also very easy to re-implement yourself).
-
-For v2, you may still continue returning normal `Response` instances and they'll apply status codes in the same way as the `response` stub, and will apply all headers via `headers.set` - overwriting any same-named header values from parents. If you need to append a header, you will need to switch from returning a `Response` instance to using the new `response` parameter.
-
-To ensure you can adopt these features incrementally, our goal is that you can enable Single Fetch without changing all of your `loader`/`action` functions to leverage the `response` stub. Then over time, you can incrementally convert individual routes to leverage the new `response` stub.
+- If your `loader`/`action` was returning `json`/`defer` without setting any `status`/`headers`, then you can just remove the call to `json`/`defer` and return the data directly
+- If your `loader`/`action` was returning custom `status`/`headers` via `json`/`defer`, you should switch those to use the new [`unstable_data()`][data-utility] utility.
### Client Loaders
@@ -434,42 +384,6 @@ The Remix v2 behavior with Single Fetch enabled is as follows:
Note: It is _not_ recommended to use `defineLoader`/`defineAction` for externally-accessed resource routes that need to return specific `Response` instances. It's best to just stick with `loader`/`LoaderFunctionArgs` for these cases.
-#### Response Stub and Resource Routes
-
-As discussed above, the `headers` export is deprecated in favor of a new [`response` stub][responsestub] passed to your `loader` and `action` functions. This is somewhat confusing in resource routes, though, because you get to return the _actual_ `Response` - there's no real need for a "stub" concept because there's no merging results from multiple loaders into a single Response:
-
-```tsx filename=app/routes/resource.tsx
-// Using your own Response is the most straightforward approach
-export async function loader() {
- const data = await getData();
- return Response.json(data, {
- status: 200,
- headers: {
- "X-Custom": "whatever",
- },
- });
-}
-```
-
-To keep things consistent, resource route `loader`/`action` functions will still receive a `response` stub and you can use it if you need to (maybe to share code amongst non-resource route handlers):
-
-```tsx filename=app/routes/resource.tsx
-// But you can still set values on the response stub
-export async function loader({
- response,
-}: LoaderFunctionArgs) {
- const data = await getData();
- response?.status = 200;
- response?.headers.set("X-Custom", "whatever");
- return Response.json(data);
-}
-```
-
-It's best to try to avoid using the `response` stub _and also_ returning a `Response` with custom status/headers, but if you do, the following logic will apply":
-
-- The `Response` instance status will take priority over any `response` stub status
-- Headers operations on the `response` stub `headers` will be re-played on the returned `Response` headers instance
-
## Additional Details
### Streaming Data Format
@@ -557,16 +471,13 @@ Revalidation is handled via a `?_routes` query string parameter on the single fe
[hydrateroot]: https://react.dev/reference/react-dom/client/hydrateRoot
[starttransition]: https://react.dev/reference/react/startTransition
[headers]: ../route/headers
-[mdn-headers]: https://developer.mozilla.org/en-US/docs/Web/API/Headers
[resource-routes]: ../guides/resource-routes
[returning-response]: ../route/loader.md#returning-response-instances
-[responsestub]: #headers
[streaming-format]: #streaming-data-format
[undici-polyfill]: https://github.com/remix-run/remix/blob/main/CHANGELOG.md#undici
[undici]: https://github.com/nodejs/undici
[csp]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/script-src
[csp-nonce]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/Sources#sources
-[remix-utils]: https://github.com/sergiodxa/remix-utils
[merging-remix-and-rr]: https://remix.run/blog/merging-remix-and-react-router
[migration-guide]: #migrating-a-route-with-single-fetch
[breaking-changes]: #breaking-changes
@@ -574,3 +485,4 @@ Revalidation is handled via a `?_routes` query string parameter on the single fe
[start]: #enabling-single-fetch
[type-inference-section]: #type-inference
[compatibility-flag]: https://developers.cloudflare.com/workers/configuration/compatibility-dates
+[data-utility]: ../utils/data
diff --git a/docs/utils/data.md b/docs/utils/data.md
new file mode 100644
index 00000000000..c7872a64372
--- /dev/null
+++ b/docs/utils/data.md
@@ -0,0 +1,41 @@
+---
+title: unstable_data
+toc: false
+---
+
+# `unstable_data`
+
+This is a utility for use with [Single Fetch][single-fetch] to return raw data accompanied with a status code or custom response headers. This avoids the need to serialize your data into a `Response` instance to provide custom status/headers. This is generally a replacement for `loader`/`action` functions that used [`json`][json] or [`defer`][defer] prior to Single Fetch.
+
+```tsx
+import { unstable_data as data } from "@remix-run/node"; // or cloudflare/deno
+
+export const loader = async () => {
+ // So you can write this:
+ return data(
+ { not: "coffee" },
+ {
+ status: 418,
+ headers: {
+ "Cache-Control": "no-store",
+ },
+ }
+ );
+};
+```
+
+You should _not_ be using this function if you don't need to return custom status/headers - in that case, just return the data directly:
+
+```tsx
+export const loader = async () => {
+ // ❌ Bad
+ return data({ not: "coffee" });
+
+ // ✅ Good
+ return { not: "coffee" };
+};
+```
+
+[single-fetch]: ../guides/single-fetch
+[json]: ./json
+[defer]: ./defer
diff --git a/integration/package.json b/integration/package.json
index db21212335a..be111b12c70 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": "0.0.0-experimental-cffa549a1",
+ "@remix-run/router": "0.0.0-experimental-9ffbba722",
"@remix-run/server-runtime": "workspace:*",
"@types/express": "^4.17.9",
"@vanilla-extract/css": "^1.10.0",
diff --git a/integration/single-fetch-test.ts b/integration/single-fetch-test.ts
index 7655d09d9eb..e383ff812ab 100644
--- a/integration/single-fetch-test.ts
+++ b/integration/single-fetch-test.ts
@@ -15,6 +15,16 @@ const files = {
"app/root.tsx": js`
import { Form, Link, Links, Meta, Outlet, Scripts } from "@remix-run/react";
+ export function headers ({ actionHeaders, loaderHeaders, errorHeaders }) {
+ if (errorHeaders) {
+ return errorHeaders;
+ } else if ([...actionHeaders].length > 0) {
+ return actionHeaders;
+ } else {
+ return loaderHeaders;
+ }
+ }
+
export function loader() {
return {
message: "ROOT",
@@ -84,6 +94,48 @@ const files = {
)
}
`,
+
+ "app/routes/data-with-response.tsx": js`
+ import { useActionData, useLoaderData, unstable_data as data } from "@remix-run/react";
+
+ export function headers ({ actionHeaders, loaderHeaders, errorHeaders }) {
+ if ([...actionHeaders].length > 0) {
+ return actionHeaders;
+ } else {
+ return loaderHeaders;
+ }
+ }
+
+ export async function action({ request }) {
+ let formData = await request.formData();
+ return data({
+ key: formData.get('key'),
+ }, { status: 201, headers: { 'X-Action': 'yes' }});
+ }
+
+ export function loader({ request }) {
+ if (new URL(request.url).searchParams.has("error")) {
+ throw new Error("Loader Error");
+ }
+ return data({
+ message: "DATA",
+ date: new Date("${ISO_DATE}"),
+ }, { status: 206, headers: { 'X-Loader': 'yes' }});
+ }
+
+ export default function DataWithResponse() {
+ let data = useLoaderData();
+ let actionData = useActionData();
+ return (
+ <>
+