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";