Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor(react-server): use official encodeReply/decodeReply/decodeAction/decodeFormState + feat: support useActionState #282

Merged
merged 33 commits into from
Apr 20, 2024
Merged
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
24c5e17
refactor(react-server): use `encodeReply/decodeReply`
hi-ogawa Apr 11, 2024
7bc3043
refactor: cleanup
hi-ogawa Apr 11, 2024
994b80d
refactor: cleanup
hi-ogawa Apr 11, 2024
3d98017
chore: comment
hi-ogawa Apr 11, 2024
37f1ab9
chore: assert ssr
hi-ogawa Apr 11, 2024
34d03b1
test: action after client render
hi-ogawa Apr 11, 2024
d2f60fc
Merge branch 'main' into refactor-use-encodeReply-decodeReply
hi-ogawa Apr 12, 2024
669cf97
refactor(react-server): use official `createServerReference` (#283)
hi-ogawa Apr 12, 2024
2af6075
Merge branch 'main' into refactor-use-encodeReply-decodeReply
hi-ogawa Apr 19, 2024
e7d87c2
refactor(react-server): use official `decodeAction/decodeFormState` (…
hi-ogawa Apr 19, 2024
5568455
Merge branch 'main' into refactor-use-encodeReply-decodeReply
hi-ogawa Apr 19, 2024
92de3e3
chore: use React.useActionState
hi-ogawa Apr 19, 2024
9e87c64
wip: js action return value
hi-ogawa Apr 19, 2024
9d0a3c3
wip: action return value (give up global action pending)
hi-ogawa Apr 19, 2024
adc98b9
chore: replace DIY useActionData
hi-ogawa Apr 19, 2024
3faa6d3
test: skip actionPending test
hi-ogawa Apr 19, 2024
ac42f49
chore: remove useActionData
hi-ogawa Apr 19, 2024
39cf205
wip: decodeFormState
hi-ogawa Apr 19, 2024
37fed28
fix: __startActionTransition
hi-ogawa Apr 19, 2024
b3d4431
chore: unused actionResult.id
hi-ogawa Apr 19, 2024
1aed799
feat: `useActionContext`
hi-ogawa Apr 19, 2024
a1ea233
test: revert skip
hi-ogawa Apr 19, 2024
6c85707
chore: tweak
hi-ogawa Apr 19, 2024
c187900
chore: comment
hi-ogawa Apr 20, 2024
60acbff
chore: more examples
hi-ogawa Apr 20, 2024
5f73822
test: test useActionState prev
hi-ogawa Apr 20, 2024
536dee0
chore: remove unused
hi-ogawa Apr 20, 2024
346e35f
test: test action bind
hi-ogawa Apr 20, 2024
e76be85
wip: non form action
hi-ogawa Apr 20, 2024
7a93367
wip: support non form action
hi-ogawa Apr 20, 2024
9957170
test: e2e
hi-ogawa Apr 20, 2024
1c14083
chore: re-export typed useActionState
hi-ogawa Apr 20, 2024
06033ba
chore: release
hi-ogawa Apr 20, 2024
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
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,18 @@ 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 };
}

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()] });
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
"use client";

import { useActionData } from "@hiogawa/react-server/client";
import React from "react";
import ReactDom from "react-dom";
import {
actionBindTest,
actionCheckAnswer,
actionStateTest,
addMessage,
changeCounter,
type getMessages,
Expand Down Expand Up @@ -99,10 +100,24 @@ export function Chat(props: { messages: ReturnType<typeof getMessages> }) {
);
}

// https://github.com/facebook/react/pull/28491
type ReactUseActionState = <State, Payload>(
action: (state: Awaited<State>, payload: Payload) => State | Promise<State>,
initialState: Awaited<State>,
permalink?: string,
) => [
state: Awaited<State>,
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 (
<form action={actionCheckAnswer} className="flex flex-col gap-2">
<form action={formAction} className="flex flex-col gap-2">
<h4 className="font-bold">Action Data</h4>
<div className="flex gap-2">
<div>1 + 1 = </div>
Expand All @@ -117,6 +132,36 @@ export function ActionDataTest() {
);
}

// TODO
export function UseActionStateTest() {
const [data, formAction, isPending] = useActionState(actionStateTest, null);

React.useEffect(() => {
console.log("[useActionState]", data, isPending);
}, [data, isPending]);

return (
<form action={formAction} className="flex flex-col gap-2">
<input type="hidden" name="hello" value="world" />
<button className="antd-input p-1 text-sm max-w-30">
useActionState Test
</button>
</form>
);
}

export function ClientActionBindTest() {
const formAction = actionBindTest.bind(null, "bound!!");
return (
<form action={formAction} className="flex flex-col gap-2">
<input type="hidden" name="hello" value="world" />
<button className="antd-input p-1 text-sm max-w-30">
Client Action Bind Test
</button>
</form>
);
}

// https://react.dev/reference/react-dom/hooks/useFormStatus
export function FormStateTest() {
return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -17,6 +19,8 @@ export default async function Page() {
</div>
<Chat messages={getMessages()} />
<ActionDataTest />
<UseActionStateTest />
<ClientActionBindTest />
<FormStateTest />
</div>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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");
}
Expand Down
2 changes: 1 addition & 1 deletion packages/react-server/examples/basic/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
1 change: 0 additions & 1 deletion packages/react-server/src/client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
41 changes: 18 additions & 23 deletions packages/react-server/src/entry/browser.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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";
Expand Down Expand Up @@ -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<ServerRouterData>(fetch(request), {
callServer,
}),
);
});
const result = reactServerDomClient.createFromFetch<ServerRouterData>(
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 <script>
const initialLayoutPromise =
reactServerDomClient.createFromReadableStream<ServerRouterData>(
// TODO: needs to await for hydration formState?
const initialLayout =
await reactServerDomClient.createFromReadableStream<ServerRouterData>(
Copy link
Owner Author

@hi-ogawa hi-ogawa Apr 19, 2024

Choose a reason for hiding this comment

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

TODO: move out action form state from initial ssr stream?

readStreamScript<string>().pipeThrough(new TextEncoderStream()),
{ callServer },
);
const initialLayoutPromise = Promise.resolve(initialLayout);

//
// browser root
Expand Down Expand Up @@ -181,7 +173,10 @@ export async function start() {
if (document.documentElement.dataset["noHydrate"]) {
reactDomClient.createRoot(document).render(reactRootEl);
} else {
reactDomClient.hydrateRoot(document, reactRootEl);
reactDomClient.hydrateRoot(document, reactRootEl, {
// @ts-expect-error no type yet
formState: initialLayout.action?.data,
});
}

// custom event for RSC reload
Expand Down
58 changes: 37 additions & 21 deletions packages/react-server/src/entry/react-server.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,16 @@ import {
type LayoutRequest,
type ServerRouterData,
} from "../features/router/utils";
import { runActionContext } from "../features/server-action/context";
import {
ActionContext,
type ActionResult,
createActionBundlerConfig,
importServerAction,
initializeWebpackReactServer,
serverReferenceImportPromiseCache,
} from "../features/server-action/react-server";
import { ejectActionId } 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 {
Expand Down Expand Up @@ -46,6 +51,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") {
Expand Down Expand Up @@ -94,7 +105,7 @@ async function render({
{
layout: nodeMap,
action: actionResult
? objectPick(actionResult, ["id", "data", "error"])
? objectPick(actionResult, ["data", "error"])
: undefined,
},
bundlerConfig,
Expand Down Expand Up @@ -147,30 +158,35 @@ 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);
}
const id = ejectActionId(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];
const context = new ActionContext(request);
const streamAction = unwrapStreamActionRequest(request);
let boundAction: Function;
if (streamAction) {
const formData = await request.formData();
const args = await reactServerDomServer.decodeReply(formData);
const action = await importServerAction(streamAction.id);
boundAction = () => action.apply(null, args);
} 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];
const formData = await request.formData();
const decodedAction = await reactServerDomServer.decodeAction(
formData,
createActionBundlerConfig(),
);
boundAction = async () => {
const result = await decodedAction();
const formState = await reactServerDomServer.decodeFormState(
result,
formData,
);
return formState;
};
}

const context = new ActionContext(request);
const result: ActionResult = { id, context };
const result: ActionResult = { context };
try {
result.data = await action.apply(context, [formData]);
result.data = await runActionContext(context, () => boundAction());
} catch (e) {
result.error = getErrorContext(e) ?? DEFAULT_ERROR_CONTEXT;
} finally {
Expand Down
2 changes: 2 additions & 0 deletions packages/react-server/src/entry/server.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,8 @@ export async function renderHtml(
let status = 200;
try {
ssrStream = await reactDomServer.renderToReadableStream(reactRootEl, {
// @ts-expect-error no type yet
formState: result.actionResult?.data,
bootstrapModules: url.search.includes("__nojs")
? []
: assets.bootstrapModules,
Expand Down
2 changes: 1 addition & 1 deletion packages/react-server/src/features/router/utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export type LayoutRequest = Record<
>;

export type ServerRouterData = {
action?: Pick<ActionResult, "id" | "error" | "data">;
action?: Pick<ActionResult, "error" | "data">;
layout: Record<string, React.ReactNode>;
};

Expand Down
4 changes: 1 addition & 3 deletions packages/react-server/src/features/server-action/browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
16 changes: 0 additions & 16 deletions packages/react-server/src/features/server-action/client.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +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";

export function useActionData<T extends (...args: any[]) => any>(
action: T,
): Awaited<ReturnType<T>> | 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 (
<RedirectBoundary>
Expand Down
Loading