Skip to content

Commit b143400

Browse files
committed
feat: server action revalidation opt out via $NO_REVALIDATE field.
1 parent 3c75d2b commit b143400

File tree

3 files changed

+101
-0
lines changed

3 files changed

+101
-0
lines changed

.changeset/rsc-no-revalidate.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+
Allow for `<input name="$NO_REVALIDATE" type="hidden" />` to be provided to <form action> and useActionState to allow progressively enhanced calls to opt out of route level revalidation.

integration/rsc/rsc-test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -479,6 +479,11 @@ implementations.forEach((implementation) => {
479479
path: "hydrate-fallback-props",
480480
lazy: () => import("./routes/hydrate-fallback-props/home"),
481481
},
482+
{
483+
id: "no-revalidate-server-action",
484+
path: "no-revalidate-server-action",
485+
lazy: () => import("./routes/no-revalidate-server-action/home"),
486+
},
482487
],
483488
},
484489
] satisfies RSCRouteConfig;
@@ -1108,6 +1113,43 @@ implementations.forEach((implementation) => {
11081113
);
11091114
}
11101115
`,
1116+
1117+
"src/routes/no-revalidate-server-action/home.actions.ts": js`
1118+
"use server";
1119+
1120+
export async function noRevalidateAction() {
1121+
return "no revalidate";
1122+
}
1123+
`,
1124+
"src/routes/no-revalidate-server-action/home.tsx": js`
1125+
import ClientHomeRoute from "./home.client";
1126+
1127+
export default function HomeRoute() {
1128+
return <ClientHomeRoute identity={{}} />;
1129+
}
1130+
`,
1131+
"src/routes/no-revalidate-server-action/home.client.tsx": js`
1132+
"use client";
1133+
1134+
import { useActionState, useState } from "react";
1135+
import { noRevalidateAction } from "./home.actions";
1136+
1137+
export default function HomeRoute({ identity }) {
1138+
const [initialIdentity] = useState(identity);
1139+
const [state, action, pending] = useActionState(noRevalidateAction, null);
1140+
return (
1141+
<div>
1142+
<form action={action}>
1143+
<input name="$NO_REVALIDATE" type="hidden" />
1144+
<button type="submit" data-submit>No Revalidate</button>
1145+
</form>
1146+
{state && <div data-state>{state}</div>}
1147+
{pending && <div data-pending>Pending</div>}
1148+
{initialIdentity !== identity && <div data-revalidated>Revalidated</div>}
1149+
</div>
1150+
);
1151+
}
1152+
`,
11111153
},
11121154
});
11131155
});
@@ -1525,6 +1567,23 @@ implementations.forEach((implementation) => {
15251567
// Ensure this is using RSC
15261568
validateRSCHtml(await page.content());
15271569
});
1570+
1571+
test("Supports server actions that disable revalidation", async ({
1572+
page,
1573+
}) => {
1574+
await page.goto(
1575+
`http://localhost:${port}/no-revalidate-server-action`,
1576+
{ waitUntil: "networkidle" },
1577+
);
1578+
1579+
await page.click("[data-submit]");
1580+
await page.waitForSelector("[data-state]");
1581+
await page.waitForSelector("[data-pending]", { state: "hidden" });
1582+
await page.waitForSelector("[data-revalidated]", { state: "hidden" });
1583+
expect(await page.locator("[data-state]").textContent()).toBe(
1584+
"no revalidate",
1585+
);
1586+
});
15281587
});
15291588

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

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

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -518,6 +518,7 @@ async function processServerAction(
518518
revalidationRequest: Request;
519519
actionResult?: Promise<unknown>;
520520
formState?: unknown;
521+
revalidate: boolean;
521522
}
522523
| Response
523524
| undefined
@@ -559,9 +560,30 @@ async function processServerAction(
559560
// The error is propagated to the client through the result promise in the stream
560561
onError?.(error);
561562
}
563+
564+
// We check both the first and second args to cover both <form action> and useActionState.
565+
let formData1 =
566+
actionArgs &&
567+
typeof actionArgs[0] === "object" &&
568+
actionArgs[0] instanceof FormData
569+
? actionArgs[0]
570+
: null;
571+
let formData2 =
572+
actionArgs &&
573+
typeof actionArgs[1] === "object" &&
574+
actionArgs[1] instanceof FormData
575+
? actionArgs[1]
576+
: null;
577+
let revalidate =
578+
(formData1 && formData1.has("$NO_REVALIDATE")) ||
579+
(formData2 && formData2.has("$NO_REVALIDATE"))
580+
? false
581+
: true;
582+
562583
return {
563584
actionResult,
564585
revalidationRequest: getRevalidationRequest(),
586+
revalidate,
565587
};
566588
} else if (isFormRequest) {
567589
const formData = await request.clone().formData();
@@ -591,6 +613,7 @@ async function processServerAction(
591613
return {
592614
formState,
593615
revalidationRequest: getRevalidationRequest(),
616+
revalidate: true,
594617
};
595618
}
596619
}
@@ -756,6 +779,20 @@ async function generateRenderResponse(
756779
undefined,
757780
);
758781
}
782+
783+
if (result && result.revalidate === false) {
784+
return generateResponse(
785+
{
786+
headers: new Headers(),
787+
statusCode: 200,
788+
payload: {
789+
type: "action",
790+
actionResult: Promise.resolve(result.actionResult),
791+
},
792+
},
793+
{ temporaryReferences },
794+
);
795+
}
759796
}
760797

761798
let staticContext = await query(request);

0 commit comments

Comments
 (0)