Skip to content

Commit c74bd3e

Browse files
Brooooooklynclaude
andauthored
fix(pages): link CSS that island and Markdown pages reach through components (#481)
Fixes #452 ## Problem ``` pages/layout.tsx ──renders──► src/Shell.tsx ──imports──► src/global.css ``` `vp dev` applied `global.css`. A production build did not: - The islands client entry was only built when a page or layout file had a direct CSS import, `<style>` or island import (a text scan). With CSS only reachable through a component, no client build ran, so pages got no `<link rel="stylesheet">`. - Even when the entry was built, it only imported the **direct** CSS imports of pages and layouts. ## Fix ``` server build client build ──────────── ──────────── buildEnd: walk static importedIds islands client entry layouts first, then pages ──recorded──► import "<css id>"; ... (real resolution: aliases, ▼ packages, Vue/Svelte styles) islands-client-*.css → <link> on island/md pages ``` - **When the entry is built:** for every app with island or Markdown pages (`hasIslandPages`). It replaces the `needsIslandClientBundle` text scan, which only the adapters used. - **What CSS goes in:** - The server build has already resolved every import of the island pages and layouts, so the server build's module graph lists the stylesheets they reach. - Static imports only, in import order, like the dev CSS collector. `?inline` / `?url` / `?raw` are skipped. - The walk also enters island components, as dev does, so a layout that renders one without hydrating keeps its CSS. - Without a server build (client-only builds), the old direct-import scan is still used. - **Framework details:** - CSS modules (`*.module.css`, Vue `<style module>`) are kept. Vite marks them side-effect free, so the bare import would drop them. - Vue scoped styles: `cssScopeTo` is cleared for the recorded ids, because the component itself is not in the client bundle. - Svelte: the entry imports vite-plugin-svelte's own `?svelte&type=style` id, compiled through the existing island-style loader, so the same rule is not emitted twice in mixed apps. - **No extra JS:** - Pages that need no JS still get only the `<link>`, no `<script>`. - With no islands and no page needing JS, the entry holds only the CSS imports, so no hydration runtime is bundled. - React and Solid now add the entry to the client build only, as Vue and Svelte already did, so the worker no longer bundles it. ## Behaviour changes worth knowing - Static-output island apps without any direct CSS import are now prerendered. Before, the missing client build meant no HTML. - A Vue island page or layout with `<style scoped>` used to fail the build (`PARSE_ERROR` on `...&scoped=true`). It now builds and links the CSS. - `playground/markdown-blog`: same CSS rules, but `@void/md/theme.css` (imported in the script) now comes before the layout's `<style>`, which is Vue's normal order. ## Known limit (unchanged, not in this PR) In apps with both island and regular pages, `collectRouteCss` (`routing-assets.ts`) puts a chunk's own CSS before the CSS of chunks it imports, so the islands stylesheet can come before shared layout CSS. That order already applied to direct page CSS on `main`. Changing it would change the link order on every page. ## Tests New production-build tests for each adapter (`packages/{react,vue,svelte,solid}/test/integration/island-css-build.test.ts`, shared `test/island-css-build.ts`). Each one builds a static app where a layout renders a component that imports CSS, with an island page and a Markdown page, and checks: - the page links a stylesheet, and each rule (plain CSS, CSS module, Vue/Svelte `<style>`, Vue scoped with matching `data-v-*`, Svelte matching `svelte-*` class) appears exactly once; - pages that need no JS have no `<script>`; - React: an app with no CSS has no link, script or `hydrateRoot`, and `dist/ssr` has no islands client. Plus unit tests for `collectIslandStyles` and `islandStyleRoots` (order, no repeats, `\0` ids, `?inline`/`?url`). | Check | Result | |---|---| | New build tests on `main` sources | 6 fail (no client build) | | New build tests with the fix | pass | | Undo each part (walk, entry decision, each adapter hook, CSS-module keep, Vue scope, Svelte id) | a test fails for each | | Adapter + md package tests, browser suites with local Chrome | pass | | `vp fmt --check`, `vp lint`, typecheck void/react/vue/svelte/solid | clean | 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent dbf62d2 commit c74bd3e

11 files changed

Lines changed: 790 additions & 106 deletions

File tree

‎packages/react/src/plugin-islands.ts‎

Lines changed: 46 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@ import { generateComponentManifest, scanPages, resolveEffectiveLayoutChain } fro
55
import type { PageScanResult } from 'void/pages';
66
import {
77
resolveSpecifier,
8-
needsIslandClientBundle,
8+
hasIslandPages,
9+
recordIslandStyles,
10+
recordedIslandStyles,
11+
islandStylesPlugin,
912
computePagesNeedingClientJS,
1013
watchPagesForRescan,
1114
rewriteCssImportForVirtualModule,
@@ -121,6 +124,10 @@ export function islandsPlugin(
121124
}
122125
},
123126

127+
buildEnd() {
128+
recordIslandStyles(this, pageScan, pagesDir);
129+
},
130+
124131
resolveId: {
125132
filter: { id: /^virtual:void-islands-(renderer|client)$/ },
126133
handler(source) {
@@ -151,7 +158,14 @@ export function islandsPlugin(
151158
if (!pageScan) {
152159
return '';
153160
}
154-
return generateIslandClientEntry(islandManifest, islandCssFiles, isDev);
161+
return generateIslandClientEntry(
162+
islandManifest,
163+
islandCssFiles,
164+
recordedIslandStyles(pagesDir),
165+
islandManifest.size > 0 ||
166+
computePagesNeedingClientJS(pageScan, pagesDir, resolvedConfig).size > 0,
167+
isDev,
168+
);
155169
}
156170
},
157171
},
@@ -211,26 +225,24 @@ export function islandsPlugin(
211225
},
212226
},
213227

214-
config(_, env) {
215-
if (!existsSync(pagesDir)) {
216-
return;
217-
}
218-
// Only add the islands client entry when content needs it
219-
if (
220-
env.command !== 'serve' &&
221-
needsIslandClientBundle(pagesDir, /\.island\.(tsx|jsx)$/, false)
222-
) {
228+
async config(_, env) {
229+
if (env.command !== 'serve' && (await hasIslandPages(root, pagesDir))) {
223230
return {
224-
build: {
225-
manifest: true,
226-
rollupOptions: {
227-
input: { 'islands-client': ISLANDS_CLIENT_ENTRY_ID },
231+
environments: {
232+
client: {
233+
build: {
234+
manifest: true,
235+
rollupOptions: {
236+
input: { 'islands-client': ISLANDS_CLIENT_ENTRY_ID },
237+
},
238+
},
228239
},
229240
},
230241
};
231242
}
232243
},
233244
},
245+
islandStylesPlugin(pagesDir),
234246
];
235247
}
236248

@@ -319,6 +331,8 @@ export async function renderIslandPage(pageObj, assetTags) {
319331
function generateIslandClientEntry(
320332
manifest: Map<string, IslandEntry>,
321333
cssFiles: Set<string>,
334+
styles: ReadonlyArray<string> | null,
335+
needsRuntime: boolean,
322336
isDev: boolean,
323337
): string {
324338
const entries = [...manifest.values()];
@@ -329,13 +343,16 @@ function generateIslandClientEntry(
329343

330344
const lines: Array<string> = [];
331345

332-
lines.push('import { initIslands } from "void/pages-client";');
333-
334346
// In dev, first-paint CSS is provided by Void's shared dev CSS collector.
335347
// Skip CSS imports here to avoid duplicate client injection.
336-
// In production, the <link> tags aren't used — the imports below ensure
337-
// the CSS is included in the bundled output.
338-
if (!isDev) {
348+
// In production, the imports below bundle the CSS that island pages link.
349+
// `styles` comes from the server build; without one, only the direct
350+
// imports of the pages and layouts are known.
351+
if (!isDev && styles) {
352+
for (const id of styles) {
353+
lines.push(`import ${JSON.stringify(id)};`);
354+
}
355+
} else if (!isDev) {
339356
for (const file of cssFiles) {
340357
const content = readFileSync(file, 'utf-8');
341358
for (const item of findStaticCssImports(content, file)) {
@@ -344,6 +361,15 @@ function generateIslandClientEntry(
344361
}
345362
}
346363

364+
// Without islands, no built page gets this script (see
365+
// computePagesNeedingClientJS), so the entry only gives the styles a chunk to
366+
// link. Dev keeps the runtime: it does not reload this module when a page
367+
// gains an island.
368+
if (!needsRuntime && !isDev) {
369+
return lines.join('\n');
370+
}
371+
372+
lines.push('import { initIslands } from "void/pages-client";');
347373
lines.push('const islands = {');
348374
for (const [id, path] of uniqueIslands) {
349375
lines.push(` ${JSON.stringify(id)}: () => import(${JSON.stringify(path)}),`);
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { join } from 'node:path';
2+
import { expect, it } from 'vite-plus/test';
3+
import { buildIslandApp, defineIslandCssBuildTest } from '../../../../test/island-css-build.ts';
4+
5+
const packageDir = join(import.meta.dirname, '../..');
6+
const tsconfig = JSON.stringify({ compilerOptions: { jsx: 'react-jsx' } });
7+
8+
defineIslandCssBuildTest({
9+
packageDir,
10+
pluginName: 'voidReact',
11+
files: {
12+
'tsconfig.json': tsconfig,
13+
'src/shell.css': '.shell { outline-offset: 7px; }',
14+
'src/card.module.css': '.card { text-indent: 9px; }',
15+
'src/badge.css': '.badge { word-spacing: 5px; }',
16+
'src/Badge.tsx': `import './badge.css';
17+
export default function Badge() { return <b className="badge">Badge</b>; }`,
18+
'src/Shell.tsx': `import './shell.css';
19+
import styles from './card.module.css';
20+
import Badge from './Badge';
21+
export default function Shell({ children }) {
22+
return <main className={'shell ' + styles.card}><Badge />{children}</main>;
23+
}`,
24+
'pages/layout.tsx': `import Shell from '../src/Shell';
25+
export default function Layout({ children }) { return <Shell>{children}</Shell>; }`,
26+
'pages/index.island.tsx': 'export default function Home() { return <h1>Home</h1>; }',
27+
// The layout renders Badge without hydrating it; this page hydrates it.
28+
'pages/badge.island.tsx': `import Badge from '../src/Badge' with { island: 'load' };
29+
export default function BadgePage() { return <Badge />; }`,
30+
},
31+
expectedCss: ['.shell{outline-offset:7px}', '{text-indent:9px}', '.badge{word-spacing:5px}'],
32+
});
33+
34+
it('builds island pages without CSS or JavaScript with no stylesheet, script or client runtime', () => {
35+
const build = buildIslandApp({
36+
packageDir,
37+
pluginName: 'voidReact',
38+
files: {
39+
'tsconfig.json': tsconfig,
40+
'pages/layout.tsx':
41+
'export default function Layout({ children }) { return <main>{children}</main>; }',
42+
'pages/index.island.tsx': 'export default function Home() { return <h1>Home</h1>; }',
43+
},
44+
});
45+
try {
46+
for (const file of ['index.html', 'about.html']) {
47+
const { html } = build.page(file);
48+
expect(html, file).toContain('</main>');
49+
expect(html, file).not.toContain('<link');
50+
expect(html, file).not.toContain('<script');
51+
}
52+
// No page loads the islands entry, so it carries no hydration code.
53+
for (const [file, text] of build.output('client')) {
54+
expect(text.includes('hydrateRoot'), file).toBe(false);
55+
}
56+
} finally {
57+
build.cleanup();
58+
}
59+
}, 120_000);
60+
61+
it('keeps the islands client entry out of the server build', () => {
62+
const build = buildIslandApp({
63+
packageDir,
64+
pluginName: 'voidReact',
65+
files: {
66+
'tsconfig.json': tsconfig,
67+
'src/Counter.tsx': 'export default function Counter() { return <button>0</button>; }',
68+
'pages/index.island.tsx': `import Counter from '../src/Counter' with { island: 'load' };
69+
export default function Home() { return <Counter />; }`,
70+
},
71+
});
72+
try {
73+
expect(build.page('index.html').html).toContain('<script');
74+
for (const [file, text] of build.output('ssr')) {
75+
expect(file).not.toContain('islands-client');
76+
expect(text.includes('hydrateRoot'), file).toBe(false);
77+
}
78+
} finally {
79+
build.cleanup();
80+
}
81+
}, 120_000);

‎packages/solid/src/plugin-islands.ts‎

Lines changed: 46 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@ import { generateComponentManifest, scanPages, resolveEffectiveLayoutChain } fro
55
import type { PageScanResult } from 'void/pages';
66
import {
77
resolveSpecifier,
8-
needsIslandClientBundle,
8+
hasIslandPages,
9+
recordIslandStyles,
10+
recordedIslandStyles,
11+
islandStylesPlugin,
912
rewriteCssImportForVirtualModule,
1013
computePagesNeedingClientJS,
1114
watchPagesForRescan,
@@ -135,6 +138,10 @@ export function islandsPlugin(
135138
}
136139
},
137140

141+
buildEnd() {
142+
recordIslandStyles(this, pageScan, pagesDir);
143+
},
144+
138145
resolveId: {
139146
filter: {
140147
id: /^(virtual:void-islands-(renderer|client)|\/@void-island-wrap\/)/,
@@ -172,7 +179,14 @@ export function islandsPlugin(
172179
if (!pageScan) {
173180
return '';
174181
}
175-
return generateIslandClientEntry(islandManifest, islandCssFiles, isDev);
182+
return generateIslandClientEntry(
183+
islandManifest,
184+
islandCssFiles,
185+
recordedIslandStyles(pagesDir),
186+
islandManifest.size > 0 ||
187+
computePagesNeedingClientJS(pageScan, pagesDir, resolvedConfig).size > 0,
188+
isDev,
189+
);
176190
}
177191
if (id.startsWith(WRAPPER_PREFIX)) {
178192
const meta = wrapperMeta.get(id);
@@ -214,25 +228,24 @@ export function islandsPlugin(
214228
},
215229
},
216230

217-
config(_, env) {
218-
if (!existsSync(pagesDir)) {
219-
return;
220-
}
221-
if (
222-
env.command !== 'serve' &&
223-
needsIslandClientBundle(pagesDir, /\.island\.(tsx|jsx)$/, false)
224-
) {
231+
async config(_, env) {
232+
if (env.command !== 'serve' && (await hasIslandPages(root, pagesDir))) {
225233
return {
226-
build: {
227-
manifest: true,
228-
rollupOptions: {
229-
input: { 'islands-client': ISLANDS_CLIENT_ENTRY_ID },
234+
environments: {
235+
client: {
236+
build: {
237+
manifest: true,
238+
rollupOptions: {
239+
input: { 'islands-client': ISLANDS_CLIENT_ENTRY_ID },
240+
},
241+
},
230242
},
231243
},
232244
};
233245
}
234246
},
235247
},
248+
islandStylesPlugin(pagesDir),
236249
];
237250
}
238251

@@ -337,6 +350,8 @@ export async function renderIslandPage(pageObj, assetTags) {
337350
function generateIslandClientEntry(
338351
manifest: Map<string, IslandEntry>,
339352
cssFiles: Set<string>,
353+
styles: ReadonlyArray<string> | null,
354+
needsRuntime: boolean,
340355
isDev: boolean,
341356
): string {
342357
const entries = [...manifest.values()];
@@ -346,13 +361,17 @@ function generateIslandClientEntry(
346361
}
347362

348363
const lines: Array<string> = [];
349-
lines.push('import { initIslands } from "void/pages-client";');
350364

351365
// In dev, first-paint CSS is provided by Void's shared dev CSS collector.
352366
// Skip CSS imports here to avoid duplicate client injection.
353-
// In production, the <link> tags aren't used — the imports below ensure
354-
// the CSS is included in the bundled output.
355-
if (!isDev) {
367+
// In production, the imports below bundle the CSS that island pages link.
368+
// `styles` comes from the server build; without one, only the direct
369+
// imports of the pages and layouts are known.
370+
if (!isDev && styles) {
371+
for (const id of styles) {
372+
lines.push(`import ${JSON.stringify(id)};`);
373+
}
374+
} else if (!isDev) {
356375
for (const file of cssFiles) {
357376
const content = readFileSync(file, 'utf-8');
358377
for (const item of findStaticCssImports(content, file)) {
@@ -361,6 +380,15 @@ function generateIslandClientEntry(
361380
}
362381
}
363382

383+
// Without islands, no built page gets this script (see
384+
// computePagesNeedingClientJS), so the entry only gives the styles a chunk to
385+
// link. Dev keeps the runtime: it does not reload this module when a page
386+
// gains an island.
387+
if (!needsRuntime && !isDev) {
388+
return lines.join('\n');
389+
}
390+
391+
lines.push('import { initIslands } from "void/pages-client";');
364392
lines.push('const islands = {');
365393
for (const [id, path] of uniqueIslands) {
366394
lines.push(` ${JSON.stringify(id)}: () => import(${JSON.stringify(path)}),`);
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { join } from 'node:path';
2+
import { defineIslandCssBuildTest } from '../../../../test/island-css-build.ts';
3+
4+
defineIslandCssBuildTest({
5+
packageDir: join(import.meta.dirname, '../..'),
6+
pluginName: 'voidSolid',
7+
files: {
8+
'tsconfig.json': JSON.stringify({
9+
compilerOptions: { jsx: 'preserve', jsxImportSource: 'solid-js' },
10+
}),
11+
'src/shell.css': '.shell { outline-offset: 7px; }',
12+
'src/card.module.css': '.card { text-indent: 9px; }',
13+
'src/badge.css': '.badge { word-spacing: 5px; }',
14+
'src/Badge.tsx': `import './badge.css';
15+
export default function Badge() { return <b class="badge">Badge</b>; }`,
16+
'src/Shell.tsx': `import './shell.css';
17+
import styles from './card.module.css';
18+
import Badge from './Badge';
19+
export default function Shell(props) {
20+
return <main class={'shell ' + styles.card}><Badge />{props.children}</main>;
21+
}`,
22+
'pages/layout.tsx': `import Shell from '../src/Shell';
23+
export default function Layout(props) { return <Shell>{props.children}</Shell>; }`,
24+
'pages/index.island.tsx': 'export default function Home() { return <h1>Home</h1>; }',
25+
// The layout renders Badge without hydrating it; this page hydrates it.
26+
'pages/badge.island.tsx': `import Badge from '../src/Badge' with { island: 'load' };
27+
export default function BadgePage() { return <Badge />; }`,
28+
},
29+
expectedCss: ['.shell{outline-offset:7px}', '{text-indent:9px}', '.badge{word-spacing:5px}'],
30+
});

0 commit comments

Comments
 (0)