diff --git a/packages/react-server/examples/basic/e2e/basic.test.ts b/packages/react-server/examples/basic/e2e/basic.test.ts
index 9e694cfe3..6e23aef3c 100644
--- a/packages/react-server/examples/basic/e2e/basic.test.ts
+++ b/packages/react-server/examples/basic/e2e/basic.test.ts
@@ -664,6 +664,27 @@ async function testActionContext(page: Page) {
await page.getByText("Hi, anonymous user!").click();
}
+test("action revalidate", async ({ page }) => {
+ checkNoError(page);
+
+ await page.goto("/test/action");
+ await waitForHydration(page);
+
+ const checkClientState = await setupCheckClientState(page);
+
+ if (process.env.E2E_PREVIEW) {
+ await page.getByText("[effect: 1]").click();
+ await page.getByRole("button", { name: "Revalidate!" }).click();
+ await page.getByText("[effect: 2]").click();
+ } else {
+ await page.getByText("[effect: 2]").click();
+ await page.getByRole("button", { name: "Revalidate!" }).click();
+ await page.getByText("[effect: 3]").click();
+ }
+
+ await checkClientState();
+});
+
test("dynamic routes", async ({ page }) => {
checkNoError(page);
diff --git a/packages/react-server/examples/basic/src/routes/test/action/_action.tsx b/packages/react-server/examples/basic/src/routes/test/action/_action.tsx
index 991bf35fc..f2b82c3f4 100644
--- a/packages/react-server/examples/basic/src/routes/test/action/_action.tsx
+++ b/packages/react-server/examples/basic/src/routes/test/action/_action.tsx
@@ -1,5 +1,6 @@
"use server";
+import type { ActionContext } from "@hiogawa/react-server/server";
import { sleep, tinyassert } from "@hiogawa/utils";
let counter = 0;
@@ -35,3 +36,7 @@ export async function actionCheckAnswer(formData: FormData) {
const message = answer === 2 ? "Correct!" : "Wrong!";
return { message };
}
+
+export async function actionTestRevalidate(this: ActionContext) {
+ this.revalidate = true;
+}
diff --git a/packages/react-server/examples/basic/src/routes/test/action/page.tsx b/packages/react-server/examples/basic/src/routes/test/action/page.tsx
index 5736be91c..4f5c60d50 100644
--- a/packages/react-server/examples/basic/src/routes/test/action/page.tsx
+++ b/packages/react-server/examples/basic/src/routes/test/action/page.tsx
@@ -1,4 +1,9 @@
-import { changeCounter, getCounter, getMessages } from "./_action";
+import {
+ actionTestRevalidate,
+ changeCounter,
+ getCounter,
+ getMessages,
+} from "./_action";
import {
ActionDataTest,
Chat,
@@ -17,6 +22,10 @@ export default async function Page() {
+
);
diff --git a/packages/react-server/src/entry/react-server.tsx b/packages/react-server/src/entry/react-server.tsx
index 2130384f1..96a9f4ecd 100644
--- a/packages/react-server/src/entry/react-server.tsx
+++ b/packages/react-server/src/entry/react-server.tsx
@@ -1,12 +1,19 @@
-import { createDebug, objectMapKeys, objectMapValues } from "@hiogawa/utils";
+import {
+ createDebug,
+ objectMapKeys,
+ objectMapValues,
+ objectPick,
+} from "@hiogawa/utils";
import type { RenderToReadableStreamOptions } from "react-dom/server";
import reactServerDomServer from "react-server-dom-webpack/server.edge";
import {
- type ActionResult,
type LayoutRequest,
type ServerRouterData,
} from "../features/router/utils";
-import { type ActionContext } from "../features/server-action/react-server";
+import {
+ ActionContext,
+ type ActionResult,
+} from "../features/server-action/react-server";
import { ejectActionId } from "../features/server-action/utils";
import { unwrapStreamRequest } from "../features/server-component/utils";
import { createBundlerConfig } from "../features/use-client/react-server";
@@ -47,7 +54,10 @@ export const handler: ReactServerHandler = async (ctx) => {
}
// check stream only request
- const { request, layoutRequest, isStream } = unwrapStreamRequest(ctx.request);
+ const { request, layoutRequest, isStream } = unwrapStreamRequest(
+ ctx.request,
+ actionResult,
+ );
const stream = await render({ request, layoutRequest, actionResult });
if (isStream) {
@@ -82,7 +92,12 @@ async function render({
);
const bundlerConfig = createBundlerConfig();
return reactServerDomServer.renderToReadableStream(
- { layout: nodeMap, action: actionResult },
+ {
+ layout: nodeMap,
+ action: actionResult
+ ? objectPick(actionResult, ["id", "data", "error"])
+ : undefined,
+ },
bundlerConfig,
{
onError: reactServerOnError,
@@ -150,8 +165,8 @@ async function actionHandler({ request }: { request: Request }) {
action = mod[name];
}
- const context: ActionContext = { request, responseHeaders: {} };
- const result: ActionResult = { id };
+ const context = new ActionContext(request);
+ const result: ActionResult = { id, context };
try {
result.data = await action.apply(context, [formData]);
} catch (e) {
diff --git a/packages/react-server/src/features/router/utils.tsx b/packages/react-server/src/features/router/utils.tsx
index 5b2f741b8..46841a3e8 100644
--- a/packages/react-server/src/features/router/utils.tsx
+++ b/packages/react-server/src/features/router/utils.tsx
@@ -1,4 +1,4 @@
-import type { ReactServerErrorContext } from "../../server";
+import type { ActionResult } from "../server-action/react-server";
export type LayoutRequest = Record<
string,
@@ -8,16 +8,8 @@ export type LayoutRequest = Record<
}
>;
-// TODO: discriminated union
-export type ActionResult = {
- id?: string;
- error?: ReactServerErrorContext;
- data?: unknown;
- responseHeaders?: Record;
-};
-
export type ServerRouterData = {
- action?: ActionResult;
+ action?: Pick;
layout: Record;
};
diff --git a/packages/react-server/src/features/server-action/react-server.tsx b/packages/react-server/src/features/server-action/react-server.tsx
index f69d26908..e9aebcc04 100644
--- a/packages/react-server/src/features/server-action/react-server.tsx
+++ b/packages/react-server/src/features/server-action/react-server.tsx
@@ -1,3 +1,5 @@
+import type { ReactServerErrorContext } from "../../server";
+
export function createServerReference(id: string, action: Function): React.FC {
return Object.defineProperties(action, {
$$typeof: {
@@ -17,8 +19,18 @@ export function createServerReference(id: string, action: Function): React.FC {
}) as any;
}
-// action function can access context via (this: ActionContext)
-export interface ActionContext {
- request: Request;
- responseHeaders: Record; // TODO: Headers?
+// TODO: discriminated union
+export type ActionResult = {
+ id: string;
+ error?: ReactServerErrorContext;
+ data?: unknown;
+ responseHeaders?: Record;
+ context: ActionContext;
+};
+
+export class ActionContext {
+ responseHeaders: Record = {};
+ revalidate = false;
+
+ constructor(public request: Request) {}
}
diff --git a/packages/react-server/src/features/server-component/utils.tsx b/packages/react-server/src/features/server-component/utils.tsx
index 9b9866518..95a2723c5 100644
--- a/packages/react-server/src/features/server-component/utils.tsx
+++ b/packages/react-server/src/features/server-component/utils.tsx
@@ -3,6 +3,7 @@ import {
createLayoutContentRequest,
getNewLayoutContentKeys,
} from "../router/utils";
+import type { ActionResult } from "../server-action/react-server";
// TODO: use accept header x-component?
const RSC_PARAM = "__rsc";
@@ -22,13 +23,16 @@ export function wrapStreamRequestUrl(
return newUrl.toString();
}
-export function unwrapStreamRequest(request: Request) {
+export function unwrapStreamRequest(
+ request: Request,
+ actionResult?: ActionResult,
+) {
const url = new URL(request.url);
const rscParam = url.searchParams.get(RSC_PARAM);
url.searchParams.delete(RSC_PARAM);
let layoutRequest = createLayoutContentRequest(url.pathname);
- if (rscParam) {
+ if (rscParam && !actionResult?.context.revalidate) {
const param = JSON.parse(rscParam);
if (param.lastPathname && !param.invalidateAll) {
const newKeys = getNewLayoutContentKeys(param.lastPathname, url.pathname);