From 3044d6088532aa5fd7be3cf534d417a35a80ef93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Thu, 9 Apr 2026 13:04:31 +0200 Subject: [PATCH 1/3] fix: CSS modules not working in _app.tsx/_layout.tsx and across routes (#3633) Fixes two CSS modules issues: 1. Islands in _app.tsx, _layout.tsx, or _error.tsx had their CSS missing because they were discovered after was already rendered. Now FreshScripts (at end of ) emits tags for any island CSS that wasn't already injected in . 2. In dev mode, CSS modules for islands shared across multiple routes only worked on the first page visited. The dev server's CSS collection now also walks island module graphs in addition to the client entry, ensuring all island CSS is collected regardless of which route is accessed first. Closes #3633 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../fresh/src/runtime/server/preact_hooks.ts | 35 +++++++++++++++++-- .../plugin-vite/src/plugins/dev_server.ts | 14 +++++++- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/packages/fresh/src/runtime/server/preact_hooks.ts b/packages/fresh/src/runtime/server/preact_hooks.ts index 80c81096960..8070db536a6 100644 --- a/packages/fresh/src/runtime/server/preact_hooks.ts +++ b/packages/fresh/src/runtime/server/preact_hooks.ts @@ -70,6 +70,8 @@ export class RenderState { islandProps: any[] = []; islands = new Set(); islandAssets = new Set(); + /** CSS assets already injected in `` via `RemainingHead`. */ + injectedCss = new Set(); // deno-lint-ignore no-explicit-any encounteredPartials = new Set(); owners = new Map(); @@ -116,6 +118,7 @@ export class RenderState { this.islands.clear(); this.encounteredPartials.clear(); this.owners.clear(); + this.injectedCss.clear(); this.slots = []; this.islandProps = []; this.ownerStack = []; @@ -511,13 +514,19 @@ function RemainingHead() { if (island.css.length > 0) { for (let i = 0; i < island.css.length; i++) { const css = island.css[i]; - items.push(h("link", { rel: "stylesheet", href: css })); + if (!RENDER_STATE!.injectedCss.has(css)) { + RENDER_STATE!.injectedCss.add(css); + items.push(h("link", { rel: "stylesheet", href: css })); + } } } }); RENDER_STATE.islandAssets.forEach((css) => { - items.push(h("link", { rel: "stylesheet", href: css })); + if (!RENDER_STATE!.injectedCss.has(css)) { + RENDER_STATE!.injectedCss.add(css); + items.push(h("link", { rel: "stylesheet", href: css })); + } }); if (items.length > 0) { @@ -629,10 +638,32 @@ export function FreshScripts() { // Remaining slots must be rendered before creating the Fresh runtime // script, so that we have the full list of islands rendered + + // Collect CSS for islands discovered after was rendered + // (e.g. islands used in _app.tsx, _layout.tsx, or _error.tsx). + // deno-lint-ignore no-explicit-any + const lateCssLinks: VNode[] = []; + RENDER_STATE.islands.forEach((island) => { + for (let i = 0; i < island.css.length; i++) { + const css = island.css[i]; + if (!RENDER_STATE!.injectedCss.has(css)) { + RENDER_STATE!.injectedCss.add(css); + lateCssLinks.push(h("link", { rel: "stylesheet", href: css })); + } + } + }); + RENDER_STATE.islandAssets.forEach((css) => { + if (!RENDER_STATE!.injectedCss.has(css)) { + RENDER_STATE!.injectedCss.add(css); + lateCssLinks.push(h("link", { rel: "stylesheet", href: css })); + } + }); + return ( h( Fragment, null, + ...lateCssLinks, slots.map((slot) => { if (slot === null) return null; return ( diff --git a/packages/plugin-vite/src/plugins/dev_server.ts b/packages/plugin-vite/src/plugins/dev_server.ts index 0411c535250..c1ca76d6c1f 100644 --- a/packages/plugin-vite/src/plugins/dev_server.ts +++ b/packages/plugin-vite/src/plugins/dev_server.ts @@ -128,11 +128,23 @@ export function devServer(freshConfig: ResolvedFreshViteConfig): Plugin[] { url.pathname !== "/__inspect" && res.headers.get("Content-Type")?.includes("text/html") ) { + const clientEnv = server.environments.client; const collected = await collectCss( "fresh:client-entry", - server.environments.client, + clientEnv, ); + // Also collect CSS from island modules. In dev mode, + // island css is [] so RemainingHead can't inject them. + // Walk all loaded modules that look like island entries + // to pick up their CSS module imports. + for (const mod of clientEnv.moduleGraph.idToModuleMap.values()) { + if (mod.id?.includes("fresh-island::")) { + const islandCss = await collectCss(mod.id, clientEnv); + collected.push(...islandCss); + } + } + let html = await res.text(); const styles = collected.join("\n"); From a01e03766600d13c061358753860fce0b5a23c86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Thu, 9 Apr 2026 13:41:51 +0200 Subject: [PATCH 2/3] test: add tests for CSS modules in _app.tsx and shared across routes Adds an AppNav island with a CSS module to _app.tsx and a second page that shares the CssModules island. Tests verify: 1. CSS modules from islands in _app.tsx are injected into the page (both build and dev mode) 2. CSS modules from shared islands work on the second page visited, not just the first (both build and dev mode) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../demo/islands/AppNav.module.css | 3 ++ packages/plugin-vite/demo/islands/AppNav.tsx | 10 ++++ packages/plugin-vite/demo/routes/_app.tsx | 2 + .../demo/routes/tests/css_modules_page2.tsx | 10 ++++ packages/plugin-vite/tests/build_test.ts | 53 +++++++++++++++++++ packages/plugin-vite/tests/dev_server_test.ts | 47 ++++++++++++++++ 6 files changed, 125 insertions(+) create mode 100644 packages/plugin-vite/demo/islands/AppNav.module.css create mode 100644 packages/plugin-vite/demo/islands/AppNav.tsx create mode 100644 packages/plugin-vite/demo/routes/tests/css_modules_page2.tsx diff --git a/packages/plugin-vite/demo/islands/AppNav.module.css b/packages/plugin-vite/demo/islands/AppNav.module.css new file mode 100644 index 00000000000..ec58c3182de --- /dev/null +++ b/packages/plugin-vite/demo/islands/AppNav.module.css @@ -0,0 +1,3 @@ +.nav { + background-color: rgb(30, 30, 30); +} diff --git a/packages/plugin-vite/demo/islands/AppNav.tsx b/packages/plugin-vite/demo/islands/AppNav.tsx new file mode 100644 index 00000000000..8cd7d09f14e --- /dev/null +++ b/packages/plugin-vite/demo/islands/AppNav.tsx @@ -0,0 +1,10 @@ +// @ts-ignore upstream issue https://github.com/denoland/deno/issues/30560 +import styles from "./AppNav.module.css"; + +export function AppNav() { + return ( + + ); +} diff --git a/packages/plugin-vite/demo/routes/_app.tsx b/packages/plugin-vite/demo/routes/_app.tsx index 8e2fce9f295..639a95c7970 100644 --- a/packages/plugin-vite/demo/routes/_app.tsx +++ b/packages/plugin-vite/demo/routes/_app.tsx @@ -1,4 +1,5 @@ import type { PageProps } from "fresh"; +import { AppNav } from "../islands/AppNav.tsx"; export default function App({ Component }: PageProps) { return ( @@ -9,6 +10,7 @@ export default function App({ Component }: PageProps) { + diff --git a/packages/plugin-vite/demo/routes/tests/css_modules_page2.tsx b/packages/plugin-vite/demo/routes/tests/css_modules_page2.tsx new file mode 100644 index 00000000000..1b0ea13ae29 --- /dev/null +++ b/packages/plugin-vite/demo/routes/tests/css_modules_page2.tsx @@ -0,0 +1,10 @@ +import { CssModules } from "../../islands/CssModules.tsx"; + +export default function Page2() { + return ( +
+ +

page2

+
+ ); +} diff --git a/packages/plugin-vite/tests/build_test.ts b/packages/plugin-vite/tests/build_test.ts index ef08df5ebaf..a3f1135838e 100644 --- a/packages/plugin-vite/tests/build_test.ts +++ b/packages/plugin-vite/tests/build_test.ts @@ -336,6 +336,59 @@ integrationTest("vite build - css modules", async () => { ); }); +// Issue: https://github.com/denoland/fresh/issues/3633 +integrationTest( + "vite build - css modules in _app.tsx island are injected", + async () => { + await launchProd( + { cwd: viteResult.tmp }, + async (address) => { + await withBrowser(async (page) => { + await page.goto(`${address}/tests/css_modules`, { + waitUntil: "networkidle2", + }); + + // The AppNav island is in _app.tsx and uses a CSS module. + // Its styles should be injected even though the island + // is discovered after renders. + const bgColor = await page + .locator(".app-nav") + // deno-lint-ignore no-explicit-any + .evaluate((el) => + window.getComputedStyle(el as any).backgroundColor + ); + expect(bgColor).toEqual("rgb(30, 30, 30)"); + }); + }, + ); + }, +); + +// Issue: https://github.com/denoland/fresh/issues/3633 +integrationTest( + "vite build - css modules work on second page with shared island", + async () => { + await launchProd( + { cwd: viteResult.tmp }, + async (address) => { + await withBrowser(async (page) => { + // Access the second page that shares the CssModules island + await page.goto(`${address}/tests/css_modules_page2`, { + waitUntil: "networkidle2", + }); + + // The shared CssModules island's CSS should work here too + const color = await page + .locator(".red > h1") + // deno-lint-ignore no-explicit-any + .evaluate((el) => window.getComputedStyle(el as any).color); + expect(color).toEqual("rgb(255, 0, 0)"); + }); + }, + ); + }, +); + integrationTest("vite build - route css import", async () => { await launchProd( { cwd: viteResult.tmp }, diff --git a/packages/plugin-vite/tests/dev_server_test.ts b/packages/plugin-vite/tests/dev_server_test.ts index 9126ddf08da..ffc30f92ee2 100644 --- a/packages/plugin-vite/tests/dev_server_test.ts +++ b/packages/plugin-vite/tests/dev_server_test.ts @@ -235,6 +235,53 @@ integrationTest("vite dev - css modules", async () => { }); }); +// Issue: https://github.com/denoland/fresh/issues/3633 +integrationTest( + "vite dev - css modules in _app.tsx island are injected", + async () => { + await withBrowser(async (page) => { + await page.goto(`${demoServer.address()}/tests/css_modules`, { + waitUntil: "networkidle2", + }); + + await waitFor(async () => { + const bgColor = await page + .locator(".app-nav") + // deno-lint-ignore no-explicit-any + .evaluate((el) => window.getComputedStyle(el as any).backgroundColor); + expect(bgColor).toEqual("rgb(30, 30, 30)"); + return true; + }); + }); + }, +); + +// Issue: https://github.com/denoland/fresh/issues/3633 +integrationTest( + "vite dev - css modules work on second page with shared island", + async () => { + await withBrowser(async (page) => { + // First visit a different page, then visit page2 that shares + // the CssModules island. In dev mode, the CSS must still work. + await page.goto(`${demoServer.address()}/`, { + waitUntil: "networkidle2", + }); + await page.goto(`${demoServer.address()}/tests/css_modules_page2`, { + waitUntil: "networkidle2", + }); + + await waitFor(async () => { + const color = await page + .locator(".red > h1") + // deno-lint-ignore no-explicit-any + .evaluate((el) => window.getComputedStyle(el as any).color); + expect(color).toEqual("rgb(255, 0, 0)"); + return true; + }); + }); + }, +); + integrationTest("vite dev - route css import", async () => { await withBrowser(async (page) => { await page.goto(`${demoServer.address()}/tests/css`, { From a1643c6327960dac1c7f5d031c9fcb3c0b79ef53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Thu, 9 Apr 2026 14:15:16 +0200 Subject: [PATCH 3/3] chore: fix lint error in build test Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/plugin-vite/tests/build_test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/plugin-vite/tests/build_test.ts b/packages/plugin-vite/tests/build_test.ts index a3f1135838e..1a393dcc983 100644 --- a/packages/plugin-vite/tests/build_test.ts +++ b/packages/plugin-vite/tests/build_test.ts @@ -353,9 +353,8 @@ integrationTest( // is discovered after renders. const bgColor = await page .locator(".app-nav") - // deno-lint-ignore no-explicit-any .evaluate((el) => - window.getComputedStyle(el as any).backgroundColor + window.getComputedStyle(el as Element).backgroundColor ); expect(bgColor).toEqual("rgb(30, 30, 30)"); });