Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/rsc-reload-document.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"react-router": patch
---

Add versioned document recovery for RSC navigations, route discovery, and server actions.
1 change: 1 addition & 0 deletions contributors.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
- adriananin
- adrienharnay
- afzalsayed96
- agcty
- AhmadMayo
- Ajayff4
- akamfoad
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
// Import the routes generated by routes.ts
import routes from "virtual:react-router/unstable_rsc/routes";
import routeDiscovery from "virtual:react-router/unstable_rsc/route-discovery";
import version from "virtual:react-router/unstable_rsc/version";
import basename from "virtual:react-router/unstable_rsc/basename";
import unstable_reactRouterServeConfig from "virtual:react-router/unstable_rsc/react-router-serve-config";

Expand All @@ -38,6 +39,7 @@ export function fetchServer(
routes,
// The route discovery configuration.
routeDiscovery,
version,
// Encode the match with the React Server implementation.
generateResponse(match, options) {
return new Response(renderToReadableStream(match.payload, options), {
Expand Down
5 changes: 5 additions & 0 deletions packages/react-router-dev/rsc-types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,9 @@ declare module "virtual:react-router/unstable_rsc/route-discovery" {
export default routeDiscovery;
}

declare module "virtual:react-router/unstable_rsc/version" {
const version: string;
export default version;
}

declare module "virtual:react-router/unstable_rsc/inject-hmr-runtime" {}
17 changes: 17 additions & 0 deletions packages/react-router-dev/vite/rsc/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ export function reactRouterRSCVitePlugin(): Vite.PluginOption[] {

let config: ResolvedReactRouterConfig;
let rootRouteFile: string;
let rscVersion = `${Date.now().toString(36)}-${Math.random()
.toString(36)
.slice(2)}`;
function updateConfig(newConfig: ResolvedReactRouterConfig) {
config = newConfig;
rootRouteFile = Path.resolve(
Expand Down Expand Up @@ -566,6 +569,19 @@ export function reactRouterRSCVitePlugin(): Vite.PluginOption[] {
}
},
},
{
name: "react-router/rsc/virtual-version",
resolveId(id) {
if (id === virtual.version.id) {
return virtual.version.resolvedId;
}
},
load(id) {
if (id === virtual.version.resolvedId) {
return `export default ${JSON.stringify(rscVersion)};`;
}
},
},
{
name: "react-router/rsc/hmr/inject-runtime",
enforce: "pre",
Expand Down Expand Up @@ -776,6 +792,7 @@ export function reactRouterRSCVitePlugin(): Vite.PluginOption[] {
const virtual = {
routeConfig: create("unstable_rsc/routes"),
routeDiscovery: create("unstable_rsc/route-discovery"),
version: create("unstable_rsc/version"),
injectHmrRuntime: create("unstable_rsc/inject-hmr-runtime"),
basename: create("unstable_rsc/basename"),
reactRouterServeConfig: create("unstable_rsc/react-router-serve-config"),
Expand Down
116 changes: 116 additions & 0 deletions packages/react-router/__tests__/rsc/browser-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import {
claimRSCReloadDocumentAttempt,
clearRSCReloadDocumentAttempt,
getRSCManifestReloadDocumentTarget,
getRSCReloadDocumentTarget,
isRSCReloadDocumentResponse,
} from "../../lib/rsc/browser";

describe("RSC document reload recovery", () => {
beforeEach(() => {
window.history.replaceState(null, "", "/current?tab=one#section");
// @ts-expect-error test-only router globals
window.__reactRouterDataRouter = undefined;
// @ts-expect-error test-only router globals
window.__reactRouterRSCVersion = undefined;
clearRSCReloadDocumentAttempt();
});

it("recognizes reload-document responses", () => {
expect(
isRSCReloadDocumentResponse(
new Response(null, {
status: 204,
headers: { "X-Remix-Reload-Document": "true" },
}),
),
).toBe(true);

expect(
isRSCReloadDocumentResponse(
new Response(null, {
status: 200,
headers: { "X-Remix-Reload-Document": "true" },
}),
),
).toBe(false);
});

it("reloads GET RSC responses at the document request target", () => {
// @ts-expect-error partial router global for this focused unit test
window.__reactRouterDataRouter = {
state: {
navigation: {
location: {
pathname: "/target",
search: "?panel=notes",
hash: "#source",
},
},
},
};

expect(
getRSCReloadDocumentTarget(
new Request("http://localhost/target?panel=notes"),
),
).toBe("/target?panel=notes#source");
});

it("reloads GET fetcher RSC responses at the current document", () => {
expect(
getRSCReloadDocumentTarget(
new Request("http://localhost/target?panel=notes"),
"fetcher",
),
).toBe("http://localhost/current?tab=one#section");
});

it("reloads mutation RSC responses at the current document", () => {
expect(
getRSCReloadDocumentTarget(
new Request("http://localhost/target", { method: "POST" }),
),
).toBe("http://localhost/current?tab=one#section");
});

it("reloads manifest mismatches at the pending navigation target", () => {
// @ts-expect-error partial router global for this focused unit test
window.__reactRouterDataRouter = {
state: {
navigation: {
location: {
pathname: "/target",
search: "?panel=notes",
hash: "#source",
},
},
},
};

expect(getRSCManifestReloadDocumentTarget("/target")).toBe(
"/target?panel=notes#source",
);
});

it("reloads fetcher manifest mismatches at the current document", () => {
expect(getRSCManifestReloadDocumentTarget("/target", "fetcher")).toBe(
"http://localhost/current?tab=one#section",
);
});

it("bounds reload attempts by target URL and client version", () => {
// @ts-expect-error test-only router globals
window.__reactRouterRSCVersion = "version-a";
expect(claimRSCReloadDocumentAttempt("/target")).toBe(true);
expect(claimRSCReloadDocumentAttempt("/target")).toBe(false);

// @ts-expect-error test-only router globals
window.__reactRouterRSCVersion = "version-b";
expect(claimRSCReloadDocumentAttempt("/target")).toBe(true);
expect(claimRSCReloadDocumentAttempt("/other")).toBe(true);

clearRSCReloadDocumentAttempt();
expect(claimRSCReloadDocumentAttempt("/target")).toBe(true);
});
});
96 changes: 96 additions & 0 deletions packages/react-router/__tests__/rsc/server-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import {
unstable_matchRSCServerRequest as matchRSCServerRequest,
type unstable_RSCMatch as RSCMatch,
} from "../../index-react-server";
import { unstable_routeRSCServerRequest as routeRSCServerRequest } from "../../index";

describe("RSC server version recovery", () => {
it("returns a reload document response for stale manifest requests", async () => {
let response = await matchRSCServerRequest({
createTemporaryReferenceSet: () => undefined,
request: new Request("http://localhost/target.manifest?version=old"),
routes: [],
version: "new",
generateResponse,
});

expect(response.status).toBe(204);
expect(response.headers.get("X-Remix-Reload-Document")).toBe("true");
});

it("returns a reload document response for stale configured manifest requests", async () => {
let response = await matchRSCServerRequest({
createTemporaryReferenceSet: () => undefined,
request: new Request(
"http://localhost/__routes?paths=/target&version=old",
),
routeDiscovery: { mode: "lazy", manifestPath: "/__routes" },
routes: [],
version: "new",
generateResponse,
});

expect(response.status).toBe(204);
expect(response.headers.get("X-Remix-Reload-Document")).toBe("true");
});

it("returns a reload document response for stale RSC data requests", async () => {
let response = await matchRSCServerRequest({
createTemporaryReferenceSet: () => undefined,
request: new Request("http://localhost/target.rsc", {
headers: { "X-React-Router-RSC-Version": "old" },
}),
routes: [],
version: "new",
generateResponse,
});

expect(response.status).toBe(204);
expect(response.headers.get("X-Remix-Reload-Document")).toBe("true");
});

it("returns a reload document response for stale server action requests", async () => {
let response = await matchRSCServerRequest({
createTemporaryReferenceSet: () => undefined,
request: new Request("http://localhost/current", {
method: "POST",
headers: {
"rsc-action-id": "action",
"X-React-Router-RSC-Version": "old",
},
}),
routes: [],
version: "new",
generateResponse,
});

expect(response.status).toBe(204);
expect(response.headers.get("X-Remix-Reload-Document")).toBe("true");
});

it("passes through configured manifest responses from the SSR router", async () => {
let response = await routeRSCServerRequest({
request: new Request(
"http://localhost/__routes?paths=/target&version=new",
),
serverResponse: new Response("manifest", {
headers: { "React-Router-RSC-Manifest": "true" },
}),
createFromReadableStream: async () => {
throw new Error("Should not decode manifest responses");
},
renderHTML() {
throw new Error("Should not render manifest responses");
},
});

expect(await response.text()).toBe("manifest");
});
});

function generateResponse(match: RSCMatch) {
return new Response(JSON.stringify(match.payload), {
status: match.statusCode,
headers: match.headers,
});
}
Loading