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
73 changes: 59 additions & 14 deletions bun.lock

Large diffs are not rendered by default.

34 changes: 34 additions & 0 deletions fixtures/react-router-rsc/app/root.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// oxlint-disable-next-line import/no-unassigned-import
import "./styles.css";
import { Link, Outlet } from "react-router";

export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>React Router Vite</title>
</head>
<body>
<header>
<nav>
<ul>
<li>
<Link to="/">Home</Link>
</li>
<li>
<Link to="/about">About</Link>
</li>
</ul>
</nav>
</header>
{children}
</body>
</html>
);
}

export default function Component() {
return <Outlet />;
}
21 changes: 21 additions & 0 deletions fixtures/react-router-rsc/app/routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { unstable_RSCRouteConfigEntry } from "react-router";

export const routes: Array<unstable_RSCRouteConfigEntry> = [
{
id: "root",
path: "",
lazy: () => import("./root"),
children: [
{
id: "home",
index: true,
lazy: () => import("./routes/home"),
},
{
id: "about",
path: "about",
lazy: () => import("./routes/about"),
},
],
},
];
17 changes: 17 additions & 0 deletions fixtures/react-router-rsc/app/routes/about.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
"use client";

import React from "react";

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

return (
<main>
<h1>About</h1>
<p>This is the about page, rendered as a client component.</p>
<button className="counter" onClick={() => setCount((c) => c + 1)}>
Count is {count}
</button>
</main>
);
}
10 changes: 10 additions & 0 deletions fixtures/react-router-rsc/app/routes/home.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
const Component = () => {
return (
<main>
<h1>Home</h1>
<p>This is the home page, rendered as a React Server Component.</p>
</main>
);
};

export default Component;
44 changes: 44 additions & 0 deletions fixtures/react-router-rsc/app/styles.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
:root {
color-scheme: light;
font-family:
system-ui,
-apple-system,
"Segoe UI",
Roboto,
sans-serif;
}

body {
margin: 0;
color: #111;
background: #fff;
}

nav ul {
display: flex;
gap: 1rem;
list-style: none;
margin: 0;
padding: 1rem 2rem;
border-bottom: 1px solid #e5e5e5;
}

nav a {
color: #2563eb;
text-decoration: none;
}

main {
max-width: 48rem;
margin: 0 auto;
padding: 2rem;
}

button {
cursor: pointer;
padding: 0.5rem 1rem;
border: 1px solid #d4d4d4;
border-radius: 0.375rem;
background: #f5f5f5;
font: inherit;
}
32 changes: 32 additions & 0 deletions fixtures/react-router-rsc/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{
"name": "@fixtures/react-router-rsc",
"private": true,
"license": "MIT",
"type": "module",
"scripts": {
"dev": "vite dev --port 3200",
"build": "vite build",
"preview": "vite preview",
"test": "playwright test",
"test:update-snapshots": "playwright test --update-snapshots",
"pretest": "playwright install chromium"
},
"dependencies": {
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-router": "7.16.0"
},
"devDependencies": {
"@cloudflare/workers-types": "catalog:workers",
"@distilled.cloud/cloudflare-runtime": "workspace:*",
"@distilled.cloud/cloudflare-vite-plugin": "workspace:*",
"@distilled.cloud/test-utils": "workspace:*",
"@playwright/test": "catalog:",
"@types/node": "^24.12.0",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "latest",
"@vitejs/plugin-rsc": "latest",
"vite": "catalog:"
}
}
21 changes: 21 additions & 0 deletions fixtures/react-router-rsc/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { defineConfig } from "@playwright/test";

export default defineConfig({
testDir: "./test",
timeout: 60_000,
expect: {
timeout: 10_000,
},
snapshotPathTemplate: "{testDir}/__snapshots__/{testFileName}/{arg}{ext}",
projects: [
{
name: "chromium",
use: {
browserName: "chromium",
colorScheme: "light",
deviceScaleFactor: 1,
viewport: { width: 1280, height: 720 },
},
},
],
});
47 changes: 47 additions & 0 deletions fixtures/react-router-rsc/react-router-vite/entry.browser.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import {
createFromReadableStream,
createTemporaryReferenceSet,
encodeReply,
setServerCallback,
} from "@vitejs/plugin-rsc/browser";
import { startTransition, StrictMode } from "react";
import { hydrateRoot } from "react-dom/client";
import type { DataRouter, unstable_RSCPayload as RSCServerPayload } from "react-router";
import {
unstable_createCallServer as createCallServer,
unstable_getRSCStream as getRSCStream,
unstable_RSCHydratedRouter as RSCHydratedRouter,
} from "react-router/dom";

setServerCallback(
createCallServer({
createFromReadableStream,
createTemporaryReferenceSet,
encodeReply,
}),
);

createFromReadableStream<RSCServerPayload>(getRSCStream()).then((payload) => {
startTransition(async () => {
const formState = payload.type === "render" ? await payload.formState : undefined;

hydrateRoot(
document,
<StrictMode>
<RSCHydratedRouter createFromReadableStream={createFromReadableStream} payload={payload} />
</StrictMode>,
{
// @ts-expect-error - no types for this yet
formState,
},
);
});
});

declare let __reactRouterDataRouter: DataRouter;

if (import.meta.hot) {
import.meta.hot.on("rsc:update", () => {
__reactRouterDataRouter.revalidate();
});
}
32 changes: 32 additions & 0 deletions fixtures/react-router-rsc/react-router-vite/entry.rsc.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import {
createTemporaryReferenceSet,
decodeAction,
decodeFormState,
decodeReply,
loadServerAction,
renderToReadableStream,
} from "@vitejs/plugin-rsc/rsc";
import { unstable_matchRSCServerRequest as matchRSCServerRequest } from "react-router";
import { routes } from "../app/routes";

export function fetchServer(request: Request) {
return matchRSCServerRequest({
createTemporaryReferenceSet,
decodeAction,
decodeFormState,
decodeReply,
loadServerAction,
request,
routes,
generateResponse(match, options) {
return new Response(renderToReadableStream(match.payload, options), {
status: match.statusCode,
headers: match.headers,
});
},
});
}

if (import.meta.hot) {
import.meta.hot.accept();
}
29 changes: 29 additions & 0 deletions fixtures/react-router-rsc/react-router-vite/entry.ssr.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { createFromReadableStream } from "@vitejs/plugin-rsc/ssr";
import { renderToReadableStream as renderHTMLToReadableStream } from "react-dom/server.edge";
import {
unstable_routeRSCServerRequest as routeRSCServerRequest,
unstable_RSCStaticRouter as RSCStaticRouter,
} from "react-router";

export default async function handler(
request: Request,
serverResponse: Response,
): Promise<Response> {
const bootstrapScriptContent = await import.meta.viteRsc.loadBootstrapScriptContent("index");

return await routeRSCServerRequest({
request,
serverResponse,
createFromReadableStream,
async renderHTML(getPayload, options) {
const payload = getPayload();

return await renderHTMLToReadableStream(<RSCStaticRouter getPayload={getPayload} />, {
...options,
bootstrapScriptContent,
signal: request.signal,
formState: await payload.formState,
});
},
});
}
12 changes: 12 additions & 0 deletions fixtures/react-router-rsc/react-router-vite/entry.worker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type * as EntrySsr from "./entry.ssr";
import { fetchServer } from "./entry.rsc";

// The distilled Cloudflare worker wrapper expects a `{ fetch }` default export.
// The worker runs in the `rsc` environment and loads the `ssr` environment at
// runtime to render HTML from the RSC payload.
export default {
async fetch(request: Request): Promise<Response> {
const ssr = await import.meta.viteRsc.loadModule<typeof EntrySsr>("ssr", "index");
return ssr.default(request, await fetchServer(request));
},
};
2 changes: 2 additions & 0 deletions fixtures/react-router-rsc/react-router-vite/types.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/// <reference types="vite/client" />
/// <reference types="@vitejs/plugin-rsc/types" />
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
79 changes: 79 additions & 0 deletions fixtures/react-router-rsc/test/smoke.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { createMiniflare, type MiniflareInstance } from "@distilled.cloud/test-utils/miniflare";
import { miniflareModulesFromDirectory } from "@distilled.cloud/test-utils/miniflare-module";
import { expect, test } from "@playwright/test";
import path from "node:path";
import { fileURLToPath } from "node:url";

const dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(dirname, "..");
const client = path.resolve(root, "dist/client");

// The Worker runs in the `rsc` environment and loads the `ssr` environment at
// runtime via a relative `../../ssr/index.js` import, so both output
// directories ship as a single Miniflare module set under matching prefixes.
const WORKER_ENTRY = "rsc/entry.worker.js";

let miniflare: MiniflareInstance;

test.beforeAll(async () => {
const [rsc, ssr] = await Promise.all([
miniflareModulesFromDirectory(path.resolve(root, "dist/rsc"), "rsc"),
miniflareModulesFromDirectory(path.resolve(root, "dist/ssr"), "ssr"),
]);
const modules = [...rsc, ...ssr];
const entryIndex = modules.findIndex((module) => module.path === WORKER_ENTRY);
if (entryIndex === -1) {
throw new Error(`Worker entry "${WORKER_ENTRY}" not found in build output`);
}
// Miniflare treats the first module as the Worker entrypoint.
modules.unshift(modules.splice(entryIndex, 1)[0]);

miniflare = await createMiniflare({
modules,
compatibilityDate: "2026-03-10",
compatibilityFlags: ["nodejs_compat"],
assets: {
directory: client,
routerConfig: {
has_user_worker: true,
invoke_user_worker_ahead_of_assets: false,
debug: true,
},
assetConfig: {
html_handling: "auto-trailing-slash",
not_found_handling: "none",
debug: true,
has_static_routing: false,
},
},
});
});

test.afterAll(async () => {
await miniflare?.[Symbol.asyncDispose]();
});

test("renders the homepage and hydrates client routes", async ({ page }) => {
const response = await page.goto(miniflare.url.toString());
expect(response?.status()).toBe(200);
await page.waitForLoadState("networkidle");
await page.evaluate(() => document.fonts.ready);

await expect(page).toHaveScreenshot("index.png", {
animations: "disabled",
maxDiffPixelRatio: 0.03,
});

await page.click("a[href='/about']");
await page.waitForURL("**/about");
await page.evaluate(() => document.fonts.ready);

await expect(page).toHaveScreenshot("about.png", {
animations: "disabled",
maxDiffPixelRatio: 0.03,
});

expect(await page.textContent("button.counter")).toBe("Count is 0");
await page.click("button.counter");
expect(await page.textContent("button.counter")).toBe("Count is 1");
});
Loading
Loading