Skip to content

Commit 331e23b

Browse files
Brooooooklynclaude
andcommitted
fix(pages): link CSS that island and Markdown pages reach through components
In production, island and Markdown pages only got the CSS their page and layout files import directly. A stylesheet imported by a component that a layout renders was missing, and when no file imported CSS directly the islands client entry was not built at all, so no page got a stylesheet. - Build the islands client entry for every app with island or Markdown pages, replacing the text scan in needsIslandClientBundle. - Record the stylesheets that island pages and their layouts reach through static imports from the server build's module graph, and import them in the islands client entry. This covers aliases, packages, CSS modules and Vue/Svelte style blocks, and keeps the CSS order of dev. - Keep CSS modules that the entry imports (Vite marks them side-effect free), keep Vue scoped styles whose component is not in the client bundle, and load Svelte styles through vite-plugin-svelte's own id so a rule is not emitted twice. - Without islands, the entry only holds the CSS imports, so no hydration runtime is bundled. The React and Solid entry is now added only to the client build. Fixes #452 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 43c7fe1 commit 331e23b

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)