Skip to content

Commit bebb46c

Browse files
authored
feat(react-router): server action revalidation opt out via $SKIP_REVALIDATION field. (#14154)
1 parent d10c7ef commit bebb46c

File tree

4 files changed

+168
-14
lines changed

4 files changed

+168
-14
lines changed

.changeset/cuddly-rockets-obey.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"react-router": patch
3+
---
4+
5+
server action revalidation opt out via $SKIP_REVALIDATION field

integration/rsc/rsc-test.ts

Lines changed: 113 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,13 @@
1-
import { test, expect } from "@playwright/test";
1+
import {
2+
test,
3+
expect,
4+
type Response as PlaywrightResponse,
5+
} from "@playwright/test";
26
import getPort from "get-port";
37

48
import { implementations, js, setupRscTest, validateRSCHtml } from "./utils";
59

610
implementations.forEach((implementation) => {
7-
let stop: () => void;
8-
9-
test.afterEach(() => {
10-
stop?.();
11-
});
12-
1311
test.describe(`RSC (${implementation.name})`, () => {
1412
test.describe("Development", () => {
1513
let port: number;
@@ -479,11 +477,48 @@ implementations.forEach((implementation) => {
479477
path: "hydrate-fallback-props",
480478
lazy: () => import("./routes/hydrate-fallback-props/home"),
481479
},
480+
{
481+
id: "no-revalidate-server-action",
482+
path: "no-revalidate-server-action",
483+
lazy: () => import("./routes/no-revalidate-server-action/home"),
484+
},
482485
],
483486
},
484487
] satisfies RSCRouteConfig;
485488
`,
486489

490+
"src/routes/root.tsx": js`
491+
import { Links, Outlet, ScrollRestoration } from "react-router";
492+
493+
export const unstable_middleware = [
494+
async (_, next) => {
495+
const response = await next();
496+
return response.headers.set("x-test", "test");
497+
}
498+
];
499+
500+
export function Layout({ children }: { children: React.ReactNode }) {
501+
return (
502+
<html lang="en">
503+
<head>
504+
<meta charSet="utf-8" />
505+
<meta name="viewport" content="width=device-width, initial-scale=1" />
506+
<title>Vite (RSC)</title>
507+
<Links />
508+
</head>
509+
<body>
510+
{children}
511+
<ScrollRestoration />
512+
</body>
513+
</html>
514+
);
515+
}
516+
517+
export default function RootRoute() {
518+
return <Outlet />;
519+
}
520+
`,
521+
487522
"src/config/request-context.ts": js`
488523
import { unstable_createContext, unstable_RouterContextProvider } from "react-router";
489524
@@ -1108,6 +1143,47 @@ implementations.forEach((implementation) => {
11081143
);
11091144
}
11101145
`,
1146+
1147+
"src/routes/no-revalidate-server-action/home.actions.ts": js`
1148+
"use server";
1149+
1150+
export async function noRevalidateAction() {
1151+
return "no revalidate";
1152+
}
1153+
`,
1154+
"src/routes/no-revalidate-server-action/home.tsx": js`
1155+
import ClientHomeRoute from "./home.client";
1156+
1157+
export function loader() {
1158+
console.log("loader");
1159+
}
1160+
1161+
export default function HomeRoute() {
1162+
return <ClientHomeRoute identity={{}} />;
1163+
}
1164+
`,
1165+
"src/routes/no-revalidate-server-action/home.client.tsx": js`
1166+
"use client";
1167+
1168+
import { useActionState, useState } from "react";
1169+
import { noRevalidateAction } from "./home.actions";
1170+
1171+
export default function HomeRoute({ identity }) {
1172+
const [initialIdentity] = useState(identity);
1173+
const [state, action, pending] = useActionState(noRevalidateAction, null);
1174+
return (
1175+
<div>
1176+
<form action={action}>
1177+
<input name="$SKIP_REVALIDATION" type="hidden" />
1178+
<button type="submit" data-submit>No Revalidate</button>
1179+
</form>
1180+
{state && <div data-state>{state}</div>}
1181+
{pending && <div data-pending>Pending</div>}
1182+
{initialIdentity !== identity && <div data-revalidated>Revalidated</div>}
1183+
</div>
1184+
);
1185+
}
1186+
`,
11111187
},
11121188
});
11131189
});
@@ -1525,6 +1601,36 @@ implementations.forEach((implementation) => {
15251601
// Ensure this is using RSC
15261602
validateRSCHtml(await page.content());
15271603
});
1604+
1605+
test("Supports server actions that disable revalidation", async ({
1606+
page,
1607+
}) => {
1608+
await page.goto(
1609+
`http://localhost:${port}/no-revalidate-server-action`,
1610+
{ waitUntil: "networkidle" },
1611+
);
1612+
1613+
const actionResponsePromise = new Promise<PlaywrightResponse>(
1614+
(resolve) => {
1615+
page.on("response", async (response) => {
1616+
if (!!(await response.request().headerValue("rsc-action-id"))) {
1617+
resolve(response);
1618+
}
1619+
});
1620+
},
1621+
);
1622+
1623+
await page.click("[data-submit]");
1624+
await page.waitForSelector("[data-state]");
1625+
await page.waitForSelector("[data-pending]", { state: "hidden" });
1626+
await page.waitForSelector("[data-revalidated]", { state: "hidden" });
1627+
expect(await page.locator("[data-state]").textContent()).toBe(
1628+
"no revalidate",
1629+
);
1630+
1631+
const actionResponse = await actionResponsePromise;
1632+
expect(await actionResponse.headerValue("x-test")).toBe("test");
1633+
});
15281634
});
15291635

15301636
test.describe("Errors", () => {

packages/react-router/lib/router/router.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -435,7 +435,12 @@ export interface StaticHandler {
435435
skipRevalidation?: boolean;
436436
dataStrategy?: DataStrategyFunction<unknown>;
437437
unstable_generateMiddlewareResponse?: (
438-
query: (r: Request) => Promise<StaticHandlerContext | Response>,
438+
query: (
439+
r: Request,
440+
args?: {
441+
filterMatchesToLoad?: (match: AgnosticDataRouteMatch) => boolean;
442+
},
443+
) => Promise<StaticHandlerContext | Response>,
439444
) => MaybePromise<Response>;
440445
},
441446
): Promise<StaticHandlerContext | Response>;
@@ -3649,7 +3654,14 @@ export function createStaticHandler(
36493654
},
36503655
async () => {
36513656
let res = await generateMiddlewareResponse(
3652-
async (revalidationRequest: Request) => {
3657+
async (
3658+
revalidationRequest: Request,
3659+
opts: {
3660+
filterMatchesToLoad?:
3661+
| ((match: AgnosticDataRouteMatch) => boolean)
3662+
| undefined;
3663+
} = {},
3664+
) => {
36533665
let result = await queryImpl(
36543666
revalidationRequest,
36553667
location,
@@ -3658,7 +3670,9 @@ export function createStaticHandler(
36583670
dataStrategy || null,
36593671
skipLoaderErrorBubbling === true,
36603672
null,
3661-
filterMatchesToLoad || null,
3673+
"filterMatchesToLoad" in opts
3674+
? (opts.filterMatchesToLoad ?? null)
3675+
: null,
36623676
skipRevalidation === true,
36633677
);
36643678

packages/react-router/lib/rsc/server.rsc.ts

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -515,6 +515,7 @@ async function processServerAction(
515515
temporaryReferences: unknown,
516516
): Promise<
517517
| {
518+
skipRevalidation: boolean;
518519
revalidationRequest: Request;
519520
actionResult?: Promise<unknown>;
520521
formState?: unknown;
@@ -559,9 +560,21 @@ async function processServerAction(
559560
// The error is propagated to the client through the result promise in the stream
560561
onError?.(error);
561562
}
563+
564+
let maybeFormData = actionArgs.length === 1 ? actionArgs[0] : actionArgs[1];
565+
let formData =
566+
maybeFormData &&
567+
typeof maybeFormData === "object" &&
568+
maybeFormData instanceof FormData
569+
? maybeFormData
570+
: null;
571+
572+
let skipRevalidation = formData?.has("$SKIP_REVALIDATION") ?? false;
573+
562574
return {
563575
actionResult,
564576
revalidationRequest: getRevalidationRequest(),
577+
skipRevalidation,
565578
};
566579
} else if (isFormRequest) {
567580
const formData = await request.clone().formData();
@@ -591,6 +604,7 @@ async function processServerAction(
591604
return {
592605
formState,
593606
revalidationRequest: getRevalidationRequest(),
607+
skipRevalidation: false,
594608
};
595609
}
596610
}
@@ -701,20 +715,25 @@ async function generateRenderResponse(
701715
const ctx: ServerContext = {
702716
runningAction: false,
703717
};
718+
704719
const result = await ServerStorage.run(ctx, () =>
705720
staticHandler.query(request, {
706721
requestContext,
707722
skipLoaderErrorBubbling: isDataRequest,
708723
skipRevalidation: isSubmission,
709724
...(routeIdsToLoad
710-
? { filterMatchesToLoad: (m) => routeIdsToLoad!.includes(m.route.id) }
711-
: null),
725+
? {
726+
filterMatchesToLoad: (m: AgnosticDataRouteMatch) =>
727+
routeIdsToLoad!.includes(m.route.id),
728+
}
729+
: {}),
712730
async unstable_generateMiddlewareResponse(query) {
713731
// If this is an RSC server action, process that and then call query as a
714732
// revalidation. If this is a RR Form/Fetcher submission,
715733
// `processServerAction` will fall through as a no-op and we'll pass the
716734
// POST `request` to `query` and process our action there.
717735
let formState: unknown;
736+
let skipRevalidation = false;
718737
if (request.method === "POST") {
719738
ctx.runningAction = true;
720739
let result = await processServerAction(
@@ -741,6 +760,7 @@ async function generateRenderResponse(
741760
);
742761
}
743762

763+
skipRevalidation = result?.skipRevalidation ?? false;
744764
actionResult = result?.actionResult;
745765
formState = result?.formState;
746766
request = result?.revalidationRequest ?? request;
@@ -758,7 +778,14 @@ async function generateRenderResponse(
758778
}
759779
}
760780

761-
let staticContext = await query(request);
781+
let staticContext = await query(
782+
request,
783+
skipRevalidation
784+
? {
785+
filterMatchesToLoad: () => false,
786+
}
787+
: undefined,
788+
);
762789

763790
if (isResponse(staticContext)) {
764791
return generateRedirectResponse(
@@ -784,6 +811,7 @@ async function generateRenderResponse(
784811
formState,
785812
staticContext,
786813
temporaryReferences,
814+
skipRevalidation,
787815
ctx.redirect?.headers,
788816
);
789817
},
@@ -875,6 +903,7 @@ async function generateStaticContextResponse(
875903
formState: unknown | undefined,
876904
staticContext: StaticHandlerContext,
877905
temporaryReferences: unknown,
906+
skipRevalidation: boolean,
878907
sideEffectRedirectHeaders: Headers | undefined,
879908
): Promise<Response> {
880909
statusCode = staticContext.statusCode ?? statusCode;
@@ -949,7 +978,7 @@ async function generateStaticContextResponse(
949978
payload = {
950979
type: "action",
951980
actionResult,
952-
rerender: renderPayloadPromise(),
981+
rerender: skipRevalidation ? undefined : renderPayloadPromise(),
953982
};
954983
} else if (isSubmission && isDataRequest) {
955984
// Short circuit without matches on non server-action submissions since

0 commit comments

Comments
 (0)