From 5bf78ec0200a51dc9e2485342e7e3742329f11a2 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 24 Mar 2026 17:12:36 +0800 Subject: [PATCH] fix(pages): scope CSS collection to active page and its layout chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous collectDynamicCss walked all dynamicImports from the pages client entry, which included every page and layout in the app. This defeated CSS code splitting — rendering /about would emit stylesheet links for /users, /dashboard, and every other page. Replace with collectRouteCss: a two-phase walk that collects CSS from (1) the entry's static imports (shared/global CSS) and (2) only the active page's chunk and its layout chain's chunks. A build-time PAGE_CSS_KEYS mapping provides the manifest keys for each componentId. getPagesAssetTags and getIslandsAssetTags now accept componentId so they can look up the correct keys. The protocol layer threads componentId through the getAssetTags callback in both handlePageGet and handlePageMutation. pagesDirRelative is normalized to POSIX separators so manifest key lookups work correctly on Windows. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/void/src/pages/protocol.ts | 14 +-- packages/void/src/plugin-routing.ts | 3 +- packages/void/src/router/compile.ts | 93 +++++++++++++++++-- packages/void/test/unit/pages-compile.test.ts | 3 + 4 files changed, 96 insertions(+), 17 deletions(-) diff --git a/packages/void/src/pages/protocol.ts b/packages/void/src/pages/protocol.ts index da9df6278..6b28926c4 100644 --- a/packages/void/src/pages/protocol.ts +++ b/packages/void/src/pages/protocol.ts @@ -116,19 +116,20 @@ export async function handlePageGet( getAssetTags: ( req: Request, env: any, + componentId: string, ) => Promise<{ css: string; preloads: string; body: string }>, configHead?: HeadConfig, island?: boolean, ): Promise { const shared = c.get("shared") || {}; - // Run loader and check for redirect/non-data responses before extracting props + // Run loader and check for redirect/error responses before extracting props let props: Record = {}; if (handler) { const result = await handler(c); if (result instanceof Response) { const status = result.status; - if (status >= 300 && status < 400) return result; + if (status >= 300) return result; } props = await extractProps(result); } @@ -211,7 +212,7 @@ export async function handlePageGet( ); } - const assetTags = await getAssetTags(c.req.raw, c.env); + const assetTags = await getAssetTags(c.req.raw, c.env, componentId); const html = await renderFn(pageObj, assetTags); if (!hasDeferred) { @@ -250,6 +251,7 @@ export async function handlePageMutation( getAssetTags: ( req: Request, env: any, + componentId: string, ) => Promise<{ css: string; preloads: string; body: string }>, configHead?: HeadConfig, island?: boolean, @@ -285,7 +287,7 @@ export async function handlePageMutation( const props = stripDeferred(rawProps); const pageObj = buildMutationPageObj(props, e.errors); if (isPagesRequest(c)) return pageJsonResponse(pageObj); - const assetTags = await getAssetTags(c.req.raw, c.env); + const assetTags = await getAssetTags(c.req.raw, c.env, componentId); return new Response(await renderFn(pageObj, assetTags), { headers: { "content-type": "text/html; charset=utf-8", ...POWERED_BY }, }); @@ -309,7 +311,7 @@ export async function handlePageMutation( const props = stripDeferred(rawProps); const pageObj = buildMutationPageObj(props, errors); if (isPagesRequest(c)) return pageJsonResponse(pageObj); - const assetTags = await getAssetTags(c.req.raw, c.env); + const assetTags = await getAssetTags(c.req.raw, c.env, componentId); return new Response(await renderFn(pageObj, assetTags), { headers: { "content-type": "text/html; charset=utf-8", ...POWERED_BY }, }); @@ -326,7 +328,7 @@ export async function handlePageMutation( const props = stripDeferred(rawProps); const pageObj = buildMutationPageObj(props, null); if (isPagesRequest(c)) return pageJsonResponse(pageObj); - const assetTags = await getAssetTags(c.req.raw, c.env); + const assetTags = await getAssetTags(c.req.raw, c.env, componentId); return new Response(await renderFn(pageObj, assetTags), { headers: { "content-type": "text/html; charset=utf-8", ...POWERED_BY }, }); diff --git a/packages/void/src/plugin-routing.ts b/packages/void/src/plugin-routing.ts index 2cd8eeb6d..52bd7af9f 100644 --- a/packages/void/src/plugin-routing.ts +++ b/packages/void/src/plugin-routing.ts @@ -1,6 +1,6 @@ import { existsSync, mkdirSync, writeFileSync } from "node:fs"; import { readConfig, getDatabaseDialect } from "./config"; -import { dirname, join } from "node:path"; +import { dirname, join, relative } from "node:path"; import { fileURLToPath } from "node:url"; import { loadEnv, type Plugin } from "vite"; import { scanRoutes, type ScanResult } from "./router/scan"; @@ -205,6 +205,7 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug : undefined; return { pagesDir, + pagesDirRelative: relative(root, pagesDir).replaceAll("\\", "/"), protocolPath: resolveFile("void/pages-protocol"), headPath: resolveFile("void/pages-head"), clientEntryId: "virtual:void-pages-client", diff --git a/packages/void/src/router/compile.ts b/packages/void/src/router/compile.ts index 33576bbae..5860f870a 100644 --- a/packages/void/src/router/compile.ts +++ b/packages/void/src/router/compile.ts @@ -1,6 +1,7 @@ import type { HeaderRule, RedirectRule } from "../cli/types"; import type { VoidAuthProvider } from "../auth/providers"; import type { ScanResult } from "./scan"; +import { resolveLayoutIds } from "../pages/layout-resolution"; /** * Script tag injected into dev HTML to load the binding-hint virtual module. @@ -59,6 +60,8 @@ export interface SsrCompileOptions { export interface PagesCompileOptions { /** Absolute path to pages/ directory */ pagesDir: string; + /** Pages directory relative to Vite root, e.g. "pages" — used for manifest key lookup */ + pagesDirRelative: string; /** Absolute path to void/pages-protocol module */ protocolPath: string; /** Absolute path to void/pages-head module */ @@ -715,6 +718,71 @@ function compilePageRoutes(lines: string[], scan: ScanResult, pages: PagesCompil lines.push(` return Array.from(seen);`); lines.push(`}`); lines.push(""); + // Build manifest-key mapping for each page and its layout chain + // so CSS collection can be scoped to the active route only + const relPagesDir = pages.pagesDirRelative; + const pageCssKeys: Record = {}; + for (const page of scan.pages.pages) { + const keys = [`${relPagesDir}/${page.componentPath}`]; + const layoutIds = resolveLayoutIds(page, scan.pages.layouts, scan.pages.namedLayouts); + for (const layoutId of layoutIds) { + // Map layout IDs back to file paths + const layout = scan.pages.layouts.find( + (l) => (l.directory ? `${l.directory}/layout` : "layout") === layoutId, + ); + if (layout) { + keys.push(`${relPagesDir}/${layout.filePath}`); + continue; + } + const named = scan.pages.namedLayouts.find((nl) => { + const id = nl.directory ? `${nl.directory}/_layouts/${nl.name}` : `_layouts/${nl.name}`; + return id === layoutId; + }); + if (named) { + keys.push(`${relPagesDir}/${named.filePath}`); + } + } + pageCssKeys[page.componentId] = keys; + } + lines.push(`const PAGE_CSS_KEYS = ${JSON.stringify(pageCssKeys)};`); + lines.push(""); + + lines.push(`function collectRouteCss(manifest, entryId, pageKeys) {`); + lines.push(` const cssFiles = new Set();`); + lines.push(` const visited = new Set();`); + lines.push(` const toVisit = [entryId];`); + lines.push(` while (toVisit.length > 0) {`); + lines.push(` const id = toVisit.pop();`); + lines.push(` if (visited.has(id)) continue;`); + lines.push(` visited.add(id);`); + lines.push(` const chunk = manifest[id];`); + lines.push(` if (!chunk) continue;`); + lines.push(` if (Array.isArray(chunk.css)) {`); + lines.push(` for (const f of chunk.css) cssFiles.add(f);`); + lines.push(` }`); + lines.push(` if (Array.isArray(chunk.imports)) {`); + lines.push(` for (const i of chunk.imports) toVisit.push(i);`); + lines.push(` }`); + lines.push(` }`); + lines.push(` for (const key of pageKeys) {`); + lines.push(` const stack = [key];`); + lines.push(` while (stack.length > 0) {`); + lines.push(` const id = stack.pop();`); + lines.push(` if (visited.has(id)) continue;`); + lines.push(` visited.add(id);`); + lines.push(` const chunk = manifest[id];`); + lines.push(` if (!chunk) continue;`); + lines.push(` if (Array.isArray(chunk.css)) {`); + lines.push(` for (const f of chunk.css) cssFiles.add(f);`); + lines.push(` }`); + lines.push(` if (Array.isArray(chunk.imports)) {`); + lines.push(` for (const i of chunk.imports) stack.push(i);`); + lines.push(` }`); + lines.push(` }`); + lines.push(` }`); + lines.push(` return cssFiles;`); + lines.push(`}`); + lines.push(""); lines.push(`async function loadPagesManifest(request, env) {`); lines.push(` if (cachedPagesManifest) return cachedPagesManifest;`); lines.push( @@ -732,7 +800,7 @@ function compilePageRoutes(lines: string[], scan: ScanResult, pages: PagesCompil lines.push(` return cachedPagesManifest;`); lines.push(`}`); lines.push(""); - lines.push(`async function getPagesAssetTags(request, env) {`); + lines.push(`async function getPagesAssetTags(request, env, componentId) {`); lines.push(` const manifest = await loadPagesManifest(request, env);`); lines.push(` if (!manifest || typeof manifest !== "object") {`); lines.push(` if (!import.meta.env.DEV) return { css: "", preloads: "", body: "" };`); @@ -743,14 +811,12 @@ function compilePageRoutes(lines: string[], scan: ScanResult, pages: PagesCompil lines.push(` return { css: "", preloads: "", body: "" };`); lines.push(` }`); lines.push(` const ids = collectPagesManifestFiles(manifest, PAGES_CLIENT_ENTRY_ID);`); - lines.push(` const cssFiles = new Set();`); + lines.push(` const pageKeys = componentId ? (PAGE_CSS_KEYS[componentId] || []) : [];`); + lines.push(` const cssFiles = collectRouteCss(manifest, PAGES_CLIENT_ENTRY_ID, pageKeys);`); lines.push(` const preloadFiles = new Set();`); lines.push(` for (const id of ids) {`); lines.push(` const chunk = manifest[id];`); lines.push(` if (!chunk || typeof chunk !== "object") continue;`); - lines.push(` if (Array.isArray(chunk.css)) {`); - lines.push(` for (const cssFile of chunk.css) cssFiles.add(cssFile);`); - lines.push(` }`); lines.push(` if (id !== PAGES_CLIENT_ENTRY_ID && typeof chunk.file === "string") {`); lines.push(` preloadFiles.add(chunk.file);`); lines.push(` }`); @@ -803,7 +869,7 @@ function compilePageRoutes(lines: string[], scan: ScanResult, pages: PagesCompil lines.push(` return { css: "", preloads, body: bodyTag };`); lines.push(`}`); lines.push(""); - lines.push(`async function getIslandsAssetTags(request, env) {`); + lines.push(`async function getIslandsAssetTags(request, env, componentId) {`); lines.push(` const manifest = await loadPagesManifest(request, env);`); lines.push(` if (!manifest || typeof manifest !== "object") {`); lines.push(` if (!import.meta.env.DEV) return { css: "", preloads: "", body: "" };`); @@ -822,14 +888,21 @@ function compilePageRoutes(lines: string[], scan: ScanResult, pages: PagesCompil lines.push(` for (const mdId of mdIds) ids.push(mdId);`); lines.push(` }`); } - lines.push(` const cssFiles = new Set();`); + // Collect route-scoped CSS (page + layout chunks) plus static-import CSS + lines.push(` const pageKeys = componentId ? (PAGE_CSS_KEYS[componentId] || []) : [];`); + lines.push(` const cssFiles = collectRouteCss(manifest, ISLANDS_CLIENT_ENTRY_ID, pageKeys);`); + if (pages.mdClientEntryId) { + // Also collect CSS from the md client entry's static imports + lines.push(` if (mdEntry && typeof mdEntry.file === "string") {`); + lines.push(` const mdKeys = [];`); + lines.push(` const mdCss = collectRouteCss(manifest, MD_CLIENT_ENTRY_ID, mdKeys);`); + lines.push(` for (const f of mdCss) cssFiles.add(f);`); + lines.push(` }`); + } lines.push(` const preloadFiles = new Set();`); lines.push(` for (const id of ids) {`); lines.push(` const chunk = manifest[id];`); lines.push(` if (!chunk || typeof chunk !== "object") continue;`); - lines.push(` if (Array.isArray(chunk.css)) {`); - lines.push(` for (const cssFile of chunk.css) cssFiles.add(cssFile);`); - lines.push(` }`); // Exclude entry points from preloads (they get their own