Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions examples/vite-rsc/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
node_modules
dist
5 changes: 5 additions & 0 deletions examples/vite-rsc/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Vite + RSC + Nitro Example

Copied from https://github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-rsc/examples/starter

The difference from the original template is to export `default.fetch` handler from `entry.ssr.tsx` instead of `entry.rsc.tsx`.
11 changes: 11 additions & 0 deletions examples/vite-rsc/app/action.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
"use server";

let serverCounter = 0;

export async function getServerCounter() {
return serverCounter;
}

export async function updateServerCounter(change: number) {
serverCounter += change;
}
42 changes: 42 additions & 0 deletions examples/vite-rsc/app/assets/nitro.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions examples/vite-rsc/app/assets/react.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions examples/vite-rsc/app/assets/vite.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
13 changes: 13 additions & 0 deletions examples/vite-rsc/app/client.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
"use client";

import React from "react";

export function ClientCounter() {
const [count, setCount] = React.useState(0);

return (
<button onClick={() => setCount((count) => count + 1)}>
Client Counter: {count}
</button>
);
}
139 changes: 139 additions & 0 deletions examples/vite-rsc/app/framework/entry.browser.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import {
createFromReadableStream,
createFromFetch,
setServerCallback,
createTemporaryReferenceSet,
encodeReply,
} from "@vitejs/plugin-rsc/browser";
import React from "react";
import { createRoot, hydrateRoot } from "react-dom/client";
import { rscStream } from "rsc-html-stream/client";
import { GlobalErrorBoundary } from "./error-boundary";
import type { RscPayload } from "./entry.rsc";
import { createRscRenderRequest } from "./request";

async function main() {
// Stash `setPayload` function to trigger re-rendering
// from outside of `BrowserRoot` component (e.g. server function call, navigation, hmr)
let setPayload: (v: RscPayload) => void;

// Deserialize RSC stream back to React VDOM for CSR
const initialPayload = await createFromReadableStream<RscPayload>(
// Initial RSC stream is injected in SSR stream as <script>...FLIGHT_DATA...</script>
rscStream
);

// Browser root component to (re-)render RSC payload as state
function BrowserRoot() {
const [payload, setPayload_] = React.useState(initialPayload);

React.useEffect(() => {
setPayload = (v) => React.startTransition(() => setPayload_(v));
}, [setPayload_]);

// Re-fetch/render on client side navigation
React.useEffect(() => {
return listenNavigation(() => fetchRscPayload());
}, []);

return payload.root;
}

// Re-fetch RSC and trigger re-rendering
async function fetchRscPayload() {
const renderRequest = createRscRenderRequest(globalThis.location.href);
const payload = await createFromFetch<RscPayload>(fetch(renderRequest));
setPayload(payload);
}

// Register a handler which will be internally called by React
// on server function request after hydration.
setServerCallback(async (id, args) => {
const temporaryReferences = createTemporaryReferenceSet();
const renderRequest = createRscRenderRequest(globalThis.location.href, {
id,
body: await encodeReply(args, { temporaryReferences }),
});
const payload = await createFromFetch<RscPayload>(fetch(renderRequest), {
temporaryReferences,
});
setPayload(payload);
const { ok, data } = payload.returnValue!;
if (!ok) throw data;
return data;
});

// Hydration
const browserRoot = (
<React.StrictMode>
<GlobalErrorBoundary>
<BrowserRoot />
</GlobalErrorBoundary>
</React.StrictMode>
);
if ("__NO_HYDRATE" in globalThis) {
createRoot(document).render(browserRoot);
} else {
hydrateRoot(document, browserRoot, {
formState: initialPayload.formState,
});
}

// Implement server HMR by triggering re-fetch/render of RSC upon server code change
if (import.meta.hot) {
import.meta.hot.on("rsc:update", () => {
fetchRscPayload();
});
}
}

// A little helper to setup events interception for client side navigation
function listenNavigation(onNavigation: () => void) {
globalThis.addEventListener("popstate", onNavigation);

const oldPushState = globalThis.history.pushState;
globalThis.history.pushState = function (...args) {
const res = oldPushState.apply(this, args);
onNavigation();
return res;
};

const oldReplaceState = globalThis.history.replaceState;
globalThis.history.replaceState = function (...args) {
const res = oldReplaceState.apply(this, args);
onNavigation();
return res;
};

function onClick(e: MouseEvent) {
const link = (e.target as Element).closest("a");
if (
link &&
link instanceof HTMLAnchorElement &&
link.href &&
(!link.target || link.target === "_self") &&
link.origin === location.origin &&
!link.hasAttribute("download") &&
e.button === 0 && // left clicks only
!e.metaKey && // open in new tab (mac)
!e.ctrlKey && // open in new tab (windows)
!e.altKey && // download
!e.shiftKey &&
!e.defaultPrevented
) {
e.preventDefault();
history.pushState(null, "", link.href);
}
}
document.addEventListener("click", onClick);

return () => {
document.removeEventListener("click", onClick);
globalThis.removeEventListener("popstate", onNavigation);
globalThis.history.pushState = oldPushState;
globalThis.history.replaceState = oldReplaceState;
};
}

// eslint-disable-next-line unicorn/prefer-top-level-await
main();
126 changes: 126 additions & 0 deletions examples/vite-rsc/app/framework/entry.rsc.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import {
renderToReadableStream,
createTemporaryReferenceSet,
decodeReply,
loadServerAction,
decodeAction,
decodeFormState,
} from "@vitejs/plugin-rsc/rsc";
import type { ReactFormState } from "react-dom/client";
import { Root } from "../root.tsx";
import { parseRenderRequest } from "./request.tsx";

// The schema of payload which is serialized into RSC stream on rsc environment
// and deserialized on ssr/client environments.
export type RscPayload = {
// this demo renders/serializes/deserializes entire root html element
// but this mechanism can be changed to render/fetch different parts of components
// based on your own route conventions.
root: React.ReactNode;

// Server action return value of non-progressive enhancement case
returnValue?: { ok: boolean; data: unknown };

// Server action form state (e.g. useActionState) of progressive enhancement case
formState?: ReactFormState;
};

// The plugin by default assumes `rsc` entry having default export of request handler.
// however, how server entries are executed can be customized by registering own server handler.
export default async function handler(request: Request): Promise<Response> {
// Differentiate RSC, SSR, action, etc.
const renderRequest = parseRenderRequest(request);
request = renderRequest.request;

// Handle server function request
let returnValue: RscPayload["returnValue"] | undefined;
let formState: ReactFormState | undefined;
let temporaryReferences: unknown | undefined;
let actionStatus: number | undefined;

if (renderRequest.isAction === true) {
if (renderRequest.actionId) {
// Action is called via `ReactClient.setServerCallback`.
const contentType = request.headers.get("content-type");
const body = contentType?.startsWith("multipart/form-data")
? await request.formData()
: await request.text();
temporaryReferences = createTemporaryReferenceSet();
const args = await decodeReply(body, { temporaryReferences });
const action = await loadServerAction(renderRequest.actionId);
try {
// eslint-disable-next-line prefer-spread
const data = await action.apply(null, args);
returnValue = { ok: true, data };
} catch (error_) {
returnValue = { ok: false, data: error_ };
actionStatus = 500;
}
} else {
// Otherwise server function is called via `<form action={...}>`
// before hydration (e.g. when JavaScript is disabled).
// aka progressive enhancement.
const formData = await request.formData();
const decodedAction = await decodeAction(formData);
try {
const result = await decodedAction();
formState = await decodeFormState(result, formData);
} catch {
// there's no single general obvious way to surface this error,
// so explicitly return classic 500 response.
return new Response("Internal Server Error: server action failed", {
status: 500,
});
}
}
}

// Serialization from React VDOM tree to RSC stream.
// We render RSC stream after handling server function request
// so that new render reflects updated state from server function call
// to achieve single round trip to mutate and fetch from server.
const rscPayload: RscPayload = {
root: <Root url={renderRequest.url} />,
formState,
returnValue,
};

const rscOptions = { temporaryReferences };
const rscStream = renderToReadableStream<RscPayload>(rscPayload, rscOptions);

// Respond RSC stream without HTML rendering as decided by `RenderRequest`
if (renderRequest.isRsc) {
return new Response(rscStream, {
status: actionStatus,
headers: {
"content-type": "text/x-component;charset=utf-8",
},
});
}

// Delegate to SSR environment for HTML rendering.
// The plugin provides `loadModule` helper to allow loading SSR environment entry module
// in RSC environment. however this can be customized by implementing own runtime communication
// e.g. `@cloudflare/vite-plugin`'s service binding.
const ssrEntryModule = await import.meta.viteRsc.loadModule<
typeof import("./entry.ssr.tsx")
>("ssr", "index");

const ssrResult = await ssrEntryModule.renderHTML(rscStream, {
formState,
// Allow quick simulation of JavaScript disabled browser
debugNoJS: renderRequest.url.searchParams.has("__nojs"),
});

// Respond HTML
return new Response(ssrResult.stream, {
status: ssrResult.status,
headers: {
"Content-Type": "text/html",
},
});
}

if (import.meta.hot) {
import.meta.hot.accept();
}
Loading
Loading