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

feat(react-server): render full html via RSC #203

Merged
merged 29 commits into from
Mar 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
9cbaaa7
wip: render full html via RSC
hi-ogawa Mar 18, 2024
708219e
feat: add `virtual:browser-bootstrap/build`
hi-ogawa Mar 18, 2024
2882177
wip: virtual:ssr-css
hi-ogawa Mar 18, 2024
4a30b55
fix: fix examples/basic
hi-ogawa Mar 18, 2024
cad8e66
chore: rename
hi-ogawa Mar 18, 2024
b514278
fix: invalidate virtual:ssr-css/dev.css
hi-ogawa Mar 18, 2024
65259d1
chore: comment
hi-ogawa Mar 18, 2024
24b91ae
chore: remove index.html
hi-ogawa Mar 18, 2024
f622cb7
test: e2e
hi-ogawa Mar 18, 2024
3c496fa
fix: build css url
hi-ogawa Mar 18, 2024
ad62467
refactor: add collectStyle
hi-ogawa Mar 18, 2024
85a0666
refactor: add virtual:ssr-head
hi-ogawa Mar 18, 2024
b14f98f
chore: remove unused
hi-ogawa Mar 18, 2024
b4cee79
refactor: simplify build assets
hi-ogawa Mar 18, 2024
3033053
test: fix regex
hi-ogawa Mar 18, 2024
3a38148
refactor: refactor `virtual:ssr-assets`
hi-ogawa Mar 18, 2024
9d7aa62
fix: ensure __vite_plugin_react_preamble_installed__
hi-ogawa Mar 18, 2024
894c271
test: e2e css no js
hi-ogawa Mar 18, 2024
16d08a3
chore: move code
hi-ogawa Mar 18, 2024
bf0f8f5
chore: release
hi-ogawa Mar 18, 2024
e9c06ed
chore: readme
hi-ogawa Mar 18, 2024
4982047
chore: rename
hi-ogawa Mar 18, 2024
e49ffbf
refactor: add createVirtualPlugin util
hi-ogawa Mar 18, 2024
9d358a1
refactor: minor
hi-ogawa Mar 18, 2024
222a28f
refactor: minor
hi-ogawa Mar 18, 2024
5f516dd
chore: lint
hi-ogawa Mar 18, 2024
703df98
fix: fix "data-ssr-dev-css" removal
hi-ogawa Mar 18, 2024
1d7f999
chore: fix import
hi-ogawa Mar 18, 2024
cf36381
chore: release
hi-ogawa Mar 18, 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
2 changes: 1 addition & 1 deletion packages/react-server/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# @hiogawa/react-server

[Try it on Stackblitz](https://stackblitz.com/edit/github-ufbaye?file=src%2Froutes%2Fserver-action%2Fclient.tsx)
[Try it on Stackblitz](https://stackblitz.com/github/hi-ogawa/vite-plugins/tree/feat-react-server-full-html/)

![image](https://github.com/hi-ogawa/vite-plugins/assets/4232207/119a42ee-d68e-401d-830a-161cc53c8b24)

Expand Down
43 changes: 41 additions & 2 deletions packages/react-server/examples/basic/e2e/basic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ test("navigation", async ({ page }) => {
await page.getByRole("link", { name: "/test/other" }).click();
await page.getByText("Other Page").click();
await page.waitForURL("/test/other");
await page.goBack();
await page.waitForURL("/test");

await checkClientState();
});
Expand Down Expand Up @@ -91,15 +93,21 @@ test("@dev client hmr", async ({ page }) => {
expect(await res.text()).toContain("<div>test-hmr-edit-div</div>");
});

test("css", async ({ page }) => {
test("css", async ({ page, browser }) => {
await page.goto("/test");
await expect(page.getByRole("heading", { name: "RSC Experiment" })).toHaveCSS(
"font-weight",
"700",
);

const page2 = await browser.newPage({ javaScriptEnabled: false });
await page2.goto("/test");
await expect(
page2.getByRole("heading", { name: "RSC Experiment" }),
).toHaveCSS("font-weight", "700");
});

test("@dev css hmr", async ({ page }) => {
test("@dev css hmr", async ({ page, browser }) => {
await page.goto("/test");
await page.getByText("hydrated: true").click();

Expand All @@ -114,10 +122,18 @@ test("@dev css hmr", async ({ page }) => {
"font-weight",
"300",
);

// verify new style is applied without js
const page2 = await browser.newPage({ javaScriptEnabled: false });
await page2.goto("/test");
await expect(
page2.getByRole("heading", { name: "RSC Experiment" }),
).toHaveCSS("font-weight", "300");
});

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

const checkClientState = await setupCheckClientState(page);

Expand Down Expand Up @@ -189,6 +205,29 @@ test("custom entry-react-server", async ({ request }) => {
expect(await res.json()).toEqual({ hello: "world" });
});

test("head in rsc", async ({ page }) => {
await page.goto("/test/head");
await page.getByText("hydrated: true").click();

const checkClientState = await setupCheckClientState(page);

await page.getByRole("link", { name: "title = hello" }).click();
await expect(page).toHaveTitle("hello");
await page.getByRole("link", { name: "title = world" }).click();
await expect(page).toHaveTitle("world");

await checkClientState();

// TODO: it doesn't magically overwrite already rendered title in the layout...
const res = await page.request.get("/test/head?title=hello");
const resText = await res.text();
expect(resText).toMatch(/<head>.*<title>rsc-experiment<\/title>.*<\/head>/s);
expect(resText).toMatch(/<head>.*<title>hello<\/title>.*<\/head>/s);
expect(resText).toMatch(
/<head>.*<meta name="test" content="hello"\/>.*<\/head>/s,
);
});

async function setupCheckClientState(page: Page) {
// setup client state
await page.getByPlaceholder("test-input").fill("hello");
Expand Down
12 changes: 0 additions & 12 deletions packages/react-server/examples/basic/index.html

This file was deleted.

21 changes: 15 additions & 6 deletions packages/react-server/examples/basic/src/routes/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,21 @@ import type { LayoutRouteProps } from "@hiogawa/react-server/server";
import { Header } from "../components/header";
import { NavMenu } from "../components/nav-menu";

export default async function Layout(props: LayoutRouteProps) {
export default function Layout(props: LayoutRouteProps) {
return (
<div className="p-4 flex flex-col gap-2">
<Header />
<NavMenu links={["/", "/test", "/demo/waku_02"]} />
{props.children}
</div>
<html>
<head>
<meta charSet="UTF-8" />
<title>rsc-experiment</title>
<link rel="icon" href="/favicon.ico" />
</head>
<body>
<div className="p-4 flex flex-col gap-2">
<Header />
<NavMenu links={["/", "/test", "/demo/waku_02"]} />
{props.children}
</div>
</body>
</html>
);
}
21 changes: 21 additions & 0 deletions packages/react-server/examples/basic/src/routes/test/head/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Link } from "@hiogawa/react-server/client";
import type { PageRouteProps } from "@hiogawa/react-server/server";

export default function Page(props: PageRouteProps) {
const title = new URL(props.request.url).searchParams.get("title");
return (
<div className="flex flex-col gap-2">
{title && <title>{title}</title>}
{title && <meta name="test" content={title} />}
<h5 className="text-lg font-bold">head test</h5>
<div className="flex gap-2">
<Link className="antd-btn antd-btn-default px-2" href="?title=hello">
title = hello
</Link>
<Link className="antd-btn antd-btn-default px-2" href="?title=world">
title = world
</Link>
</div>
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export default async function Layout(props: React.PropsWithChildren) {
"/test/other",
"/test/action",
"/test/deps",
"/test/head",
"/test/not-found",
]}
/>
Expand Down
12 changes: 0 additions & 12 deletions packages/react-server/examples/starter/index.html

This file was deleted.

55 changes: 31 additions & 24 deletions packages/react-server/examples/starter/src/routes/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,36 @@
import { Link } from "@hiogawa/react-server/client";

export default async function Layout(props: React.PropsWithChildren) {
export default function Layout(props: React.PropsWithChildren) {
return (
<div>
<h3>React Server Starter</h3>
<a
href="https://github.com/hi-ogawa/vite-plugins/tree/main/packages/react-server"
target="_blank"
>
GitHub
</a>
<nav>
<ul>
<li>
<Link href="/">Home</Link>
</li>
<li>
<Link href="/use-state">Counter (useState)</Link>
</li>
<li>
<Link href="/server-action">Counter (server action)</Link>
</li>
</ul>
</nav>
{props.children}
</div>
<html>
<head>
<meta charSet="UTF-8" />
<title>React Server Starter</title>
<link rel="icon" href="/favicon.ico" />
</head>
<body>
<h3>React Server Starter</h3>
<a
href="https://github.com/hi-ogawa/vite-plugins/tree/main/packages/react-server"
target="_blank"
>
GitHub
</a>
<nav>
<ul>
<li>
<Link href="/">Home</Link>
</li>
<li>
<Link href="/use-state">Counter (useState)</Link>
</li>
<li>
<Link href="/server-action">Counter (server action)</Link>
</li>
</ul>
</nav>
{props.children}
</body>
</html>
);
}
2 changes: 1 addition & 1 deletion packages/react-server/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@hiogawa/react-server",
"version": "0.1.0-pre.4",
"version": "0.1.0-pre.6",
"license": "MIT",
"type": "module",
"exports": {
Expand Down
7 changes: 2 additions & 5 deletions packages/react-server/src/entry/browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { debug } from "../lib/debug";
import { injectActionId, wrapRscRequestUrl } from "../lib/shared";
import type { CallServerCallback } from "../lib/types";

// TODO: root error boundary?
// TODO: root error boundary? suspense?

export async function start() {
initDomWebpackCsr();
Expand Down Expand Up @@ -72,11 +72,8 @@ export async function start() {
return React.use(rsc);
}

const rootEl = document.getElementById("root");
tinyassert(rootEl);

reactDomClient.hydrateRoot(
rootEl,
document,
<React.StrictMode>
<Root />
</React.StrictMode>,
Expand Down
78 changes: 23 additions & 55 deletions packages/react-server/src/entry/server.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { typedBoolean } from "@hiogawa/utils";
import { splitFirst } from "@hiogawa/utils";
import reactDomServer from "react-dom/server.edge";
import { injectRSCPayload } from "rsc-html-stream/server";
import {
createModuleMap,
initDomWebpackSsr,
invalidateImportCacheOnFinish,
} from "../lib/ssr";
import { invalidateModule } from "../plugin/utils";

export async function handler(request: Request): Promise<Response> {
const reactServer = await importReactServer();
Expand Down Expand Up @@ -36,7 +37,6 @@ export async function importReactServer(): Promise<
}
}

// TODO: full <html> render by RSC?
export async function renderHtml(
rscStream: ReadableStream,
): Promise<ReadableStream> {
Expand All @@ -62,69 +62,37 @@ export async function renderHtml(
},
);

if (import.meta.env.DEV) {
// ensure latest css
invalidateModule(__devServer, "\0virtual:ssr-assets");
invalidateModule(__devServer, "\0virtual:dev-ssr-css.css?direct");
}
const assets = (await import("virtual:ssr-assets" as string)).default;

const ssrStream = await reactDomServer.renderToReadableStream(rscNode, {
// TODO
bootstrapModules: [],
bootstrapScripts: [],
bootstrapModules: assets.bootstrapModules,
});

return ssrStream
.pipeThrough(invalidateImportCacheOnFinish(renderId))
.pipeThrough(await injectToHtmlTempalte())
.pipeThrough(new TextDecoderStream())
.pipeThrough(injectToHead(assets.head))
.pipeThrough(new TextEncoderStream())
.pipeThrough(injectRSCPayload(rscStream2));
}

async function injectToHtmlTempalte() {
let html = await importHtmlTemplate();

if (import.meta.env.DEV) {
// fix dev FOUC (cf. https://github.com/hi-ogawa/vite-plugins/pull/110)
// for now crawl only direct dependency of entry-client
const entry = "/src/entry-client";
await __devServer.transformRequest(entry);
const modNode = await __devServer.moduleGraph.getModuleByUrl(entry);
if (modNode) {
const links = [...modNode.importedModules]
.map((modNode) => modNode.id)
.filter(typedBoolean)
.filter((id) => id.match(CSS_LANGS_RE))
.map((id) => `<link rel="stylesheet" href="${id}?direct" />\n`)
.join("");
html = html.replace("</head>", `${links}</head>`);
}
}

// transformer to inject SSR stream
const [pre, post] = html.split("<!--@INJECT_SSR@-->");
const encoder = new TextEncoder();
return new TransformStream<Uint8Array, Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode(pre));
},
function injectToHead(data: string) {
const marker = "<head>";
let done = false;
return new TransformStream<string, string>({
transform(chunk, controller) {
if (!done && chunk.includes(marker)) {
const [pre, post] = splitFirst(chunk, marker);
controller.enqueue(pre + marker + data + post);
done = true;
return;
}
controller.enqueue(chunk);
},
flush(controller) {
controller.enqueue(encoder.encode(post));
},
});
}

async function importHtmlTemplate() {
let html: string;
if (import.meta.env.DEV) {
const mod = await import("/index.html?raw");
html = await __devServer.transformIndexHtml("/", mod.default);
} else {
const mod = await import("/dist/client/index.html?raw");
html = mod.default;
}
// ensure </body></html> trailer
// https://github.com/devongovett/rsc-html-stream/blob/5c2f058996e42be6120dfaf1df384361331f3ea9/server.js#L2
html = html.replace(/<\/body>\s*<\/html>\s*/, "</body></html>");
return html;
}

// cf. https://github.com/vitejs/vite/blob/d6bde8b03d433778aaed62afc2be0630c8131908/packages/vite/src/node/constants.ts#L49C23-L50
const CSS_LANGS_RE =
/\.(css|less|sass|scss|styl|stylus|pcss|postcss|sss)(?:$|\?)/;
Loading
Loading