diff --git a/packages/react-server/README.md b/packages/react-server/README.md
index e383ab475..96e9cdbfb 100644
--- a/packages/react-server/README.md
+++ b/packages/react-server/README.md
@@ -28,6 +28,7 @@ pnpm preview
- `index.html`
- `src/entry-client.tsx`
+- `src/entry-react-server.tsx`
- `src/routes/**/(page|layout).tsx`
- `"use client"`
- `"use server"`
diff --git a/packages/react-server/examples/basic/e2e/basic.test.ts b/packages/react-server/examples/basic/e2e/basic.test.ts
index f68c6afc6..01278bef0 100644
--- a/packages/react-server/examples/basic/e2e/basic.test.ts
+++ b/packages/react-server/examples/basic/e2e/basic.test.ts
@@ -33,7 +33,7 @@ test("404", async ({ page }) => {
await page.getByText("Not Found: /test/not-found").click();
});
-test("@dev rsc hmr", async ({ page }) => {
+test("rsc hmr @dev", async ({ page }) => {
checkNoError(page);
await page.goto("/test");
@@ -51,7 +51,7 @@ test("@dev rsc hmr", async ({ page }) => {
await checkClientState();
});
-test("@dev common hmr", async ({ page }) => {
+test("common hmr @dev", async ({ page }) => {
checkNoError(page);
await page.goto("/test");
@@ -71,7 +71,7 @@ test("@dev common hmr", async ({ page }) => {
await checkClientState();
});
-test("@dev client hmr", async ({ page }) => {
+test("client hmr @dev", async ({ page }) => {
checkNoError(page);
await page.goto("/test");
@@ -93,7 +93,7 @@ test("@dev client hmr", async ({ page }) => {
expect(await res.text()).toContain("
test-hmr-edit-div
");
});
-test("css", async ({ page, browser }) => {
+test("unocss", async ({ page, browser }) => {
await page.goto("/test");
await expect(page.getByRole("heading", { name: "RSC Experiment" })).toHaveCSS(
"font-weight",
@@ -107,10 +107,12 @@ test("css", async ({ page, browser }) => {
).toHaveCSS("font-weight", "700");
});
-test("@dev css hmr", async ({ page, browser }) => {
+test("unocss hmr @dev", async ({ page, browser }) => {
await page.goto("/test");
await page.getByText("hydrated: true").click();
+ const checkClientState = await setupCheckClientState(page);
+
await expect(page.getByRole("heading", { name: "RSC Experiment" })).toHaveCSS(
"font-weight",
"700",
@@ -123,6 +125,8 @@ test("@dev css hmr", async ({ page, browser }) => {
"300",
);
+ await checkClientState();
+
// verify new style is applied without js
const page2 = await browser.newPage({ javaScriptEnabled: false });
await page2.goto("/test");
@@ -131,6 +135,78 @@ test("@dev css hmr", async ({ page, browser }) => {
).toHaveCSS("font-weight", "300");
});
+test("react-server css", async ({ page }) => {
+ await page.goto("/test/css");
+ await expect(page.getByText("css normal")).toHaveCSS(
+ "background-color",
+ "rgb(250, 250, 200)",
+ );
+ await expect(page.getByText("css module")).toHaveCSS(
+ "background-color",
+ "rgb(200, 250, 250)",
+ );
+});
+
+test("react-server css @nojs", async ({ browser }) => {
+ const page = await browser.newPage({ javaScriptEnabled: false });
+ await page.goto("/test/css");
+ await expect(page.getByText("css normal")).toHaveCSS(
+ "background-color",
+ "rgb(250, 250, 200)",
+ );
+ await expect(page.getByText("css module")).toHaveCSS(
+ "background-color",
+ "rgb(200, 250, 250)",
+ );
+});
+
+test("react-server css hmr @dev", async ({ page, browser }) => {
+ await page.goto("/test/css");
+ await page.getByText("hydrated: true").click();
+
+ const checkClientState = await setupCheckClientState(page);
+
+ await expect(page.getByText("css normal")).toHaveCSS(
+ "background-color",
+ "rgb(250, 250, 200)",
+ );
+ await editFile("./src/routes/test/css/css-normal.css", (s) =>
+ s.replace("rgb(250, 250, 200)", "rgb(250, 250, 123)"),
+ );
+ await expect(page.getByText("css normal")).toHaveCSS(
+ "background-color",
+ "rgb(250, 250, 123)",
+ );
+
+ await expect(page.getByText("css module")).toHaveCSS(
+ "background-color",
+ "rgb(200, 250, 250)",
+ );
+ await editFile("./src/routes/test/css/css-module.module.css", (s) =>
+ s.replace("rgb(200, 250, 250)", "rgb(123, 250, 250)"),
+ );
+ await expect(page.getByText("css module")).toHaveCSS(
+ "background-color",
+ "rgb(123, 250, 250)",
+ );
+
+ await checkClientState();
+
+ // verify new style is applied without js
+ {
+ const page = await browser.newPage({ javaScriptEnabled: false });
+ await page.goto("/test/css");
+ await expect(page.getByText("css normal")).toHaveCSS(
+ "background-color",
+ "rgb(250, 250, 123)",
+ );
+ await expect(page.getByText("css module")).toHaveCSS(
+ "background-color",
+ "rgb(123, 250, 250)",
+ );
+ }
+});
+
test("server action with js", async ({ page }) => {
await page.goto("/test/action");
await page.getByText("hydrated: true").click();
diff --git a/packages/react-server/examples/basic/package.json b/packages/react-server/examples/basic/package.json
index 441d383cb..f4f64c01a 100644
--- a/packages/react-server/examples/basic/package.json
+++ b/packages/react-server/examples/basic/package.json
@@ -11,6 +11,7 @@
"test-e2e-preview": "E2E_PREVIEW=1 playwright test",
"vc-build": "pnpm build && bash misc/vercel-serverless/build.sh",
"vc-release": "vercel deploy --prod --prebuilt misc/vercel-serverless",
+ "vc-release-staging": "vercel deploy --prebuilt misc/vercel-serverless",
"cf-build": "SSR_ENTRY=/src/adapters/cloudflare-workers.ts pnpm build && bash misc/cloudflare-workers/build.sh",
"cf-preview": "cd misc/cloudflare-workers && wrangler dev",
"cf-release": "cd misc/cloudflare-workers && wrangler deploy"
diff --git a/packages/react-server/examples/basic/src/routes/test/css/css-module.module.css b/packages/react-server/examples/basic/src/routes/test/css/css-module.module.css
new file mode 100644
index 000000000..5c8e3cb0f
--- /dev/null
+++ b/packages/react-server/examples/basic/src/routes/test/css/css-module.module.css
@@ -0,0 +1,6 @@
+.test {
+ background: rgb(200, 250, 250);
+ padding: 20px;
+ width: 200px;
+ border: 1px solid gray;
+}
diff --git a/packages/react-server/examples/basic/src/routes/test/css/css-normal.css b/packages/react-server/examples/basic/src/routes/test/css/css-normal.css
new file mode 100644
index 000000000..b71240fe3
--- /dev/null
+++ b/packages/react-server/examples/basic/src/routes/test/css/css-normal.css
@@ -0,0 +1,6 @@
+#css-normal {
+ background: rgb(250, 250, 200);
+ padding: 20px;
+ width: 200px;
+ border: 1px solid gray;
+}
diff --git a/packages/react-server/examples/basic/src/routes/test/css/page.tsx b/packages/react-server/examples/basic/src/routes/test/css/page.tsx
new file mode 100644
index 000000000..927aa2549
--- /dev/null
+++ b/packages/react-server/examples/basic/src/routes/test/css/page.tsx
@@ -0,0 +1,14 @@
+import "./css-normal.css";
+import cssModule from "./css-module.module.css";
+
+export default function Page() {
+ return (
+
+
css
+
+
css normal
+
css module
+
+
+ );
+}
diff --git a/packages/react-server/examples/basic/src/routes/test/layout.tsx b/packages/react-server/examples/basic/src/routes/test/layout.tsx
index 7f8781548..992084f00 100644
--- a/packages/react-server/examples/basic/src/routes/test/layout.tsx
+++ b/packages/react-server/examples/basic/src/routes/test/layout.tsx
@@ -12,6 +12,7 @@ export default async function Layout(props: React.PropsWithChildren) {
"/test/action",
"/test/deps",
"/test/head",
+ "/test/css",
"/test/not-found",
]}
/>
diff --git a/packages/react-server/examples/basic/vite.config.ts b/packages/react-server/examples/basic/vite.config.ts
index eadc8a1bb..b153fa115 100644
--- a/packages/react-server/examples/basic/vite.config.ts
+++ b/packages/react-server/examples/basic/vite.config.ts
@@ -14,7 +14,6 @@ export default defineConfig({
react(),
unocss(),
vitePluginReactServer({
- entry: "/src/entry-react-server.tsx",
plugins: [testVitePluginVirtual()],
}),
vitePluginLogger(),
diff --git a/packages/react-server/examples/starter/src/entry-react-server.tsx b/packages/react-server/examples/starter/src/entry-react-server.tsx
new file mode 100644
index 000000000..10dcbc3d6
--- /dev/null
+++ b/packages/react-server/examples/starter/src/entry-react-server.tsx
@@ -0,0 +1 @@
+export { handler } from "@hiogawa/react-server/entry-react-server";
diff --git a/packages/react-server/package.json b/packages/react-server/package.json
index dc78cd87e..3aa34bdd4 100644
--- a/packages/react-server/package.json
+++ b/packages/react-server/package.json
@@ -1,6 +1,6 @@
{
"name": "@hiogawa/react-server",
- "version": "0.1.0-pre.7",
+ "version": "0.1.0-pre.8",
"license": "MIT",
"type": "module",
"exports": {
diff --git a/packages/react-server/src/entry/server.tsx b/packages/react-server/src/entry/server.tsx
index 1b1b2f0bc..958ec3195 100644
--- a/packages/react-server/src/entry/server.tsx
+++ b/packages/react-server/src/entry/server.tsx
@@ -6,7 +6,7 @@ import {
initDomWebpackSsr,
invalidateImportCacheOnFinish,
} from "../lib/ssr";
-import { invalidateModule } from "../plugin/utils";
+import { ENTRY_REACT_SERVER, invalidateModule } from "../plugin/utils";
export async function handler(request: Request): Promise {
const reactServer = await importReactServer();
@@ -31,7 +31,7 @@ export async function importReactServer(): Promise<
typeof import("./react-server")
> {
if (import.meta.env.DEV) {
- return __rscDevServer.ssrLoadModule(__rscEntry) as any;
+ return __rscDevServer.ssrLoadModule(ENTRY_REACT_SERVER) as any;
} else {
return import("/dist/rsc/index.js" as string);
}
@@ -65,6 +65,7 @@ export async function renderHtml(
if (import.meta.env.DEV) {
// ensure latest css
invalidateModule(__devServer, "\0virtual:ssr-assets");
+ invalidateModule(__devServer, "\0virtual:react-server-css.js");
invalidateModule(__devServer, "\0virtual:dev-ssr-css.css?direct");
}
const assets = (await import("virtual:ssr-assets" as string)).default;
diff --git a/packages/react-server/src/plugin/css.ts b/packages/react-server/src/plugin/css.ts
index c65899b37..839c2d85f 100644
--- a/packages/react-server/src/plugin/css.ts
+++ b/packages/react-server/src/plugin/css.ts
@@ -1,9 +1,24 @@
import type { ViteDevServer } from "vite";
-// cf.
+// cf
// https://github.com/hi-ogawa/vite-plugins/blob/3c496fa1bb5ac66d2880986877a37ed262f1d2a6/packages/vite-glob-routes/examples/demo/vite-plugin-ssr-css.ts
+// https://github.com/remix-run/remix/blob/dev/packages/remix-dev/vite/styles.ts
export async function collectStyle(server: ViteDevServer, entries: string[]) {
+ const urls = await collectStyleUrls(server, entries);
+ const styles = await Promise.all(
+ urls.map(async (url) => {
+ const res = await server.transformRequest(url + "?direct");
+ return res?.code;
+ }),
+ );
+ return styles.filter(Boolean).join("\n\n");
+}
+
+export async function collectStyleUrls(
+ server: ViteDevServer,
+ entries: string[],
+) {
const visited = new Set();
async function traverse(url: string) {
@@ -27,20 +42,7 @@ export async function collectStyle(server: ViteDevServer, entries: string[]) {
// traverse
await Promise.all(entries.map((url) => traverse(url)));
- const styles = await Promise.all(
- [...visited].map(async (url) => {
- if (!url.match(CSS_LANGS_RE)) {
- return;
- }
- const mod = await server.ssrLoadModule(url);
- if ("default" in mod && typeof mod["default"] === "string") {
- return mod["default"];
- }
- return;
- }),
- );
-
- return styles.filter(Boolean).join("\n");
+ return [...visited].filter((url) => url.match(CSS_LANGS_RE));
}
// cf. https://github.com/vitejs/vite/blob/d6bde8b03d433778aaed62afc2be0630c8131908/packages/vite/src/node/constants.ts#L49C23-L50
diff --git a/packages/react-server/src/plugin/index.ts b/packages/react-server/src/plugin/index.ts
index c62b93286..c1a2ad7fb 100644
--- a/packages/react-server/src/plugin/index.ts
+++ b/packages/react-server/src/plugin/index.ts
@@ -14,13 +14,14 @@ import {
type PluginOption,
type ViteDevServer,
build,
+ createLogger,
createServer,
parseAstAsync,
} from "vite";
import { debug } from "../lib/debug";
import { USE_CLIENT_RE, USE_SERVER_RE, getExportNames } from "./ast-utils";
-import { collectStyle } from "./css";
-import type { SsrAssetsType } from "./utils";
+import { collectStyle, collectStyleUrls } from "./css";
+import { ENTRY_CLIENT, ENTRY_REACT_SERVER, type SsrAssetsType } from "./utils";
const require = createRequire(import.meta.url);
@@ -51,24 +52,25 @@ class ReactServerManager {
}
export function vitePluginReactServer(options?: {
- /**
- * @default "@hiogawa/react-server/entry-react-server"
- */
- entry?: string;
plugins?: PluginOption[];
}): Plugin[] {
- const rscEntry = options?.entry ?? "@hiogawa/react-server/entry-react-server";
const manager = new ReactServerManager();
let parentServer: ViteDevServer | undefined;
let parentEnv: ConfigEnv;
let rscDevServer: ViteDevServer | undefined;
const rscConfig: InlineConfig = {
- // TODO: custom logger to distinct two server logs easily?
- // customLogger: undefined,
+ customLogger: createLogger(undefined, {
+ prefix: "[react-server]",
+ allowClearScreen: false,
+ }),
clearScreen: false,
configFile: false,
cacheDir: "./node_modules/.vite-rsc",
+ server: {
+ // TODO: for now this is to silence build only virtual:... resolution error
+ preTransformRequests: false,
+ },
optimizeDeps: {
noDiscovery: true,
include: [],
@@ -146,10 +148,12 @@ export function vitePluginReactServer(options?: {
],
build: {
ssr: true,
+ manifest: true,
+ ssrEmitAssets: true,
outDir: "dist/rsc",
rollupOptions: {
input: {
- index: rscEntry,
+ index: ENTRY_REACT_SERVER,
},
},
},
@@ -182,7 +186,7 @@ export function vitePluginReactServer(options?: {
rollupOptions: env.isSsrBuild
? undefined
: {
- input: "/src/entry-client",
+ input: "virtual:entry-client-wrapper.js",
},
},
};
@@ -197,7 +201,6 @@ export function vitePluginReactServer(options?: {
Object.assign(globalThis, {
__devServer: parentServer,
__rscDevServer: rscDevServer,
- __rscEntry: rscEntry,
});
}
if (parentEnv.command === "build") {
@@ -317,7 +320,7 @@ export function vitePluginReactServer(options?: {
`;
const result: SsrAssetsType = {
- bootstrapModules: ["/@id/__x00__virtual:dev-client-entry.js"],
+ bootstrapModules: ["/@id/__x00__virtual:entry-client-wrapper.js"],
head,
};
return `export default ${JSON.stringify(result)}`;
@@ -331,7 +334,7 @@ export function vitePluginReactServer(options?: {
"utf-8",
),
);
- const entry = manifest["src/entry-client.tsx"];
+ const entry = manifest["virtual:entry-client-wrapper.js"];
tinyassert(entry);
const head = (entry.css ?? [])
.map((url) => ``)
@@ -345,19 +348,55 @@ export function vitePluginReactServer(options?: {
tinyassert(false);
}),
- createVirtualPlugin("dev-client-entry.js", () => {
- tinyassert(!manager.buildType);
- // wrapper entry to ensure client entry runs after vite/react inititialization
- return /* js */ `
- for (let i = 0; !window.__vite_plugin_react_preamble_installed__; i++) {
- await new Promise(resolve => setTimeout(resolve, 10 * (2 ** i)));
- }
- await import("/src/entry-client");
- `;
+ createVirtualPlugin("entry-client-wrapper.js", () => {
+ // dev
+ if (!manager.buildType) {
+ // wrapper entry to ensure client entry runs after vite/react inititialization
+ return /* js */ `
+ import "virtual:react-server-css.js";
+ for (let i = 0; !window.__vite_plugin_react_preamble_installed__; i++) {
+ await new Promise(resolve => setTimeout(resolve, 10 * (2 ** i)));
+ }
+ await import("${ENTRY_CLIENT}");
+ `;
+ }
+ // build
+ if (manager.buildType === "client") {
+ return /* js */ `
+ import "virtual:react-server-css.js";
+ import "${ENTRY_CLIENT}"
+ `;
+ }
+ tinyassert(false);
}),
- createVirtualPlugin("dev-ssr-css.css?direct", () => {
+ createVirtualPlugin("dev-ssr-css.css?direct", async () => {
tinyassert(!manager.buildType);
- return collectStyle(__devServer, ["/src/entry-client"]);
+ const styles = await Promise.all([
+ `/******* react-server ********/`,
+ collectStyle(__rscDevServer, [ENTRY_REACT_SERVER]),
+ `/******* client **************/`,
+ collectStyle(__devServer, [ENTRY_CLIENT]),
+ ]);
+ return styles.join("\n\n");
+ }),
+ createVirtualPlugin("react-server-css.js", async () => {
+ // virtual module proxy css imports from react server to client
+ // TODO: invalidate + full reload when add/remove css file?
+ if (!manager.buildType) {
+ const urls = await collectStyleUrls(__rscDevServer, [
+ ENTRY_REACT_SERVER,
+ ]);
+ const code = urls.map((url) => `import "${url}";\n`).join("");
+ // ensure hmr boundary since css module doesn't have `import.meta.hot.accept`
+ return code + `if (import.meta.hot) { import.meta.hot.accept() }`;
+ }
+ if (manager.buildType === "client") {
+ // TODO: probe manifest to collect css?
+ const files = await fg("./dist/rsc/assets/*.css", { absolute: true });
+ const code = files.map((url) => `import "${url}";\n`).join("");
+ return code;
+ }
+ tinyassert(false);
}),
];
}
diff --git a/packages/react-server/src/plugin/utils.tsx b/packages/react-server/src/plugin/utils.tsx
index f0ae7fe27..855e4aa4e 100644
--- a/packages/react-server/src/plugin/utils.tsx
+++ b/packages/react-server/src/plugin/utils.tsx
@@ -11,3 +11,7 @@ export interface SsrAssetsType {
bootstrapModules: string[];
head: string;
}
+
+// TODO: configurable?
+export const ENTRY_CLIENT = "/src/entry-client";
+export const ENTRY_REACT_SERVER = "/src/entry-react-server";