From 24c5e174d105f047d9c3e2648cd085658970a085 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Thu, 11 Apr 2024 11:13:04 +0900 Subject: [PATCH 01/30] refactor(react-server): use `encodeReply/decodeReply` --- packages/react-server/src/entry/browser.tsx | 11 +++++--- .../react-server/src/entry/react-server.tsx | 26 ++++++++++++++----- .../src/features/server-action/client.tsx | 9 ++++--- .../src/features/server-component/utils.tsx | 4 +++ packages/react-server/src/lib/types-env.d.ts | 6 ++--- tsconfig.base.json | 2 +- 6 files changed, 40 insertions(+), 18 deletions(-) diff --git a/packages/react-server/src/entry/browser.tsx b/packages/react-server/src/entry/browser.tsx index a116d480f..edc711105 100644 --- a/packages/react-server/src/entry/browser.tsx +++ b/packages/react-server/src/entry/browser.tsx @@ -46,15 +46,17 @@ export async function start() { // const callServer: CallServerCallback = async (id, args) => { debug("callServer", { id, args }); - if (0) { + let body: BodyInit; + if (1) { // TODO: proper encoding? - await reactServerDomClient.encodeReply(args); + body = 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); + body = args[0]; } const request = new Request( wrapStreamRequestUrl(history.location.href, { @@ -62,7 +64,10 @@ export async function start() { }), { method: "POST", - body: args[0], + body, + headers: { + "x-server-action-id": id, + }, }, ); __startActionTransition(() => { diff --git a/packages/react-server/src/entry/react-server.tsx b/packages/react-server/src/entry/react-server.tsx index 981c5e0b1..de038cd3e 100644 --- a/packages/react-server/src/entry/react-server.tsx +++ b/packages/react-server/src/entry/react-server.tsx @@ -3,6 +3,7 @@ import { objectMapKeys, objectMapValues, objectPick, + tinyassert, } from "@hiogawa/utils"; import type { RenderToReadableStreamOptions } from "react-dom/server"; import reactServerDomServer from "react-server-dom-webpack/server.edge"; @@ -15,7 +16,10 @@ import { type ActionResult, } from "../features/server-action/react-server"; import { ejectActionId } from "../features/server-action/utils"; -import { unwrapStreamRequest } from "../features/server-component/utils"; +import { + isStreamRequest, + unwrapStreamRequest, +} from "../features/server-component/utils"; import { createBundlerConfig } from "../features/use-client/react-server"; import { DEFAULT_ERROR_CONTEXT, @@ -148,13 +152,21 @@ function createRouter() { // server action // +// https://github.com/facebook/react/blob/da69b6af9697b8042834644b14d0e715d4ace18a/fixtures/flight/server/region.js#L105 async function actionHandler({ request }: { request: Request }) { - const formData = await request.formData(); - if (0) { - // TODO: proper decoding? - await reactServerDomServer.decodeReply(formData); + let id: string; + let args: unknown[]; + if (isStreamRequest(request)) { + const headerId = request.headers.get("x-server-action-id"); + tinyassert(headerId); + id = headerId; + const formData = await request.formData(); + args = await reactServerDomServer.decodeReply(formData); + } else { + const formData = await request.formData(); + id = ejectActionId(formData); + args = [formData]; } - const id = ejectActionId(formData); let action: Function; const [file, name] = id.split("::") as [string, string]; @@ -171,7 +183,7 @@ async function actionHandler({ request }: { request: Request }) { const context = new ActionContext(request); const result: ActionResult = { id, context }; try { - result.data = await action.apply(context, [formData]); + result.data = await action.apply(context, args); } catch (e) { result.error = getErrorContext(e) ?? DEFAULT_ERROR_CONTEXT; } finally { diff --git a/packages/react-server/src/features/server-action/client.tsx b/packages/react-server/src/features/server-action/client.tsx index 3e5623334..9b0f5afb5 100644 --- a/packages/react-server/src/features/server-action/client.tsx +++ b/packages/react-server/src/features/server-action/client.tsx @@ -5,9 +5,11 @@ import { __global } from "../../lib/global"; import { createError } from "../../server"; import { LayoutStateContext } from "../router/client"; import { injectActionId } from "./utils"; +injectActionId; // https://github.com/facebook/react/blob/89021fb4ec9aa82194b0788566e736a4cedfc0e4/packages/react-server-dom-webpack/src/ReactFlightWebpackReferences.js#L87 // https://github.com/facebook/react/blob/89021fb4ec9aa82194b0788566e736a4cedfc0e4/packages/react-client/src/ReactFlightReplyClient.js#L671-L678 +// https://github.com/facebook/react/blob/da69b6af9697b8042834644b14d0e715d4ace18a/packages/react-client/src/ReactFlightReplyClient.js#L552 export function createServerReference(id: string): React.FC { return Object.defineProperties( @@ -23,12 +25,13 @@ export function createServerReference(id: string): React.FC { configurable: true, }, $$bound: { value: null, configurable: true }, + // https://github.com/facebook/react/blob/da69b6af9697b8042834644b14d0e715d4ace18a/packages/react-client/src/ReactFlightReplyClient.js#L552 $$FORM_ACTION: { - value: (name: string) => { + value: (_identifierPrefix: string) => { const data = new FormData(); - injectActionId(data, id); + // injectActionId(data, id); return { - name, + name: `$ACTION_ID_${id}`, method: "POST", encType: "multipart/form-data", data, diff --git a/packages/react-server/src/features/server-component/utils.tsx b/packages/react-server/src/features/server-component/utils.tsx index b279bcf13..afd378bed 100644 --- a/packages/react-server/src/features/server-component/utils.tsx +++ b/packages/react-server/src/features/server-component/utils.tsx @@ -23,6 +23,10 @@ export function wrapStreamRequestUrl( return newUrl.toString(); } +export function isStreamRequest(request: Request) { + return new URL(request.url).searchParams.has(RSC_PARAM); +} + export function unwrapStreamRequest( request: Request, actionResult?: ActionResult, diff --git a/packages/react-server/src/lib/types-env.d.ts b/packages/react-server/src/lib/types-env.d.ts index d75e414ff..91582900c 100644 --- a/packages/react-server/src/lib/types-env.d.ts +++ b/packages/react-server/src/lib/types-env.d.ts @@ -13,7 +13,7 @@ declare module "react-server-dom-webpack/server.edge" { }, ): ReadableStream; - export function decodeReply(body: string | FormData): Promise; + export function decodeReply(body: FormData): Promise; } // https://github.com/facebook/react/blob/89021fb4ec9aa82194b0788566e736a4cedfc0e4/packages/react-server-dom-webpack/src/ReactFlightDOMClientEdge.js @@ -44,7 +44,5 @@ declare module "react-server-dom-webpack/client.browser" { }, ): Promise; - export function encodeReply( - v: unknown, - ): Promise; + export function encodeReply(v: unknown[]): Promise; } diff --git a/tsconfig.base.json b/tsconfig.base.json index d246edcac..90f314f9f 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -7,6 +7,6 @@ "moduleResolution": "Bundler", "module": "ESNext", "target": "ESNext", - "lib": ["ESNext", "DOM"] + "lib": ["ESNext", "DOM", "DOM.Iterable"] } } From 7bc30434a4272a46526cfe02d4f92c25da437150 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Thu, 11 Apr 2024 11:27:47 +0900 Subject: [PATCH 02/30] refactor: cleanup --- packages/react-server/src/entry/browser.tsx | 17 ++--------------- .../src/features/server-action/client.tsx | 7 ++----- .../src/features/server-action/utils.tsx | 13 +++++++------ 3 files changed, 11 insertions(+), 26 deletions(-) diff --git a/packages/react-server/src/entry/browser.tsx b/packages/react-server/src/entry/browser.tsx index edc711105..1df255090 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,6 @@ import { routerRevalidate, } from "../features/router/client"; import type { ServerRouterData } from "../features/router/utils"; -import { injectActionId } 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"; @@ -46,25 +45,13 @@ export async function start() { // const callServer: CallServerCallback = async (id, args) => { debug("callServer", { id, args }); - let body: BodyInit; - if (1) { - // TODO: proper encoding? - body = 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); - body = args[0]; - } const request = new Request( wrapStreamRequestUrl(history.location.href, { lastPathname: history.location.pathname, }), { method: "POST", - body, + body: await reactServerDomClient.encodeReply(args), headers: { "x-server-action-id": id, }, diff --git a/packages/react-server/src/features/server-action/client.tsx b/packages/react-server/src/features/server-action/client.tsx index 9b0f5afb5..e8756b272 100644 --- a/packages/react-server/src/features/server-action/client.tsx +++ b/packages/react-server/src/features/server-action/client.tsx @@ -5,7 +5,6 @@ import { __global } from "../../lib/global"; import { createError } from "../../server"; import { LayoutStateContext } from "../router/client"; import { injectActionId } from "./utils"; -injectActionId; // https://github.com/facebook/react/blob/89021fb4ec9aa82194b0788566e736a4cedfc0e4/packages/react-server-dom-webpack/src/ReactFlightWebpackReferences.js#L87 // https://github.com/facebook/react/blob/89021fb4ec9aa82194b0788566e736a4cedfc0e4/packages/react-client/src/ReactFlightReplyClient.js#L671-L678 @@ -28,13 +27,11 @@ export function createServerReference(id: string): React.FC { // https://github.com/facebook/react/blob/da69b6af9697b8042834644b14d0e715d4ace18a/packages/react-client/src/ReactFlightReplyClient.js#L552 $$FORM_ACTION: { value: (_identifierPrefix: string) => { - const data = new FormData(); - // injectActionId(data, id); return { - name: `$ACTION_ID_${id}`, + name: injectActionId(id), method: "POST", encType: "multipart/form-data", - data, + data: new FormData(), }; }, }, diff --git a/packages/react-server/src/features/server-action/utils.tsx b/packages/react-server/src/features/server-action/utils.tsx index 23fc0ee34..54d2c3abb 100644 --- a/packages/react-server/src/features/server-action/utils.tsx +++ b/packages/react-server/src/features/server-action/utils.tsx @@ -1,15 +1,16 @@ import { tinyassert } from "@hiogawa/utils"; -// TODO -// it doesn't seem like a right way to do progressive enhancement for SSR -// but works okay for simple cases? (e.g. no `bind`?) -// cf. https://github.com/facebook/react/pull/26774 +// TODO: use decodeAction +// https://github.com/facebook/react/blob/da69b6af9697b8042834644b14d0e715d4ace18a/packages/react-server/src/ReactFlightActionServer.js#L78 + const ACTION_ID_PREFIX = "$ACTION_ID_"; -export function injectActionId(formData: FormData, id: string) { - formData.set(ACTION_ID_PREFIX + id, ""); +export function injectActionId(id: string) { + return ACTION_ID_PREFIX + id; } +// TODO: use decodeAction +// https://github.com/facebook/react/blob/da69b6af9697b8042834644b14d0e715d4ace18a/packages/react-server/src/ReactFlightActionServer.js#L78 export function ejectActionId(formData: FormData) { let id: string | undefined; formData.forEach((_v, k) => { From 994b80d33b79c63e174b1bb367a9c18e85b21302 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Thu, 11 Apr 2024 11:31:47 +0900 Subject: [PATCH 03/30] refactor: cleanup --- packages/react-server/src/entry/browser.tsx | 5 ++--- packages/react-server/src/entry/react-server.tsx | 16 +++++++--------- .../src/features/server-action/client.tsx | 1 - .../src/features/server-action/utils.tsx | 14 ++++++++++++++ .../src/features/server-component/utils.tsx | 4 ---- 5 files changed, 23 insertions(+), 17 deletions(-) diff --git a/packages/react-server/src/entry/browser.tsx b/packages/react-server/src/entry/browser.tsx index 1df255090..088ff2e94 100644 --- a/packages/react-server/src/entry/browser.tsx +++ b/packages/react-server/src/entry/browser.tsx @@ -8,6 +8,7 @@ import { routerRevalidate, } from "../features/router/client"; import type { ServerRouterData } from "../features/router/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"; @@ -52,9 +53,7 @@ export async function start() { { method: "POST", body: await reactServerDomClient.encodeReply(args), - headers: { - "x-server-action-id": id, - }, + headers: wrapStreamActionRequest(id), }, ); __startActionTransition(() => { diff --git a/packages/react-server/src/entry/react-server.tsx b/packages/react-server/src/entry/react-server.tsx index de038cd3e..7f1d98edb 100644 --- a/packages/react-server/src/entry/react-server.tsx +++ b/packages/react-server/src/entry/react-server.tsx @@ -3,7 +3,6 @@ import { objectMapKeys, objectMapValues, objectPick, - tinyassert, } from "@hiogawa/utils"; import type { RenderToReadableStreamOptions } from "react-dom/server"; import reactServerDomServer from "react-server-dom-webpack/server.edge"; @@ -15,11 +14,11 @@ import { ActionContext, type ActionResult, } from "../features/server-action/react-server"; -import { ejectActionId } from "../features/server-action/utils"; import { - isStreamRequest, - unwrapStreamRequest, -} from "../features/server-component/utils"; + ejectActionId, + unwrapStreamActionRequest, +} from "../features/server-action/utils"; +import { unwrapStreamRequest } from "../features/server-component/utils"; import { createBundlerConfig } from "../features/use-client/react-server"; import { DEFAULT_ERROR_CONTEXT, @@ -156,10 +155,9 @@ function createRouter() { async function actionHandler({ request }: { request: Request }) { let id: string; let args: unknown[]; - if (isStreamRequest(request)) { - const headerId = request.headers.get("x-server-action-id"); - tinyassert(headerId); - id = headerId; + const streamAction = unwrapStreamActionRequest(request); + if (streamAction) { + id = streamAction.id; const formData = await request.formData(); args = await reactServerDomServer.decodeReply(formData); } else { diff --git a/packages/react-server/src/features/server-action/client.tsx b/packages/react-server/src/features/server-action/client.tsx index e8756b272..bd8ea42c6 100644 --- a/packages/react-server/src/features/server-action/client.tsx +++ b/packages/react-server/src/features/server-action/client.tsx @@ -8,7 +8,6 @@ import { injectActionId } from "./utils"; // https://github.com/facebook/react/blob/89021fb4ec9aa82194b0788566e736a4cedfc0e4/packages/react-server-dom-webpack/src/ReactFlightWebpackReferences.js#L87 // https://github.com/facebook/react/blob/89021fb4ec9aa82194b0788566e736a4cedfc0e4/packages/react-client/src/ReactFlightReplyClient.js#L671-L678 -// https://github.com/facebook/react/blob/da69b6af9697b8042834644b14d0e715d4ace18a/packages/react-client/src/ReactFlightReplyClient.js#L552 export function createServerReference(id: string): React.FC { return Object.defineProperties( diff --git a/packages/react-server/src/features/server-action/utils.tsx b/packages/react-server/src/features/server-action/utils.tsx index 54d2c3abb..1dcf1fa92 100644 --- a/packages/react-server/src/features/server-action/utils.tsx +++ b/packages/react-server/src/features/server-action/utils.tsx @@ -22,3 +22,17 @@ export function ejectActionId(formData: FormData) { tinyassert(id); return id; } + +const ACTION_ID_HEADER = "x-server-action-id"; + +export function wrapStreamActionRequest(id: string) { + return { [ACTION_ID_HEADER]: id }; +} + +export function unwrapStreamActionRequest(request: Request) { + const id = request.headers.get(ACTION_ID_HEADER); + if (id) { + return { id }; + } + return false; +} diff --git a/packages/react-server/src/features/server-component/utils.tsx b/packages/react-server/src/features/server-component/utils.tsx index afd378bed..b279bcf13 100644 --- a/packages/react-server/src/features/server-component/utils.tsx +++ b/packages/react-server/src/features/server-component/utils.tsx @@ -23,10 +23,6 @@ export function wrapStreamRequestUrl( return newUrl.toString(); } -export function isStreamRequest(request: Request) { - return new URL(request.url).searchParams.has(RSC_PARAM); -} - export function unwrapStreamRequest( request: Request, actionResult?: ActionResult, From 3d980171c845545c5b16f624da466909d09cfd26 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Thu, 11 Apr 2024 11:37:05 +0900 Subject: [PATCH 04/30] chore: comment --- packages/react-server/src/features/server-action/utils.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/react-server/src/features/server-action/utils.tsx b/packages/react-server/src/features/server-action/utils.tsx index 1dcf1fa92..37bcf12e6 100644 --- a/packages/react-server/src/features/server-action/utils.tsx +++ b/packages/react-server/src/features/server-action/utils.tsx @@ -1,8 +1,5 @@ import { tinyassert } from "@hiogawa/utils"; -// TODO: use decodeAction -// https://github.com/facebook/react/blob/da69b6af9697b8042834644b14d0e715d4ace18a/packages/react-server/src/ReactFlightActionServer.js#L78 - const ACTION_ID_PREFIX = "$ACTION_ID_"; export function injectActionId(id: string) { From 37f1ab9a61486871355ff82442276a8f9862aaf0 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Thu, 11 Apr 2024 11:47:12 +0900 Subject: [PATCH 05/30] chore: assert ssr --- packages/react-server/src/features/server-action/client.tsx | 4 +++- packages/react-server/src/features/server-action/utils.tsx | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/react-server/src/features/server-action/client.tsx b/packages/react-server/src/features/server-action/client.tsx index bd8ea42c6..b3db9a047 100644 --- a/packages/react-server/src/features/server-action/client.tsx +++ b/packages/react-server/src/features/server-action/client.tsx @@ -22,10 +22,12 @@ export function createServerReference(id: string): React.FC { value: id, configurable: true, }, - $$bound: { value: null, configurable: true }, + $$bound: { value: ["hello"], configurable: true }, + // TODO: defaultEncodeFormAction // https://github.com/facebook/react/blob/da69b6af9697b8042834644b14d0e715d4ace18a/packages/react-client/src/ReactFlightReplyClient.js#L552 $$FORM_ACTION: { value: (_identifierPrefix: string) => { + tinyassert(import.meta.env.SSR); return { name: injectActionId(id), method: "POST", diff --git a/packages/react-server/src/features/server-action/utils.tsx b/packages/react-server/src/features/server-action/utils.tsx index 37bcf12e6..2c1438929 100644 --- a/packages/react-server/src/features/server-action/utils.tsx +++ b/packages/react-server/src/features/server-action/utils.tsx @@ -6,7 +6,7 @@ export function injectActionId(id: string) { return ACTION_ID_PREFIX + id; } -// TODO: use decodeAction +// TODO: decodeAction // https://github.com/facebook/react/blob/da69b6af9697b8042834644b14d0e715d4ace18a/packages/react-server/src/ReactFlightActionServer.js#L78 export function ejectActionId(formData: FormData) { let id: string | undefined; From 34d03b1d27f6a4166f713df61321e6d864cda020 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Thu, 11 Apr 2024 12:14:41 +0900 Subject: [PATCH 06/30] test: action after client render --- .../examples/basic/e2e/basic.test.ts | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/packages/react-server/examples/basic/e2e/basic.test.ts b/packages/react-server/examples/basic/e2e/basic.test.ts index 257b38a6e..523b95dc0 100644 --- a/packages/react-server/examples/basic/e2e/basic.test.ts +++ b/packages/react-server/examples/basic/e2e/basic.test.ts @@ -496,6 +496,38 @@ test("server action with js", async ({ page }) => { await page.getByText(`[effect: ${count}]`).click(); }); +test("server action after client render", async ({ page }) => { + checkNoError(page); + + await page.goto("/test"); + await waitForHydration(page); + + // on client render, the form doesn't have hidden $ACTION_ID_... + await page.getByRole("link", { name: "/test/action" }).click(); + + const checkClientState = await setupCheckClientState(page); + + await page.getByText("Count: 0").click(); + await page.getByRole("button", { name: "+1" }).first().click(); + await page.getByText("Count: 1").click(); + await page.getByRole("button", { name: "+1" }).nth(1).click(); + await page.getByText("Count: 2").click(); + await page.getByRole("button", { name: "+1" }).nth(2).click(); + await page.getByText("Count: 3").click(); + await page.getByRole("button", { name: "-1" }).first().click(); + await page.getByText("Count: 2").click(); + await page.getByRole("button", { name: "-1" }).nth(1).click(); + await page.getByText("Count: 1").click(); + await page.getByRole("button", { name: "-1" }).nth(2).click(); + await page.getByText("Count: 0").click(); + + await checkClientState(); + + // check layout doesn't re-render + const count = process.env.E2E_PREVIEW ? 1 : 2; + await page.getByText(`[effect: ${count}]`).click(); +}); + test("server action no js", async ({ browser }) => { const page = await browser.newPage({ javaScriptEnabled: false }); await page.goto("/test/action"); From 669cf9776c81a85ae0f031391ad248827d4a8d67 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Fri, 12 Apr 2024 19:15:45 +0900 Subject: [PATCH 07/30] refactor(react-server): use official `createServerReference` (#283) --- .../basic/src/routes/test/action/_action.tsx | 10 ++++++ .../basic/src/routes/test/action/_client.tsx | 35 +++++++++++++++++++ .../basic/src/routes/test/action/page.tsx | 4 +++ .../react-server/examples/basic/tsconfig.json | 2 +- packages/react-server/src/entry/browser.tsx | 5 ++- packages/react-server/src/entry/server.tsx | 2 ++ .../src/features/server-action/client.tsx | 1 + 7 files changed, 57 insertions(+), 2 deletions(-) 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..4d6b91a6a 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 @@ -35,3 +35,13 @@ export async function actionCheckAnswer(formData: FormData) { const message = answer === 2 ? "Correct!" : "Wrong!"; return { message }; } + +export async function actionStateTest(prevArg: unknown, formData: FormData) { + const result = { prev: prevArg, form: [...formData.entries()] }; + console.log("[actionStateTest]", result); + return result; +} + +export async function actionBindTest(boundArg: string, formData: FormData) { + console.log("[actionBindTest]", { boundArg, form: [...formData.entries()] }); +} 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..3859cdd6e 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 @@ -4,7 +4,9 @@ import { useActionData } from "@hiogawa/react-server/client"; import React from "react"; import ReactDom from "react-dom"; import { + actionBindTest, actionCheckAnswer, + actionStateTest, addMessage, changeCounter, type getMessages, @@ -117,6 +119,39 @@ export function ActionDataTest() { ); } +// TODO +export function UseActionStateTest() { + const [data, formAction, isPending] = ReactDom.useFormState( + actionStateTest, + null, + ); + + React.useEffect(() => { + console.log("[useActionState]", data, isPending); + }, [data, isPending]); + + return ( +
+ + +
+ ); +} + +export function ClientActionBindTest() { + const formAction = actionBindTest.bind(null, "bound!!"); + return ( +
+ + +
+ ); +} + // https://react.dev/reference/react-dom/hooks/useFormStatus export function FormStateTest() { return ( 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..edd362a5e 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 @@ -2,9 +2,11 @@ import { changeCounter, getCounter, getMessages } from "./_action"; import { ActionDataTest, Chat, + ClientActionBindTest, Counter, Counter2, FormStateTest, + UseActionStateTest, } from "./_client"; export default async function Page() { @@ -17,6 +19,8 @@ export default async function Page() { + + ); 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/src/entry/browser.tsx b/packages/react-server/src/entry/browser.tsx index 088ff2e94..57098316d 100644 --- a/packages/react-server/src/entry/browser.tsx +++ b/packages/react-server/src/entry/browser.tsx @@ -176,7 +176,10 @@ export async function start() { if (document.documentElement.dataset["noHydate"]) { reactDomClient.createRoot(document).render(reactRootEl); } else { - reactDomClient.hydrateRoot(document, reactRootEl); + reactDomClient.hydrateRoot(document, reactRootEl, { + // @ts-ignore TODO + formState: null, + }); } // custom event for RSC reload diff --git a/packages/react-server/src/entry/server.tsx b/packages/react-server/src/entry/server.tsx index e12369a6f..92f28f1f7 100644 --- a/packages/react-server/src/entry/server.tsx +++ b/packages/react-server/src/entry/server.tsx @@ -125,6 +125,8 @@ export async function renderHtml( let status = 200; try { ssrStream = await reactDomServer.renderToReadableStream(reactRootEl, { + // @ts-ignore TODO + formState: null, bootstrapModules: url.search.includes("__noJs") ? [] : assets.bootstrapModules, diff --git a/packages/react-server/src/features/server-action/client.tsx b/packages/react-server/src/features/server-action/client.tsx index 66589ba68..87c9b1d71 100644 --- a/packages/react-server/src/features/server-action/client.tsx +++ b/packages/react-server/src/features/server-action/client.tsx @@ -5,6 +5,7 @@ import { RedirectBoundary } from "../../runtime-client"; import { createError } from "../../server"; import { LayoutStateContext } from "../router/client"; +// TODO: replace with React.useActionState export function useActionData any>( action: T, ): Awaited> | undefined { From e7d87c27ea8692e886026e8f9c65cc4d82d67da7 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Fri, 19 Apr 2024 16:59:35 +0900 Subject: [PATCH 08/30] refactor(react-server): use official `decodeAction/decodeFormState` (#284) --- .../examples/basic/e2e/basic.test.ts | 4 +- packages/react-server/src/entry/browser.tsx | 4 -- .../react-server/src/entry/react-server.tsx | 49 +++++++-------- packages/react-server/src/entry/server.tsx | 2 +- .../features/server-action/react-server.tsx | 61 ++++++++++++++++++- .../src/features/server-action/utils.tsx | 22 ------- packages/react-server/src/lib/types-env.d.ts | 5 ++ packages/react-server/src/plugin/index.ts | 27 ++++++++ 8 files changed, 120 insertions(+), 54 deletions(-) diff --git a/packages/react-server/examples/basic/e2e/basic.test.ts b/packages/react-server/examples/basic/e2e/basic.test.ts index 62b90aec3..1647487dc 100644 --- a/packages/react-server/examples/basic/e2e/basic.test.ts +++ b/packages/react-server/examples/basic/e2e/basic.test.ts @@ -661,7 +661,7 @@ test("action return value @js", async ({ page }) => { await testActionReturnValue(page, { js: true }); }); -test("action return value @nojs", async ({ browser }) => { +test.skip("action return value @nojs", async ({ browser }) => { const page = await browser.newPage({ javaScriptEnabled: false }); checkNoError(page); await page.goto("/test/action"); @@ -685,7 +685,7 @@ test("action context @js", async ({ page }) => { await testActionContext(page); }); -test("action context @nojs", async ({ browser }) => { +test.skip("action context @nojs", async ({ browser }) => { const page = await browser.newPage({ javaScriptEnabled: false }); checkNoError(page); await page.goto("/test/session"); diff --git a/packages/react-server/src/entry/browser.tsx b/packages/react-server/src/entry/browser.tsx index 4ae029c56..3f9decdfc 100644 --- a/packages/react-server/src/entry/browser.tsx +++ b/packages/react-server/src/entry/browser.tsx @@ -25,10 +25,6 @@ import { readStreamScript } from "../utils/stream-script"; const debug = createDebug("react-server:browser"); export async function start() { - if (window.location.search.includes("__noCsr")) { - return; - } - initializeWebpackBrowser(); const { default: reactServerDomClient } = await import( diff --git a/packages/react-server/src/entry/react-server.tsx b/packages/react-server/src/entry/react-server.tsx index 168c191a3..de7f83052 100644 --- a/packages/react-server/src/entry/react-server.tsx +++ b/packages/react-server/src/entry/react-server.tsx @@ -13,11 +13,12 @@ import { import { ActionContext, type ActionResult, + createActionBundlerConfig, + importServerAction, + initializeWebpackReactServer, + serverReferenceImportPromiseCache, } from "../features/server-action/react-server"; -import { - ejectActionId, - unwrapStreamActionRequest, -} from "../features/server-action/utils"; +import { unwrapStreamActionRequest } from "../features/server-action/utils"; import { unwrapStreamRequest } from "../features/server-component/utils"; import { createBundlerConfig } from "../features/use-client/react-server"; import { @@ -49,6 +50,12 @@ export type ReactServerHandlerResult = | ReactServerHandlerStreamResult; export const handler: ReactServerHandler = async (ctx) => { + initializeWebpackReactServer(); + + if (import.meta.env.DEV) { + serverReferenceImportPromiseCache.clear(); + } + // action let actionResult: ActionResult | undefined; if (ctx.request.method === "POST") { @@ -152,35 +159,29 @@ function createRouter() { // https://github.com/facebook/react/blob/da69b6af9697b8042834644b14d0e715d4ace18a/fixtures/flight/server/region.js#L105 async function actionHandler({ request }: { request: Request }) { - let id: string; - let args: unknown[]; + const context = new ActionContext(request); const streamAction = unwrapStreamActionRequest(request); + let boundAction: Function; + let id: string | undefined; if (streamAction) { - id = streamAction.id; const formData = await request.formData(); - args = await reactServerDomServer.decodeReply(formData); + const args = await reactServerDomServer.decodeReply(formData); + const action = await importServerAction(streamAction.id); + id = streamAction.id; + boundAction = () => action.apply(context, args); } else { + // TODO: cannot bind context + // TODO: decodeFormState const formData = await request.formData(); - id = ejectActionId(formData); - args = [formData]; - } - - let action: Function; - const [file, name] = id.split("#") as [string, string]; - if (import.meta.env.DEV) { - const mod: any = await import(/* @vite-ignore */ file); - action = mod[name]; - } else { - // include all "use server" files via virtual module on build - const virtual = await import("virtual:rsc-use-server" as string); - const mod = await virtual.default[file](); - action = mod[name]; + boundAction = await reactServerDomServer.decodeAction( + formData, + createActionBundlerConfig(), + ); } - const context = new ActionContext(request); const result: ActionResult = { id, context }; try { - result.data = await action.apply(context, args); + result.data = await boundAction(); } catch (e) { result.error = getErrorContext(e) ?? DEFAULT_ERROR_CONTEXT; } finally { diff --git a/packages/react-server/src/entry/server.tsx b/packages/react-server/src/entry/server.tsx index ce5771d19..d71943adf 100644 --- a/packages/react-server/src/entry/server.tsx +++ b/packages/react-server/src/entry/server.tsx @@ -127,7 +127,7 @@ export async function renderHtml( ssrStream = await reactDomServer.renderToReadableStream(reactRootEl, { // @ts-ignore TODO formState: null, - bootstrapModules: url.search.includes("__noJs") + bootstrapModules: url.search.includes("__nojs") ? [] : assets.bootstrapModules, onError(error, errorInfo) { 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 680c0aaa2..968bb6513 100644 --- a/packages/react-server/src/features/server-action/react-server.tsx +++ b/packages/react-server/src/features/server-action/react-server.tsx @@ -1,4 +1,6 @@ +import { memoize, tinyassert } from "@hiogawa/utils"; import reactServerDomWebpack from "react-server-dom-webpack/server.edge"; +import type { BundlerConfig, ImportManifestEntry } from "../../lib/types"; import type { ReactServerErrorContext } from "../../server"; // https://github.com/facebook/react/blob/c8a035036d0f257c514b3628e927dd9dd26e5a09/packages/react-server-dom-webpack/src/ReactFlightWebpackReferences.js#L87 @@ -12,7 +14,7 @@ export function registerServerReference( } export type ActionResult = { - id: string; + id?: string; error?: ReactServerErrorContext; data?: unknown; responseHeaders?: Record; @@ -27,3 +29,60 @@ export class ActionContext { constructor(public request: Request) {} } + +const REFERENCE_SEP = "#"; + +export function createActionBundlerConfig(): BundlerConfig { + return new Proxy( + {}, + { + get(_target, $$id, _receiver) { + tinyassert(typeof $$id === "string"); + let [id, name] = $$id.split(REFERENCE_SEP); + tinyassert(id); + tinyassert(name); + return { + id, + name, + chunks: [], + } satisfies ImportManifestEntry; + }, + }, + ); +} + +// same as packages/react-server/src/features/use-client/server.tsx +export const serverReferenceImportPromiseCache = new Map< + string, + Promise +>(); + +const serverReferenceWebpackRequire = memoize(importServerReference, { + cache: serverReferenceImportPromiseCache, +}); + +export function initializeWebpackReactServer() { + Object.assign(globalThis, { + __vite_react_server_webpack_require__: serverReferenceWebpackRequire, + __vite_react_server_webpack_chunk_load__: () => { + throw new Error("todo: __webpack_chunk_load__"); + }, + }); +} + +async function importServerReference(id: string): Promise { + if (import.meta.env.DEV) { + return await import(/* @vite-ignore */ id); + } else { + const mod = await import("virtual:rsc-use-server" as string); + const dynImport = mod.default[id]; + tinyassert(dynImport, `server reference not found '${id}'`); + return dynImport(); + } +} + +export async function importServerAction(id: string): Promise { + const [file, name] = id.split(REFERENCE_SEP) as [string, string]; + const mod: any = await importServerReference(file); + return mod[name]; +} diff --git a/packages/react-server/src/features/server-action/utils.tsx b/packages/react-server/src/features/server-action/utils.tsx index 2c1438929..0c364fdab 100644 --- a/packages/react-server/src/features/server-action/utils.tsx +++ b/packages/react-server/src/features/server-action/utils.tsx @@ -1,25 +1,3 @@ -import { tinyassert } from "@hiogawa/utils"; - -const ACTION_ID_PREFIX = "$ACTION_ID_"; - -export function injectActionId(id: string) { - return ACTION_ID_PREFIX + id; -} - -// TODO: decodeAction -// https://github.com/facebook/react/blob/da69b6af9697b8042834644b14d0e715d4ace18a/packages/react-server/src/ReactFlightActionServer.js#L78 -export function ejectActionId(formData: FormData) { - let id: string | undefined; - formData.forEach((_v, k) => { - if (k.startsWith(ACTION_ID_PREFIX)) { - id = k.slice(ACTION_ID_PREFIX.length); - formData.delete(k); - } - }); - tinyassert(id); - return id; -} - const ACTION_ID_HEADER = "x-server-action-id"; export function wrapStreamActionRequest(id: string) { diff --git a/packages/react-server/src/lib/types-env.d.ts b/packages/react-server/src/lib/types-env.d.ts index 41e65b429..eb1ada2b0 100644 --- a/packages/react-server/src/lib/types-env.d.ts +++ b/packages/react-server/src/lib/types-env.d.ts @@ -26,6 +26,11 @@ declare module "react-server-dom-webpack/server.edge" { ): T; export function decodeReply(body: FormData): Promise; + + export function decodeAction( + body: FormData, + bundlerConfig: import("./types").BundlerConfig, + ): Promise<() => Promise>; } // https://github.com/facebook/react/blob/89021fb4ec9aa82194b0788566e736a4cedfc0e4/packages/react-server-dom-webpack/src/ReactFlightDOMClientEdge.js diff --git a/packages/react-server/src/plugin/index.ts b/packages/react-server/src/plugin/index.ts index 6d4cad792..eec7883c3 100644 --- a/packages/react-server/src/plugin/index.ts +++ b/packages/react-server/src/plugin/index.ts @@ -170,6 +170,32 @@ export function vitePluginReactServer(options?: { }, ), + { + name: "patch-react-server-dom-webpack", + transform(code, id, _options) { + if (id.includes("react-server-dom-webpack")) { + // rename webpack markers in react server runtime + // to avoid conflict with ssr runtime which shares same globals + code = code.replaceAll( + "__webpack_require__", + "__vite_react_server_webpack_require__", + ); + code = code.replaceAll( + "__webpack_chunk_load__", + "__vite_react_server_webpack_chunk_load__", + ); + + // make server reference async for simplicity (stale chunkCache, etc...) + // see TODO in https://github.com/facebook/react/blob/33a32441e991e126e5e874f831bd3afc237a3ecf/packages/react-server-dom-webpack/src/ReactFlightClientConfigBundlerWebpack.js#L131-L132 + code = code.replaceAll("if (isAsyncImport(metadata))", "if (true)"); + code = code.replaceAll("4===a.length", "true"); + + return code; + } + return; + }, + }, + ...(options?.plugins ?? []), ], build: { @@ -298,6 +324,7 @@ export function vitePluginReactServer(options?: { }, }; + // plugins for main vite dev server (browser / ssr) return [ rscParentPlugin, vitePluginSilenceUseClientBuildWarning(), From 92de3e3542f9f374b879370b67b9e64f3c00ae18 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Fri, 19 Apr 2024 17:08:26 +0900 Subject: [PATCH 09/30] chore: use React.useActionState --- .../examples/basic/src/routes/test/action/_client.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 3859cdd6e..8c0408ab5 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 @@ -121,7 +121,8 @@ export function ActionDataTest() { // TODO export function UseActionStateTest() { - const [data, formAction, isPending] = ReactDom.useFormState( + // @ts-ignore + const [data, formAction, isPending] = React.useActionState( actionStateTest, null, ); From 9e87c64a881c9f40327684c047bf880ef4bffd27 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Fri, 19 Apr 2024 17:44:50 +0900 Subject: [PATCH 10/30] wip: js action return value --- packages/react-server/src/entry/browser.tsx | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/react-server/src/entry/browser.tsx b/packages/react-server/src/entry/browser.tsx index 3f9decdfc..4c3a5a96f 100644 --- a/packages/react-server/src/entry/browser.tsx +++ b/packages/react-server/src/entry/browser.tsx @@ -52,13 +52,12 @@ export async function start() { headers: wrapStreamActionRequest(id), }, ); - __startActionTransition(() => { - __setLayout( - reactServerDomClient.createFromFetch(fetch(request), { - callServer, - }), - ); - }); + const result = reactServerDomClient.createFromFetch( + fetch(request), + { callServer }, + ); + __startActionTransition(() => __setLayout(result)); + return result.then((v) => v.action?.data); }; // expose as global to be used for createServerReference From 9d0a3c3b8c1984c39560f4648d8d4502ea0ee1aa Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Fri, 19 Apr 2024 18:03:31 +0900 Subject: [PATCH 11/30] wip: action return value (give up global action pending) --- packages/react-server/src/entry/browser.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/react-server/src/entry/browser.tsx b/packages/react-server/src/entry/browser.tsx index 4c3a5a96f..8882ff14a 100644 --- a/packages/react-server/src/entry/browser.tsx +++ b/packages/react-server/src/entry/browser.tsx @@ -52,12 +52,14 @@ export async function start() { headers: wrapStreamActionRequest(id), }, ); - const result = reactServerDomClient.createFromFetch( + const result = await reactServerDomClient.createFromFetch( fetch(request), { callServer }, ); - __startActionTransition(() => __setLayout(result)); - return result.then((v) => v.action?.data); + // TODO: needs to await action return value before transition, + // but that kills a whole point of "action pending" state + __startActionTransition(() => __setLayout(Promise.resolve(result))); + return result.action?.data; }; // expose as global to be used for createServerReference From adc98b932b050af007c9c705bb246939ea28594d Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Fri, 19 Apr 2024 18:17:03 +0900 Subject: [PATCH 12/30] chore: replace DIY useActionData --- .../basic/src/routes/test/action/_action.tsx | 2 +- .../basic/src/routes/test/action/_client.tsx | 25 +++++++++++++------ packages/react-server/src/lib/types.ts | 11 ++++++++ 3 files changed, 29 insertions(+), 9 deletions(-) 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 fe4ddfe6f..faf1e2a86 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,7 +30,7 @@ export async function slowAction(formData: FormData) { await sleep(Number(formData.get("sleep"))); } -export async function actionCheckAnswer(formData: FormData) { +export async function actionCheckAnswer(_prev: unknown, formData: FormData) { const answer = Number(formData.get("answer")); const message = answer === 2 ? "Correct!" : "Wrong!"; return { message }; 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 8c0408ab5..419706de8 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,6 +1,5 @@ "use client"; -import { useActionData } from "@hiogawa/react-server/client"; import React from "react"; import ReactDom from "react-dom"; import { @@ -101,10 +100,24 @@ export function Chat(props: { messages: ReturnType }) { ); } +// https://github.com/facebook/react/pull/28491 +type ReactUseActionState = ( + action: (state: Awaited, payload: Payload) => State | Promise, + initialState: Awaited, + permalink?: string, +) => [ + state: Awaited, + dispatch: (payload: Payload) => void, + isPending: boolean, +]; + +const useActionState: ReactUseActionState = (React as any).useActionState; + export function ActionDataTest() { - const data = useActionData(actionCheckAnswer); + const [data, formAction] = useActionState(actionCheckAnswer, null); + return ( -
+

Action Data

1 + 1 =
@@ -121,11 +134,7 @@ export function ActionDataTest() { // TODO export function UseActionStateTest() { - // @ts-ignore - const [data, formAction, isPending] = React.useActionState( - actionStateTest, - null, - ); + const [data, formAction, isPending] = useActionState(actionStateTest, null); React.useEffect(() => { console.log("[useActionState]", data, isPending); diff --git a/packages/react-server/src/lib/types.ts b/packages/react-server/src/lib/types.ts index f4ffd508a..41d8c1c25 100644 --- a/packages/react-server/src/lib/types.ts +++ b/packages/react-server/src/lib/types.ts @@ -32,3 +32,14 @@ export type WebpackRequire = (id: string) => Promise; export type WebpackChunkLoad = (id: string) => Promise; export type CallServerCallback = (id: any, args: any) => Promise; + +// https://github.com/facebook/react/pull/28491 +export type ReactUseActionState = ( + action: (state: Awaited, payload: Payload) => State | Promise, + initialState: Awaited, + permalink?: string, +) => [ + state: Awaited, + dispatch: (payload: Payload) => void, + isPending: boolean, +]; From 3faa6d3fa978e8387b47d730ed3ed506efac4659 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Fri, 19 Apr 2024 19:03:55 +0900 Subject: [PATCH 13/30] test: skip actionPending test --- packages/react-server/examples/basic/e2e/basic.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-server/examples/basic/e2e/basic.test.ts b/packages/react-server/examples/basic/e2e/basic.test.ts index 1647487dc..faca1707e 100644 --- a/packages/react-server/examples/basic/e2e/basic.test.ts +++ b/packages/react-server/examples/basic/e2e/basic.test.ts @@ -84,7 +84,7 @@ test("ServerTransitionContext.isPending", async ({ page }) => { await page.getByText("Took 2 sec to load.").click(); }); -test("ServerTransitionContext.isActionPending", async ({ page }) => { +test.skip("ServerTransitionContext.isActionPending", async ({ page }) => { checkNoError(page); await page.goto("/test/action"); From ac42f49dedaf8d6f7d28dc8b97dc37e70368da7a Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Fri, 19 Apr 2024 19:06:10 +0900 Subject: [PATCH 14/30] chore: remove useActionData --- packages/react-server/src/client.tsx | 1 - .../src/features/server-action/browser.tsx | 4 +--- .../src/features/server-action/client.tsx | 17 ----------------- .../src/features/server-action/server.tsx | 14 ++++---------- 4 files changed, 5 insertions(+), 31 deletions(-) diff --git a/packages/react-server/src/client.tsx b/packages/react-server/src/client.tsx index b39ef57b3..75976b07b 100644 --- a/packages/react-server/src/client.tsx +++ b/packages/react-server/src/client.tsx @@ -2,5 +2,4 @@ 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"; diff --git a/packages/react-server/src/features/server-action/browser.tsx b/packages/react-server/src/features/server-action/browser.tsx index 83794f5a9..12b72182a 100644 --- a/packages/react-server/src/features/server-action/browser.tsx +++ b/packages/react-server/src/features/server-action/browser.tsx @@ -4,9 +4,7 @@ import { __global } from "../../lib/global"; // https://github.com/facebook/react/blob/c8a035036d0f257c514b3628e927dd9dd26e5a09/packages/react-client/src/ReactFlightReplyClient.js#L758 export function createServerReference(id: string) { - const reference = reactServerDomClient.createServerReference(id, (...args) => + return reactServerDomClient.createServerReference(id, (...args) => __global.callServer(...args), ); - Object.assign(reference, { $$id: id }); - return reference; } diff --git a/packages/react-server/src/features/server-action/client.tsx b/packages/react-server/src/features/server-action/client.tsx index 87c9b1d71..9baf1f661 100644 --- a/packages/react-server/src/features/server-action/client.tsx +++ b/packages/react-server/src/features/server-action/client.tsx @@ -1,26 +1,9 @@ -import { tinyassert } from "@hiogawa/utils"; import React from "react"; import { __global } from "../../lib/global"; import { RedirectBoundary } from "../../runtime-client"; import { createError } from "../../server"; import { LayoutStateContext } from "../router/client"; -// TODO: replace with React.useActionState -export function useActionData any>( - action: T, -): Awaited> | undefined { - const actionId = (action as any).$$id; - tinyassert(actionId); - const ctx = React.useContext(LayoutStateContext); - const data = React.use(ctx.data); - if (data.action) { - if (data.action.id === actionId) { - return data.action.data as any; - } - } - return; -} - export function ActionRedirectHandler() { return ( diff --git a/packages/react-server/src/features/server-action/server.tsx b/packages/react-server/src/features/server-action/server.tsx index 198450f5d..85ca5b48a 100644 --- a/packages/react-server/src/features/server-action/server.tsx +++ b/packages/react-server/src/features/server-action/server.tsx @@ -1,14 +1,8 @@ import reactServerDomClient from "react-server-dom-webpack/client.edge"; export function createServerReference(id: string) { - const reference = reactServerDomClient.createServerReference( - id, - (...args) => { - console.error(args); - throw new Error("unexpected callServer during SSR"); - }, - ); - // for now, this is for our DIY `useActionData` system. - Object.assign(reference, { $$id: id }); - return reference; + return reactServerDomClient.createServerReference(id, (...args) => { + console.error(args); + throw new Error("unexpected callServer during SSR"); + }); } From 39cf205a860959cb68f2c6aac011245113ac941c Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Fri, 19 Apr 2024 19:58:36 +0900 Subject: [PATCH 15/30] wip: decodeFormState --- packages/react-server/examples/basic/e2e/basic.test.ts | 2 +- packages/react-server/src/entry/browser.tsx | 10 ++++++---- packages/react-server/src/entry/react-server.tsx | 10 +++++++++- packages/react-server/src/entry/server.tsx | 4 ++-- packages/react-server/src/lib/types-env.d.ts | 6 ++++++ 5 files changed, 24 insertions(+), 8 deletions(-) diff --git a/packages/react-server/examples/basic/e2e/basic.test.ts b/packages/react-server/examples/basic/e2e/basic.test.ts index faca1707e..65d97a11a 100644 --- a/packages/react-server/examples/basic/e2e/basic.test.ts +++ b/packages/react-server/examples/basic/e2e/basic.test.ts @@ -661,7 +661,7 @@ test("action return value @js", async ({ page }) => { await testActionReturnValue(page, { js: true }); }); -test.skip("action return value @nojs", async ({ browser }) => { +test("action return value @nojs", async ({ browser }) => { const page = await browser.newPage({ javaScriptEnabled: false }); checkNoError(page); await page.goto("/test/action"); diff --git a/packages/react-server/src/entry/browser.tsx b/packages/react-server/src/entry/browser.tsx index 8882ff14a..f0c704632 100644 --- a/packages/react-server/src/entry/browser.tsx +++ b/packages/react-server/src/entry/browser.tsx @@ -66,11 +66,13 @@ export async function start() { __global.callServer = callServer; // prepare initial layout data from inline