Skip to content
Merged
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/cuddly-rockets-obey.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"react-router": patch
---

server action revalidation opt out via $SKIP_REVALIDATION field
120 changes: 113 additions & 7 deletions integration/rsc/rsc-test.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,13 @@
import { test, expect } from "@playwright/test";
import {
test,
expect,
type Response as PlaywrightResponse,
} from "@playwright/test";
import getPort from "get-port";

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

implementations.forEach((implementation) => {
let stop: () => void;

test.afterEach(() => {
stop?.();
});

test.describe(`RSC (${implementation.name})`, () => {
test.describe("Development", () => {
let port: number;
Expand Down Expand Up @@ -479,11 +477,48 @@ implementations.forEach((implementation) => {
path: "hydrate-fallback-props",
lazy: () => import("./routes/hydrate-fallback-props/home"),
},
{
id: "no-revalidate-server-action",
path: "no-revalidate-server-action",
lazy: () => import("./routes/no-revalidate-server-action/home"),
},
],
},
] satisfies RSCRouteConfig;
`,

"src/routes/root.tsx": js`
import { Links, Outlet, ScrollRestoration } from "react-router";

export const unstable_middleware = [
async (_, next) => {
const response = await next();
return response.headers.set("x-test", "test");
}
];

export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Vite (RSC)</title>
<Links />
</head>
<body>
{children}
<ScrollRestoration />
</body>
</html>
);
}

export default function RootRoute() {
return <Outlet />;
}
`,

"src/config/request-context.ts": js`
import { unstable_createContext, unstable_RouterContextProvider } from "react-router";

Expand Down Expand Up @@ -1108,6 +1143,47 @@ implementations.forEach((implementation) => {
);
}
`,

"src/routes/no-revalidate-server-action/home.actions.ts": js`
"use server";

export async function noRevalidateAction() {
return "no revalidate";
}
`,
"src/routes/no-revalidate-server-action/home.tsx": js`
import ClientHomeRoute from "./home.client";

export function loader() {
console.log("loader");
}

export default function HomeRoute() {
return <ClientHomeRoute identity={{}} />;
}
`,
"src/routes/no-revalidate-server-action/home.client.tsx": js`
"use client";

import { useActionState, useState } from "react";
import { noRevalidateAction } from "./home.actions";

export default function HomeRoute({ identity }) {
const [initialIdentity] = useState(identity);
const [state, action, pending] = useActionState(noRevalidateAction, null);
return (
<div>
<form action={action}>
<input name="$SKIP_REVALIDATION" type="hidden" />
<button type="submit" data-submit>No Revalidate</button>
</form>
{state && <div data-state>{state}</div>}
{pending && <div data-pending>Pending</div>}
{initialIdentity !== identity && <div data-revalidated>Revalidated</div>}
</div>
);
}
`,
},
});
});
Expand Down Expand Up @@ -1525,6 +1601,36 @@ implementations.forEach((implementation) => {
// Ensure this is using RSC
validateRSCHtml(await page.content());
});

test("Supports server actions that disable revalidation", async ({
page,
}) => {
await page.goto(
`http://localhost:${port}/no-revalidate-server-action`,
{ waitUntil: "networkidle" },
);

const actionResponsePromise = new Promise<PlaywrightResponse>(
(resolve) => {
page.on("response", async (response) => {
if (!!(await response.request().headerValue("rsc-action-id"))) {
resolve(response);
}
});
},
);

await page.click("[data-submit]");
await page.waitForSelector("[data-state]");
await page.waitForSelector("[data-pending]", { state: "hidden" });
await page.waitForSelector("[data-revalidated]", { state: "hidden" });
expect(await page.locator("[data-state]").textContent()).toBe(
"no revalidate",
);

const actionResponse = await actionResponsePromise;
expect(await actionResponse.headerValue("x-test")).toBe("test");
});
});

test.describe("Errors", () => {
Expand Down
20 changes: 17 additions & 3 deletions packages/react-router/lib/router/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -435,7 +435,12 @@ export interface StaticHandler {
skipRevalidation?: boolean;
dataStrategy?: DataStrategyFunction<unknown>;
unstable_generateMiddlewareResponse?: (
query: (r: Request) => Promise<StaticHandlerContext | Response>,
query: (
r: Request,
args?: {
filterMatchesToLoad?: (match: AgnosticDataRouteMatch) => boolean;
},
) => Promise<StaticHandlerContext | Response>,
) => MaybePromise<Response>;
},
): Promise<StaticHandlerContext | Response>;
Expand Down Expand Up @@ -3649,7 +3654,14 @@ export function createStaticHandler(
},
async () => {
let res = await generateMiddlewareResponse(
async (revalidationRequest: Request) => {
async (
revalidationRequest: Request,
opts: {
filterMatchesToLoad?:
| ((match: AgnosticDataRouteMatch) => boolean)
| undefined;
} = {},
) => {
let result = await queryImpl(
revalidationRequest,
location,
Expand All @@ -3658,7 +3670,9 @@ export function createStaticHandler(
dataStrategy || null,
skipLoaderErrorBubbling === true,
null,
filterMatchesToLoad || null,
"filterMatchesToLoad" in opts
? (opts.filterMatchesToLoad ?? null)
: null,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we want to fall back on the original value here if they didn't pass an override?

Suggested change
: null,
: (filterMatchesToLoad ?? null),

I should go through and clean up some of the null/undefined stuff in here one of these days 😬

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

actually we need it for the first half of the ternary in case they specified an undefined key - they shouldn't, but the types would allow it. Maybe this is easier?

opts.filterMatchesToLoad ?? filterMatchesToLoad ?? null,

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's different behavior. It should be "if provided, use it (undefined, null, new imp), otherwise use default", we need the key check for that.

skipRevalidation === true,
);

Expand Down
37 changes: 33 additions & 4 deletions packages/react-router/lib/rsc/server.rsc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -515,6 +515,7 @@ async function processServerAction(
temporaryReferences: unknown,
): Promise<
| {
skipRevalidation: boolean;
revalidationRequest: Request;
actionResult?: Promise<unknown>;
formState?: unknown;
Expand Down Expand Up @@ -559,9 +560,21 @@ async function processServerAction(
// The error is propagated to the client through the result promise in the stream
onError?.(error);
}

let maybeFormData = actionArgs.length === 1 ? actionArgs[0] : actionArgs[1];
let formData =
maybeFormData &&
typeof maybeFormData === "object" &&
maybeFormData instanceof FormData
? maybeFormData
: null;

let skipRevalidation = formData?.has("$SKIP_REVALIDATION") ?? false;

return {
actionResult,
revalidationRequest: getRevalidationRequest(),
skipRevalidation,
};
} else if (isFormRequest) {
const formData = await request.clone().formData();
Expand Down Expand Up @@ -591,6 +604,7 @@ async function processServerAction(
return {
formState,
revalidationRequest: getRevalidationRequest(),
skipRevalidation: false,
};
}
}
Expand Down Expand Up @@ -701,20 +715,25 @@ async function generateRenderResponse(
const ctx: ServerContext = {
runningAction: false,
};

const result = await ServerStorage.run(ctx, () =>
staticHandler.query(request, {
requestContext,
skipLoaderErrorBubbling: isDataRequest,
skipRevalidation: isSubmission,
...(routeIdsToLoad
? { filterMatchesToLoad: (m) => routeIdsToLoad!.includes(m.route.id) }
: null),
? {
filterMatchesToLoad: (m: AgnosticDataRouteMatch) =>
routeIdsToLoad!.includes(m.route.id),
}
: {}),
async unstable_generateMiddlewareResponse(query) {
// If this is an RSC server action, process that and then call query as a
// revalidation. If this is a RR Form/Fetcher submission,
// `processServerAction` will fall through as a no-op and we'll pass the
// POST `request` to `query` and process our action there.
let formState: unknown;
let skipRevalidation = false;
if (request.method === "POST") {
ctx.runningAction = true;
let result = await processServerAction(
Expand All @@ -741,6 +760,7 @@ async function generateRenderResponse(
);
}

skipRevalidation = result?.skipRevalidation ?? false;
actionResult = result?.actionResult;
formState = result?.formState;
request = result?.revalidationRequest ?? request;
Expand All @@ -758,7 +778,14 @@ async function generateRenderResponse(
}
}

let staticContext = await query(request);
let staticContext = await query(
request,
skipRevalidation
? {
filterMatchesToLoad: () => false,
}
: undefined,
);

if (isResponse(staticContext)) {
return generateRedirectResponse(
Expand All @@ -784,6 +811,7 @@ async function generateRenderResponse(
formState,
staticContext,
temporaryReferences,
skipRevalidation,
ctx.redirect?.headers,
);
},
Expand Down Expand Up @@ -875,6 +903,7 @@ async function generateStaticContextResponse(
formState: unknown | undefined,
staticContext: StaticHandlerContext,
temporaryReferences: unknown,
skipRevalidation: boolean,
sideEffectRedirectHeaders: Headers | undefined,
): Promise<Response> {
statusCode = staticContext.statusCode ?? statusCode;
Expand Down Expand Up @@ -949,7 +978,7 @@ async function generateStaticContextResponse(
payload = {
type: "action",
actionResult,
rerender: renderPayloadPromise(),
rerender: skipRevalidation ? undefined : renderPayloadPromise(),
};
} else if (isSubmission && isDataRequest) {
// Short circuit without matches on non server-action submissions since
Expand Down
Loading