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() { +
+

Action Revalidate

+ +
); 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);