Skip to content

Commit df7fd85

Browse files
authored
feat(vite): experimental rsc plugin support (#3919)
1 parent e440bc2 commit df7fd85

File tree

24 files changed

+940
-5
lines changed

24 files changed

+940
-5
lines changed

examples/vite-rsc/.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
node_modules
2+
dist

examples/vite-rsc/README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Vite + RSC + Nitro Example
2+
3+
Copied from https://github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-rsc/examples/starter
4+
5+
The difference from the original template is to export `default.fetch` handler from `entry.ssr.tsx` instead of `entry.rsc.tsx`.

examples/vite-rsc/app/action.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
"use server";
2+
3+
let serverCounter = 0;
4+
5+
export async function getServerCounter() {
6+
return serverCounter;
7+
}
8+
9+
export async function updateServerCounter(change: number) {
10+
serverCounter += change;
11+
}
Lines changed: 42 additions & 0 deletions
Loading
Lines changed: 1 addition & 0 deletions
Loading
Lines changed: 1 addition & 0 deletions
Loading

examples/vite-rsc/app/client.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
"use client";
2+
3+
import React from "react";
4+
5+
export function ClientCounter() {
6+
const [count, setCount] = React.useState(0);
7+
8+
return (
9+
<button onClick={() => setCount((count) => count + 1)}>
10+
Client Counter: {count}
11+
</button>
12+
);
13+
}
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import {
2+
createFromReadableStream,
3+
createFromFetch,
4+
setServerCallback,
5+
createTemporaryReferenceSet,
6+
encodeReply,
7+
} from "@vitejs/plugin-rsc/browser";
8+
import React from "react";
9+
import { createRoot, hydrateRoot } from "react-dom/client";
10+
import { rscStream } from "rsc-html-stream/client";
11+
import { GlobalErrorBoundary } from "./error-boundary";
12+
import type { RscPayload } from "./entry.rsc";
13+
import { createRscRenderRequest } from "./request";
14+
15+
async function main() {
16+
// Stash `setPayload` function to trigger re-rendering
17+
// from outside of `BrowserRoot` component (e.g. server function call, navigation, hmr)
18+
let setPayload: (v: RscPayload) => void;
19+
20+
// Deserialize RSC stream back to React VDOM for CSR
21+
const initialPayload = await createFromReadableStream<RscPayload>(
22+
// Initial RSC stream is injected in SSR stream as <script>...FLIGHT_DATA...</script>
23+
rscStream
24+
);
25+
26+
// Browser root component to (re-)render RSC payload as state
27+
function BrowserRoot() {
28+
const [payload, setPayload_] = React.useState(initialPayload);
29+
30+
React.useEffect(() => {
31+
setPayload = (v) => React.startTransition(() => setPayload_(v));
32+
}, [setPayload_]);
33+
34+
// Re-fetch/render on client side navigation
35+
React.useEffect(() => {
36+
return listenNavigation(() => fetchRscPayload());
37+
}, []);
38+
39+
return payload.root;
40+
}
41+
42+
// Re-fetch RSC and trigger re-rendering
43+
async function fetchRscPayload() {
44+
const renderRequest = createRscRenderRequest(globalThis.location.href);
45+
const payload = await createFromFetch<RscPayload>(fetch(renderRequest));
46+
setPayload(payload);
47+
}
48+
49+
// Register a handler which will be internally called by React
50+
// on server function request after hydration.
51+
setServerCallback(async (id, args) => {
52+
const temporaryReferences = createTemporaryReferenceSet();
53+
const renderRequest = createRscRenderRequest(globalThis.location.href, {
54+
id,
55+
body: await encodeReply(args, { temporaryReferences }),
56+
});
57+
const payload = await createFromFetch<RscPayload>(fetch(renderRequest), {
58+
temporaryReferences,
59+
});
60+
setPayload(payload);
61+
const { ok, data } = payload.returnValue!;
62+
if (!ok) throw data;
63+
return data;
64+
});
65+
66+
// Hydration
67+
const browserRoot = (
68+
<React.StrictMode>
69+
<GlobalErrorBoundary>
70+
<BrowserRoot />
71+
</GlobalErrorBoundary>
72+
</React.StrictMode>
73+
);
74+
if ("__NO_HYDRATE" in globalThis) {
75+
createRoot(document).render(browserRoot);
76+
} else {
77+
hydrateRoot(document, browserRoot, {
78+
formState: initialPayload.formState,
79+
});
80+
}
81+
82+
// Implement server HMR by triggering re-fetch/render of RSC upon server code change
83+
if (import.meta.hot) {
84+
import.meta.hot.on("rsc:update", () => {
85+
fetchRscPayload();
86+
});
87+
}
88+
}
89+
90+
// A little helper to setup events interception for client side navigation
91+
function listenNavigation(onNavigation: () => void) {
92+
globalThis.addEventListener("popstate", onNavigation);
93+
94+
const oldPushState = globalThis.history.pushState;
95+
globalThis.history.pushState = function (...args) {
96+
const res = oldPushState.apply(this, args);
97+
onNavigation();
98+
return res;
99+
};
100+
101+
const oldReplaceState = globalThis.history.replaceState;
102+
globalThis.history.replaceState = function (...args) {
103+
const res = oldReplaceState.apply(this, args);
104+
onNavigation();
105+
return res;
106+
};
107+
108+
function onClick(e: MouseEvent) {
109+
const link = (e.target as Element).closest("a");
110+
if (
111+
link &&
112+
link instanceof HTMLAnchorElement &&
113+
link.href &&
114+
(!link.target || link.target === "_self") &&
115+
link.origin === location.origin &&
116+
!link.hasAttribute("download") &&
117+
e.button === 0 && // left clicks only
118+
!e.metaKey && // open in new tab (mac)
119+
!e.ctrlKey && // open in new tab (windows)
120+
!e.altKey && // download
121+
!e.shiftKey &&
122+
!e.defaultPrevented
123+
) {
124+
e.preventDefault();
125+
history.pushState(null, "", link.href);
126+
}
127+
}
128+
document.addEventListener("click", onClick);
129+
130+
return () => {
131+
document.removeEventListener("click", onClick);
132+
globalThis.removeEventListener("popstate", onNavigation);
133+
globalThis.history.pushState = oldPushState;
134+
globalThis.history.replaceState = oldReplaceState;
135+
};
136+
}
137+
138+
// eslint-disable-next-line unicorn/prefer-top-level-await
139+
main();
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import {
2+
renderToReadableStream,
3+
createTemporaryReferenceSet,
4+
decodeReply,
5+
loadServerAction,
6+
decodeAction,
7+
decodeFormState,
8+
} from "@vitejs/plugin-rsc/rsc";
9+
import type { ReactFormState } from "react-dom/client";
10+
import { Root } from "../root.tsx";
11+
import { parseRenderRequest } from "./request.tsx";
12+
13+
// The schema of payload which is serialized into RSC stream on rsc environment
14+
// and deserialized on ssr/client environments.
15+
export type RscPayload = {
16+
// this demo renders/serializes/deserializes entire root html element
17+
// but this mechanism can be changed to render/fetch different parts of components
18+
// based on your own route conventions.
19+
root: React.ReactNode;
20+
21+
// Server action return value of non-progressive enhancement case
22+
returnValue?: { ok: boolean; data: unknown };
23+
24+
// Server action form state (e.g. useActionState) of progressive enhancement case
25+
formState?: ReactFormState;
26+
};
27+
28+
// The plugin by default assumes `rsc` entry having default export of request handler.
29+
// however, how server entries are executed can be customized by registering own server handler.
30+
export default async function handler(request: Request): Promise<Response> {
31+
// Differentiate RSC, SSR, action, etc.
32+
const renderRequest = parseRenderRequest(request);
33+
request = renderRequest.request;
34+
35+
// Handle server function request
36+
let returnValue: RscPayload["returnValue"] | undefined;
37+
let formState: ReactFormState | undefined;
38+
let temporaryReferences: unknown | undefined;
39+
let actionStatus: number | undefined;
40+
41+
if (renderRequest.isAction === true) {
42+
if (renderRequest.actionId) {
43+
// Action is called via `ReactClient.setServerCallback`.
44+
const contentType = request.headers.get("content-type");
45+
const body = contentType?.startsWith("multipart/form-data")
46+
? await request.formData()
47+
: await request.text();
48+
temporaryReferences = createTemporaryReferenceSet();
49+
const args = await decodeReply(body, { temporaryReferences });
50+
const action = await loadServerAction(renderRequest.actionId);
51+
try {
52+
// eslint-disable-next-line prefer-spread
53+
const data = await action.apply(null, args);
54+
returnValue = { ok: true, data };
55+
} catch (error_) {
56+
returnValue = { ok: false, data: error_ };
57+
actionStatus = 500;
58+
}
59+
} else {
60+
// Otherwise server function is called via `<form action={...}>`
61+
// before hydration (e.g. when JavaScript is disabled).
62+
// aka progressive enhancement.
63+
const formData = await request.formData();
64+
const decodedAction = await decodeAction(formData);
65+
try {
66+
const result = await decodedAction();
67+
formState = await decodeFormState(result, formData);
68+
} catch {
69+
// there's no single general obvious way to surface this error,
70+
// so explicitly return classic 500 response.
71+
return new Response("Internal Server Error: server action failed", {
72+
status: 500,
73+
});
74+
}
75+
}
76+
}
77+
78+
// Serialization from React VDOM tree to RSC stream.
79+
// We render RSC stream after handling server function request
80+
// so that new render reflects updated state from server function call
81+
// to achieve single round trip to mutate and fetch from server.
82+
const rscPayload: RscPayload = {
83+
root: <Root url={renderRequest.url} />,
84+
formState,
85+
returnValue,
86+
};
87+
88+
const rscOptions = { temporaryReferences };
89+
const rscStream = renderToReadableStream<RscPayload>(rscPayload, rscOptions);
90+
91+
// Respond RSC stream without HTML rendering as decided by `RenderRequest`
92+
if (renderRequest.isRsc) {
93+
return new Response(rscStream, {
94+
status: actionStatus,
95+
headers: {
96+
"content-type": "text/x-component;charset=utf-8",
97+
},
98+
});
99+
}
100+
101+
// Delegate to SSR environment for HTML rendering.
102+
// The plugin provides `loadModule` helper to allow loading SSR environment entry module
103+
// in RSC environment. however this can be customized by implementing own runtime communication
104+
// e.g. `@cloudflare/vite-plugin`'s service binding.
105+
const ssrEntryModule = await import.meta.viteRsc.loadModule<
106+
typeof import("./entry.ssr.tsx")
107+
>("ssr", "index");
108+
109+
const ssrResult = await ssrEntryModule.renderHTML(rscStream, {
110+
formState,
111+
// Allow quick simulation of JavaScript disabled browser
112+
debugNoJS: renderRequest.url.searchParams.has("__nojs"),
113+
});
114+
115+
// Respond HTML
116+
return new Response(ssrResult.stream, {
117+
status: ssrResult.status,
118+
headers: {
119+
"Content-Type": "text/html",
120+
},
121+
});
122+
}
123+
124+
if (import.meta.hot) {
125+
import.meta.hot.accept();
126+
}

0 commit comments

Comments
 (0)