Skip to content

Commit

Permalink
feat(react-server): server action (#18)
Browse files Browse the repository at this point in the history
* wip: re-exports react server

* wip: serverActionHandler

* wip: plugin

* chore: comment

* wip

* wip

* wip: parseExports

* refactor: parseExports

* wip: transform plugin

* wip: virtual

* wip: call server

* chore: readme

* build: fix server entry

* test: update e2e
  • Loading branch information
hi-ogawa authored Apr 13, 2024
1 parent bb37e18 commit 230e992
Show file tree
Hide file tree
Showing 15 changed files with 387 additions and 64 deletions.
3 changes: 2 additions & 1 deletion examples/react-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,9 @@ pnpm cf-release
- [x] dev
- [x] build
- [x] hmr
- [x] browser (?)
- [x] browser
- [x] react-server
- [x] server action
- [ ] integrate to `@hiogawa/react-server`

## references
Expand Down
41 changes: 35 additions & 6 deletions examples/react-server/e2e/basic.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,38 @@
import { test, expect } from "@playwright/test";
import { test, type Page } from "@playwright/test";

test("basic", async ({ page }) => {
test("client-component", async ({ page }) => {
await page.goto("/");
await expect(page.locator("#root")).toContainText("hydrated: true");
await expect(page.locator("#root")).toContainText("Count: 0");
await page.getByRole("button", { name: "+" }).click();
await expect(page.locator("#root")).toContainText("Count: 1");
await page.getByText("hydrated: true").click();
await page.getByTestId("client-component").getByText("Count: 0").click();
await page
.getByTestId("client-component")
.getByRole("button", { name: "+" })
.click();
await page.getByTestId("client-component").getByText("Count: 1").click();
});

test("server-action @js", async ({ page }) => {
await page.goto("/");
await page.getByText("hydrated: true").click();
await testServerAction(page);
});

test("server-action @nojs", async ({ browser }) => {
const page = await browser.newPage({ javaScriptEnabled: false });
await page.goto("/");
await testServerAction(page);
});

async function testServerAction(page: Page) {
await page.getByTestId("server-action").getByText("Count: 0").click();
await page
.getByTestId("server-action")
.getByRole("button", { name: "+" })
.click();
await page.getByTestId("server-action").getByText("Count: 1").click();
await page
.getByTestId("server-action")
.getByRole("button", { name: "-" })
.click();
await page.getByTestId("server-action").getByText("Count: 0").click();
}
18 changes: 16 additions & 2 deletions examples/react-server/src/entry-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import reactDomClient from "react-dom/client";
import { readRscStreamScript } from "./utils/rsc-stream-script";
import { initializeWebpackServer } from "./features/use-client/server";
import type { StreamData } from "./entry-react-server";
import { __global } from "./global";
import { injectActionId } from "./features/server-action/utils";

async function main() {
if (window.location.search.includes("__noCsr")) {
Expand All @@ -15,10 +17,22 @@ async function main() {
"react-server-dom-webpack/client.browser"
);

__global.callServer = (id, args) => {
tinyassert(args.length === 1);
tinyassert(args[0] instanceof FormData);
const body = args[0];
injectActionId(body, id);
const streamData = reactServerDomClient.createFromFetch<StreamData>(
fetch("/?__rsc", { method: "POST", body }),
{ callServer: __global.callServer },
);
__setStreamData(streamData);
};

const initialStreamData =
reactServerDomClient.createFromReadableStream<StreamData>(
readRscStreamScript(),
{},
{ callServer: __global.callServer },
);

let __setStreamData: (v: Promise<StreamData>) => void;
Expand Down Expand Up @@ -48,7 +62,7 @@ async function main() {
console.log("[react-server] hot update", e.file);
const streamData = reactServerDomClient.createFromFetch<StreamData>(
fetch("/?__rsc"),
{},
{ callServer: __global.callServer },
);
__setStreamData(streamData);
});
Expand Down
7 changes: 6 additions & 1 deletion examples/react-server/src/entry-react-server.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import reactServerDomServer from "react-server-dom-webpack/server.edge";
import Page from "./routes/page";
import { createBundlerConfig } from "./features/use-client/react-server";
import { serverActionHandler } from "./features/server-action/react-server";

export type StreamData = React.ReactNode;

export async function handler({}: { request: Request }) {
export async function handler({ request }: { request: Request }) {
if (request.method === "POST") {
await serverActionHandler({ request });
}

const root = <Page />;

const stream = reactServerDomServer.renderToReadableStream<StreamData>(
Expand Down
8 changes: 8 additions & 0 deletions examples/react-server/src/features/server-action/browser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import reactServerDomClient from "react-server-dom-webpack/client.browser";
import { __global } from "../../global";

export function createServerReference(id: string) {
return reactServerDomClient.createServerReference(id, (...args) =>
__global.callServer(...args),
);
}
35 changes: 35 additions & 0 deletions examples/react-server/src/features/server-action/react-server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import reactServerDomWebpack from "react-server-dom-webpack/server.edge";
import { tinyassert } from "@hiogawa/utils";
import { ejectActionId } from "./utils";

export function registerServerReference(
action: Function,
id: string,
name: string,
) {
return reactServerDomWebpack.registerServerReference(action, id, name);
}

export async function serverActionHandler({ request }: { request: Request }) {
const formData = await request.formData();
const actionId = ejectActionId(formData);
const action = await importServerAction(actionId);
await action(formData);
}

async function importServerReference(id: string): Promise<unknown> {
if (import.meta.env.DEV) {
return import(/* @vite-ignore */ id);
} else {
const references = await import("virtual:server-reference" as string);
const dynImport = references.default[id];
tinyassert(dynImport, `server reference not found '${id}'`);
return dynImport();
}
}

async function importServerAction(id: string): Promise<Function> {
const [file, name] = id.split("#") as [string, string];
const mod: any = await importServerReference(file);
return mod[name];
}
8 changes: 8 additions & 0 deletions examples/react-server/src/features/server-action/server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import reactServerDomClient from "react-server-dom-webpack/client.edge";

export function createServerReference(id: string) {
return reactServerDomClient.createServerReference(id, (...args) => {
console.error(args);
throw new Error("unexpected callServer during SSR");
});
}
22 changes: 22 additions & 0 deletions examples/react-server/src/features/server-action/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { tinyassert } from "@hiogawa/utils";

// uniformly handle simple use cases of form action
// both for progressive enhancement and for client-side request
// without using encodeReply/decodeReply/decodeAction API
const ACTION_ID_PREFIX = "$ACTION_ID_";

export function injectActionId(formData: FormData, id: string) {
formData.set(ACTION_ID_PREFIX + id, "");
}

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;
}
2 changes: 2 additions & 0 deletions examples/react-server/src/global.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import type { ViteDevServer } from "vite";
import type { ModuleRunner } from "vite/module-runner";
import type { CallServerCallback } from "./types";

// quick global hacks...

export const __global: {
server: ViteDevServer;
reactServerRunner: ModuleRunner;
callServer: CallServerCallback;
} = ((globalThis as any).__VITE_REACT_SERVER_GLOBAL ??= {});
11 changes: 11 additions & 0 deletions examples/react-server/src/routes/_action.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
"use server";

let count = 0;

export function getCounter() {
return count;
}

export function changeCounter(formData: FormData) {
count += Number(formData.get("value"));
}
4 changes: 2 additions & 2 deletions examples/react-server/src/routes/_client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ export function ClientComponent() {
}, []);

return (
<div>
<h4>Hello use client</h4>
<div data-testid="client-component">
<h4>Hello Client Component</h4>
<div>hydrated: {String(hydrated)}</div>
<div>Count: {count}</div>
<button onClick={() => setCount((v) => v - 1)}>-1</button>
Expand Down
21 changes: 20 additions & 1 deletion examples/react-server/src/routes/page.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,29 @@
import { changeCounter, getCounter } from "./_action";
import { ClientComponent } from "./_client";

export default function Page() {
return (
<div>
<h4>Hello react server</h4>
<h4>Hello Server Component</h4>
<ServerActionDemo />
<ClientComponent />
</div>
);
}

function ServerActionDemo() {
return (
<div data-testid="server-action">
<h4>Hello Server Action</h4>
<form action={changeCounter}>
<div>Count: {getCounter()}</div>
<button name="value" value={-1}>
-1
</button>
<button name="value" value={+1}>
+1
</button>
</form>
</div>
);
}
2 changes: 1 addition & 1 deletion examples/react-server/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,4 @@ export type WebpackRequire = (id: string) => Promise<unknown>;
// TODO
export type WebpackChunkLoad = (id: string) => Promise<unknown>;

export type CallServerCallback = (id: any, args: any) => Promise<unknown>;
export type CallServerCallback = (id: string, args: unknown[]) => unknown;
16 changes: 16 additions & 0 deletions examples/react-server/src/types/react.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,21 @@ declare module "react-server-dom-webpack/server.edge" {
id: string,
name: string,
): T;

export function registerServerReference<T>(
ref: T,
id: string,
name: string,
): T;
}

// https://github.com/facebook/react/blob/89021fb4ec9aa82194b0788566e736a4cedfc0e4/packages/react-server-dom-webpack/src/ReactFlightDOMClientEdge.js
declare module "react-server-dom-webpack/client.edge" {
export function createServerReference(
id: string,
callServer: import(".").CallServerCallback,
): Function;

export function createFromReadableStream<T>(
stream: ReadableStream<Uint8Array>,
options: {
Expand All @@ -31,6 +42,11 @@ declare module "react-server-dom-webpack/client.edge" {

// https://github.com/facebook/react/blob/89021fb4ec9aa82194b0788566e736a4cedfc0e4/packages/react-server-dom-webpack/src/ReactFlightDOMClientBrowser.js
declare module "react-server-dom-webpack/client.browser" {
export function createServerReference(
id: string,
callServer: import(".").CallServerCallback,
): Function;

export function createFromReadableStream<T>(
stream: ReadableStream<Uint8Array>,
options?: {
Expand Down
Loading

0 comments on commit 230e992

Please sign in to comment.