Skip to content

Commit e6e60a6

Browse files
committed
feat(react-router): server action revalidation opt out via $NO_REVALIDATE field
1 parent 16dd742 commit e6e60a6

File tree

4 files changed

+169
-16
lines changed

4 files changed

+169
-16
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 $NO_REVALIDATE 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="$NO_REVALIDATE" 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: 18 additions & 4 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>;
@@ -3551,7 +3556,7 @@ export function createStaticHandler(
35513556
request: Parameters<StaticHandler["query"]>[0],
35523557
{
35533558
requestContext,
3554-
filterMatchesToLoad,
3559+
filterMatchesToLoad: filterMatchesToLoadBase,
35553560
skipLoaderErrorBubbling,
35563561
skipRevalidation,
35573562
dataStrategy,
@@ -3649,7 +3654,16 @@ export function createStaticHandler(
36493654
},
36503655
async () => {
36513656
let res = await generateMiddlewareResponse(
3652-
async (revalidationRequest: Request) => {
3657+
async (
3658+
revalidationRequest: Request,
3659+
{
3660+
filterMatchesToLoad = filterMatchesToLoadBase,
3661+
}: {
3662+
filterMatchesToLoad?:
3663+
| ((match: AgnosticDataRouteMatch) => boolean)
3664+
| undefined;
3665+
} = {},
3666+
) => {
36533667
let result = await queryImpl(
36543668
revalidationRequest,
36553669
location,
@@ -3776,7 +3790,7 @@ export function createStaticHandler(
37763790
dataStrategy || null,
37773791
skipLoaderErrorBubbling === true,
37783792
null,
3779-
filterMatchesToLoad || null,
3793+
filterMatchesToLoadBase || null,
37803794
skipRevalidation === true,
37813795
);
37823796

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

Lines changed: 33 additions & 5 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("$NO_REVALIDATE") ?? 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,27 @@ async function generateRenderResponse(
701715
const ctx: ServerContext = {
702716
runningAction: false,
703717
};
718+
719+
const queryOptions = routeIdsToLoad
720+
? {
721+
filterMatchesToLoad: (m: AgnosticDataRouteMatch) =>
722+
routeIdsToLoad!.includes(m.route.id),
723+
}
724+
: {};
725+
704726
const result = await ServerStorage.run(ctx, () =>
705727
staticHandler.query(request, {
706728
requestContext,
707729
skipLoaderErrorBubbling: isDataRequest,
708730
skipRevalidation: isSubmission,
709-
...(routeIdsToLoad
710-
? { filterMatchesToLoad: (m) => routeIdsToLoad!.includes(m.route.id) }
711-
: null),
731+
...queryOptions,
712732
async unstable_generateMiddlewareResponse(query) {
713733
// If this is an RSC server action, process that and then call query as a
714734
// revalidation. If this is a RR Form/Fetcher submission,
715735
// `processServerAction` will fall through as a no-op and we'll pass the
716736
// POST `request` to `query` and process our action there.
717737
let formState: unknown;
738+
let skipRevalidation = false;
718739
if (request.method === "POST") {
719740
ctx.runningAction = true;
720741
let result = await processServerAction(
@@ -741,6 +762,7 @@ async function generateRenderResponse(
741762
);
742763
}
743764

765+
skipRevalidation = result?.skipRevalidation ?? false;
744766
actionResult = result?.actionResult;
745767
formState = result?.formState;
746768
request = result?.revalidationRequest ?? request;
@@ -758,7 +780,11 @@ async function generateRenderResponse(
758780
}
759781
}
760782

761-
let staticContext = await query(request);
783+
if (skipRevalidation) {
784+
queryOptions.filterMatchesToLoad = () => false;
785+
}
786+
787+
let staticContext = await query(request, queryOptions);
762788

763789
if (isResponse(staticContext)) {
764790
return generateRedirectResponse(
@@ -784,6 +810,7 @@ async function generateRenderResponse(
784810
formState,
785811
staticContext,
786812
temporaryReferences,
813+
skipRevalidation,
787814
ctx.redirect?.headers,
788815
);
789816
},
@@ -875,6 +902,7 @@ async function generateStaticContextResponse(
875902
formState: unknown | undefined,
876903
staticContext: StaticHandlerContext,
877904
temporaryReferences: unknown,
905+
skipRevalidation: boolean,
878906
sideEffectRedirectHeaders: Headers | undefined,
879907
): Promise<Response> {
880908
statusCode = staticContext.statusCode ?? statusCode;
@@ -949,7 +977,7 @@ async function generateStaticContextResponse(
949977
payload = {
950978
type: "action",
951979
actionResult,
952-
rerender: renderPayloadPromise(),
980+
rerender: skipRevalidation ? undefined : renderPayloadPromise(),
953981
};
954982
} else if (isSubmission && isDataRequest) {
955983
// Short circuit without matches on non server-action submissions since

0 commit comments

Comments
 (0)