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/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/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"); diff --git a/packages/plugin-vite/tests/build_test.ts b/packages/plugin-vite/tests/build_test.ts index ef08df5ebaf..1a393dcc983 100644 --- a/packages/plugin-vite/tests/build_test.ts +++ b/packages/plugin-vite/tests/build_test.ts @@ -336,6 +336,58 @@ 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") + .evaluate((el) => + window.getComputedStyle(el as Element).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`, {