diff --git a/packages/react-server/examples/basic/e2e/basic.test.ts b/packages/react-server/examples/basic/e2e/basic.test.ts index 62b90aec3..ac80d67e8 100644 --- a/packages/react-server/examples/basic/e2e/basic.test.ts +++ b/packages/react-server/examples/basic/e2e/basic.test.ts @@ -654,29 +654,68 @@ test("redirect server action @js", async ({ page }) => { await page.waitForURL("/test/redirect?ok=server-action"); }); -test("action return value @js", async ({ page }) => { +test("useActionState @js", async ({ page }) => { checkNoError(page); await page.goto("/test/action"); await waitForHydration(page); - await testActionReturnValue(page, { js: true }); + await testUseActionState(page, { js: true }); }); -test("action return value @nojs", async ({ browser }) => { +test("useActionState @nojs", async ({ browser }) => { const page = await browser.newPage({ javaScriptEnabled: false }); checkNoError(page); await page.goto("/test/action"); - await testActionReturnValue(page, { js: false }); + await testUseActionState(page, { js: false }); }); -async function testActionReturnValue(page: Page, { js }: { js: boolean }) { +async function testUseActionState(page: Page, options: { js: boolean }) { await page.getByPlaceholder("Answer?").fill("3"); await page.getByPlaceholder("Answer?").press("Enter"); - await page.getByText("Wrong!").click(); - await expect(page.getByPlaceholder("Answer?")).toHaveValue(js ? "3" : ""); + if (options.js) { + await expect(page.getByTestId("action-state")).toHaveText("..."); + } + await page.getByText("Wrong! (tried once)").click(); + await expect(page.getByPlaceholder("Answer?")).toHaveValue( + options.js ? "3" : "", + ); await page.getByPlaceholder("Answer?").fill("2"); await page.getByPlaceholder("Answer?").press("Enter"); - await page.getByText("Correct!").click(); + await page.getByText("Correct! (tried 2 times)").click(); +} + +test("non-form aciton", async ({ page }) => { + checkNoError(page); + await page.goto("/test/action"); + await waitForHydration(page); + await page.getByPlaceholder("Number...").fill("1"); + await page.getByPlaceholder("Number...").press("Enter"); + await expect(page.getByTestId("non-form-action-state")).toHaveText("..."); + await expect(page.getByTestId("non-form-action-state")).toHaveText("1"); + await page.getByPlaceholder("Number...").fill("-1"); + await page.getByPlaceholder("Number...").press("Enter"); + await expect(page.getByTestId("non-form-action-state")).toHaveText("0"); +}); + +test("action bind @js", async ({ page }) => { + checkNoError(page); + await page.goto("/test/action"); + await waitForHydration(page); + await testActionBind(page); +}); + +test("action bind @nojs", async ({ browser }) => { + const page = await browser.newPage({ javaScriptEnabled: false }); + checkNoError(page); + await page.goto("/test/action"); + await testActionBind(page); +}); + +async function testActionBind(page: Page) { + await page.getByRole("button", { name: "Action Bind Test (server)" }).click(); + await expect(page.getByTestId("action-bind")).toContainText("server-bind"); + await page.getByRole("button", { name: "Action Bind Test (client)" }).click(); + await expect(page.getByTestId("action-bind")).toContainText("client-bind"); } test("action context @js", async ({ page }) => { diff --git a/packages/react-server/examples/basic/playwright.config.ts b/packages/react-server/examples/basic/playwright.config.ts index 7ac0a47e3..f99382546 100644 --- a/packages/react-server/examples/basic/playwright.config.ts +++ b/packages/react-server/examples/basic/playwright.config.ts @@ -4,7 +4,7 @@ const port = Number(process.env.E2E_PORT || 6174); const isPreview = Boolean(process.env.E2E_PREVIEW); const command = isPreview ? `pnpm preview --port ${port} --strict-port` - : `pnpm dev --port ${port} --strict-port`; + : `CI=1 pnpm dev --port ${port} --strict-port`; export default defineConfig({ testDir: "e2e", 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 4c59fbca9..10f6da62f 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 @@ -30,8 +30,35 @@ export async function slowAction(formData: FormData) { await sleep(Number(formData.get("sleep"))); } -export async function actionCheckAnswer(formData: FormData) { +type CheckAnswerState = { + message: string; + count: number; +}; + +export async function actionCheckAnswer( + prev: CheckAnswerState | null, + formData: FormData, +) { + await sleep(500); const answer = Number(formData.get("answer")); const message = answer === 2 ? "Correct!" : "Wrong!"; - return { message }; + return { message, count: (prev?.count ?? 0) + 1 }; +} + +let actionBindResult = "(none)"; + +export function getActionBindResult() { + return actionBindResult; +} + +export async function actionBindTest(bound: string) { + actionBindResult = bound; +} + +let nonFormActionCounter = 0; + +export async function nonFormAction(_prev: unknown, delta: number) { + await sleep(500); + nonFormActionCounter += delta; + return nonFormActionCounter; } diff --git a/packages/react-server/examples/basic/src/routes/test/action/_client.tsx b/packages/react-server/examples/basic/src/routes/test/action/_client.tsx index e7df3d33f..a993712f8 100644 --- a/packages/react-server/examples/basic/src/routes/test/action/_client.tsx +++ b/packages/react-server/examples/basic/src/routes/test/action/_client.tsx @@ -1,13 +1,15 @@ "use client"; -import { useActionData } from "@hiogawa/react-server/client"; +import { useActionState } from "@hiogawa/react-server/client"; import React from "react"; import ReactDom from "react-dom"; import { + actionBindTest, actionCheckAnswer, addMessage, changeCounter, type getMessages, + nonFormAction, slowAction, } from "./_action"; @@ -100,23 +102,75 @@ export function Chat(props: { messages: ReturnType }) { } export function ActionDataTest() { - const data = useActionData(actionCheckAnswer); + const [data, formAction, isPending] = useActionState(actionCheckAnswer, null); + return ( -
-

Action Data

+ +

useActionState

1 + 1 =
-
{data?.message}
+
+ {isPending ? ( + "..." + ) : data ? ( + <> + {data.message} (tried{" "} + {data.count === 1 ? "once" : data.count + " times"}) + + ) : null} +
); } +export function NonFormActionTest() { + const [data, formAction, isPending] = useActionState(nonFormAction, null); + return ( +
{ + e.preventDefault(); + const formData = new FormData(e.currentTarget); + const delta = Number(formData.get("delta")); + formAction(delta); + }} + > +

Non-form-action action

+
+ + +
+ {isPending ? "..." : data} +
+
+
+ ); +} + +export function ClientActionBindTest() { + const formAction = actionBindTest.bind(null, "client-bind"); + return ( +
+ + +
+ ); +} + // https://react.dev/reference/react-dom/hooks/useFormStatus export function FormStateTest() { return ( @@ -132,7 +186,7 @@ function FormStateTestInner() { return ( <> -

Form Status

+

useFormStatus

); @@ -45,3 +60,15 @@ function Counter3() { ); } + +function ServerActionBindTest() { + const formAction = actionBindTest.bind(null, "server-bind"); + return ( +
+ + +
+ ); +} diff --git a/packages/react-server/examples/basic/src/routes/test/revalidate/_action.tsx b/packages/react-server/examples/basic/src/routes/test/revalidate/_action.tsx index cfaab54b9..47d9a1288 100644 --- a/packages/react-server/examples/basic/src/routes/test/revalidate/_action.tsx +++ b/packages/react-server/examples/basic/src/routes/test/revalidate/_action.tsx @@ -1,7 +1,7 @@ "use server"; -import type { ActionContext } from "@hiogawa/react-server/server"; +import { useActionContext } from "@hiogawa/react-server/server"; -export async function actionTestRevalidate(this: ActionContext) { - this.revalidate = true; +export async function actionTestRevalidate() { + useActionContext().revalidate = true; } diff --git a/packages/react-server/examples/basic/src/routes/test/session/_action.tsx b/packages/react-server/examples/basic/src/routes/test/session/_action.tsx index be796efec..7a31ffaa0 100644 --- a/packages/react-server/examples/basic/src/routes/test/session/_action.tsx +++ b/packages/react-server/examples/basic/src/routes/test/session/_action.tsx @@ -1,6 +1,6 @@ "use server"; -import { type ActionContext, redirect } from "@hiogawa/react-server/server"; +import { redirect, useActionContext } from "@hiogawa/react-server/server"; import { tinyassert } from "@hiogawa/utils"; import { getSession, setSession } from "./utils"; @@ -30,11 +30,8 @@ export function getCounter() { return counter; } -export async function incrementCounter( - this: ActionContext, - formData: FormData, -) { - const session = getSession(this.request.headers); +export async function incrementCounter(formData: FormData) { + const session = getSession(useActionContext().request.headers); if (!session?.name) { throw redirect("/test/session/signin"); } diff --git a/packages/react-server/examples/basic/tsconfig.json b/packages/react-server/examples/basic/tsconfig.json index 7d581715e..e58bfa260 100644 --- a/packages/react-server/examples/basic/tsconfig.json +++ b/packages/react-server/examples/basic/tsconfig.json @@ -16,7 +16,7 @@ "moduleResolution": "Bundler", "module": "ESNext", "target": "ESNext", - "lib": ["ESNext", "DOM"], + "lib": ["ESNext", "DOM", "DOM.Iterable"], "types": ["vite/client", "react/experimental", "react-dom/experimental"], "jsx": "react-jsx" } diff --git a/packages/react-server/package.json b/packages/react-server/package.json index d18b8880e..0f6b68c17 100644 --- a/packages/react-server/package.json +++ b/packages/react-server/package.json @@ -1,6 +1,6 @@ { "name": "@hiogawa/react-server", - "version": "0.1.17", + "version": "0.1.18-pre.0", "license": "MIT", "type": "module", "homepage": "https://github.com/hi-ogawa/vite-plugins/tree/main/packages/react-server", diff --git a/packages/react-server/src/client.tsx b/packages/react-server/src/client.tsx index b39ef57b3..606147fe6 100644 --- a/packages/react-server/src/client.tsx +++ b/packages/react-server/src/client.tsx @@ -2,5 +2,5 @@ export { Link, LinkForm } from "./lib/client/link"; export { useRouter } from "./lib/client/router"; -export { useActionData } from "./features/server-action/client"; export { routerRevalidate } from "./features/router/client"; +export { useActionState } from "./features/server-action/client"; diff --git a/packages/react-server/src/entry/browser.tsx b/packages/react-server/src/entry/browser.tsx index cf7034832..d2e950969 100644 --- a/packages/react-server/src/entry/browser.tsx +++ b/packages/react-server/src/entry/browser.tsx @@ -1,4 +1,4 @@ -import { createDebug, memoize, tinyassert } from "@hiogawa/utils"; +import { createDebug, memoize } from "@hiogawa/utils"; import React from "react"; import reactDomClient from "react-dom/client"; import { @@ -8,7 +8,7 @@ import { routerRevalidate, } from "../features/router/client"; import type { ServerRouterData } from "../features/router/utils"; -import { injectActionId } from "../features/server-action/utils"; +import { wrapStreamActionRequest } from "../features/server-action/utils"; import { wrapStreamRequestUrl } from "../features/server-component/utils"; import { initializeWebpackBrowser } from "../features/use-client/browser"; import { RootErrorBoundary } from "../lib/client/error-boundary"; @@ -42,43 +42,35 @@ export async function start() { // const callServer: CallServerCallback = async (id, args) => { debug("callServer", { id, args }); - if (0) { - // TODO: proper encoding? - await reactServerDomClient.encodeReply(args); - } else { - // $ACTION_ID is injected during SSR - // but it can stripped away on client re-render (e.g. HMR?) - // so we do it here again to inject on client. - tinyassert(args[0] instanceof FormData); - injectActionId(args[0], id); - } const request = new Request( wrapStreamRequestUrl(history.location.href, { lastPathname: history.location.pathname, }), { method: "POST", - body: args[0], + body: await reactServerDomClient.encodeReply(args), + headers: wrapStreamActionRequest(id), }, ); - __startActionTransition(() => { - __setLayout( - reactServerDomClient.createFromFetch(fetch(request), { - callServer, - }), - ); - }); + const result = reactServerDomClient.createFromFetch( + fetch(request), + { callServer }, + ); + __startActionTransition(() => __setLayout(result)); + return (await result).action?.data; }; // expose as global to be used for createServerReference __global.callServer = callServer; // prepare initial layout data from inline