Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 46 additions & 20 deletions packages/react/src/plugin-islands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ import { generateComponentManifest, scanPages, resolveEffectiveLayoutChain } fro
import type { PageScanResult } from 'void/pages';
import {
resolveSpecifier,
needsIslandClientBundle,
hasIslandPages,
recordIslandStyles,
recordedIslandStyles,
islandStylesPlugin,
computePagesNeedingClientJS,
watchPagesForRescan,
rewriteCssImportForVirtualModule,
Expand Down Expand Up @@ -121,6 +124,10 @@ export function islandsPlugin(
}
},

buildEnd() {
recordIslandStyles(this, pageScan, pagesDir);
},

resolveId: {
filter: { id: /^virtual:void-islands-(renderer|client)$/ },
handler(source) {
Expand Down Expand Up @@ -151,7 +158,14 @@ export function islandsPlugin(
if (!pageScan) {
return '';
}
return generateIslandClientEntry(islandManifest, islandCssFiles, isDev);
return generateIslandClientEntry(
islandManifest,
islandCssFiles,
recordedIslandStyles(pagesDir),
islandManifest.size > 0 ||
computePagesNeedingClientJS(pageScan, pagesDir, resolvedConfig).size > 0,
isDev,
);
}
},
},
Expand Down Expand Up @@ -211,26 +225,24 @@ export function islandsPlugin(
},
},

config(_, env) {
if (!existsSync(pagesDir)) {
return;
}
// Only add the islands client entry when content needs it
if (
env.command !== 'serve' &&
needsIslandClientBundle(pagesDir, /\.island\.(tsx|jsx)$/, false)
) {
async config(_, env) {
if (env.command !== 'serve' && (await hasIslandPages(root, pagesDir))) {
return {
build: {
manifest: true,
rollupOptions: {
input: { 'islands-client': ISLANDS_CLIENT_ENTRY_ID },
environments: {
client: {
build: {
manifest: true,
rollupOptions: {
input: { 'islands-client': ISLANDS_CLIENT_ENTRY_ID },
},
},
},
},
};
}
},
},
islandStylesPlugin(pagesDir),
];
}

Expand Down Expand Up @@ -319,6 +331,8 @@ export async function renderIslandPage(pageObj, assetTags) {
function generateIslandClientEntry(
manifest: Map<string, IslandEntry>,
cssFiles: Set<string>,
styles: ReadonlyArray<string> | null,
needsRuntime: boolean,
isDev: boolean,
): string {
const entries = [...manifest.values()];
Expand All @@ -329,13 +343,16 @@ function generateIslandClientEntry(

const lines: Array<string> = [];

lines.push('import { initIslands } from "void/pages-client";');

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

// Without islands, no built page gets this script (see
// computePagesNeedingClientJS), so the entry only gives the styles a chunk to
// link. Dev keeps the runtime: it does not reload this module when a page
// gains an island.
if (!needsRuntime && !isDev) {
return lines.join('\n');
}

lines.push('import { initIslands } from "void/pages-client";');
lines.push('const islands = {');
for (const [id, path] of uniqueIslands) {
lines.push(` ${JSON.stringify(id)}: () => import(${JSON.stringify(path)}),`);
Expand Down
81 changes: 81 additions & 0 deletions packages/react/test/integration/island-css-build.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { join } from 'node:path';
import { expect, it } from 'vite-plus/test';
import { buildIslandApp, defineIslandCssBuildTest } from '../../../../test/island-css-build.ts';

const packageDir = join(import.meta.dirname, '../..');
const tsconfig = JSON.stringify({ compilerOptions: { jsx: 'react-jsx' } });

defineIslandCssBuildTest({
packageDir,
pluginName: 'voidReact',
files: {
'tsconfig.json': tsconfig,
'src/shell.css': '.shell { outline-offset: 7px; }',
'src/card.module.css': '.card { text-indent: 9px; }',
'src/badge.css': '.badge { word-spacing: 5px; }',
'src/Badge.tsx': `import './badge.css';
export default function Badge() { return <b className="badge">Badge</b>; }`,
'src/Shell.tsx': `import './shell.css';
import styles from './card.module.css';
import Badge from './Badge';
export default function Shell({ children }) {
return <main className={'shell ' + styles.card}><Badge />{children}</main>;
}`,
'pages/layout.tsx': `import Shell from '../src/Shell';
export default function Layout({ children }) { return <Shell>{children}</Shell>; }`,
'pages/index.island.tsx': 'export default function Home() { return <h1>Home</h1>; }',
// The layout renders Badge without hydrating it; this page hydrates it.
'pages/badge.island.tsx': `import Badge from '../src/Badge' with { island: 'load' };
export default function BadgePage() { return <Badge />; }`,
},
expectedCss: ['.shell{outline-offset:7px}', '{text-indent:9px}', '.badge{word-spacing:5px}'],
});

it('builds island pages without CSS or JavaScript with no stylesheet, script or client runtime', () => {
const build = buildIslandApp({
packageDir,
pluginName: 'voidReact',
files: {
'tsconfig.json': tsconfig,
'pages/layout.tsx':
'export default function Layout({ children }) { return <main>{children}</main>; }',
'pages/index.island.tsx': 'export default function Home() { return <h1>Home</h1>; }',
},
});
try {
for (const file of ['index.html', 'about.html']) {
const { html } = build.page(file);
expect(html, file).toContain('</main>');
expect(html, file).not.toContain('<link');
expect(html, file).not.toContain('<script');
}
// No page loads the islands entry, so it carries no hydration code.
for (const [file, text] of build.output('client')) {
expect(text.includes('hydrateRoot'), file).toBe(false);
}
} finally {
build.cleanup();
}
}, 120_000);

it('keeps the islands client entry out of the server build', () => {
const build = buildIslandApp({
packageDir,
pluginName: 'voidReact',
files: {
'tsconfig.json': tsconfig,
'src/Counter.tsx': 'export default function Counter() { return <button>0</button>; }',
'pages/index.island.tsx': `import Counter from '../src/Counter' with { island: 'load' };
export default function Home() { return <Counter />; }`,
},
});
try {
expect(build.page('index.html').html).toContain('<script');
for (const [file, text] of build.output('ssr')) {
expect(file).not.toContain('islands-client');
expect(text.includes('hydrateRoot'), file).toBe(false);
}
} finally {
build.cleanup();
}
}, 120_000);
64 changes: 46 additions & 18 deletions packages/solid/src/plugin-islands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ import { generateComponentManifest, scanPages, resolveEffectiveLayoutChain } fro
import type { PageScanResult } from 'void/pages';
import {
resolveSpecifier,
needsIslandClientBundle,
hasIslandPages,
recordIslandStyles,
recordedIslandStyles,
islandStylesPlugin,
rewriteCssImportForVirtualModule,
computePagesNeedingClientJS,
watchPagesForRescan,
Expand Down Expand Up @@ -135,6 +138,10 @@ export function islandsPlugin(
}
},

buildEnd() {
recordIslandStyles(this, pageScan, pagesDir);
},

resolveId: {
filter: {
id: /^(virtual:void-islands-(renderer|client)|\/@void-island-wrap\/)/,
Expand Down Expand Up @@ -172,7 +179,14 @@ export function islandsPlugin(
if (!pageScan) {
return '';
}
return generateIslandClientEntry(islandManifest, islandCssFiles, isDev);
return generateIslandClientEntry(
islandManifest,
islandCssFiles,
recordedIslandStyles(pagesDir),
islandManifest.size > 0 ||
computePagesNeedingClientJS(pageScan, pagesDir, resolvedConfig).size > 0,
isDev,
);
}
if (id.startsWith(WRAPPER_PREFIX)) {
const meta = wrapperMeta.get(id);
Expand Down Expand Up @@ -214,25 +228,24 @@ export function islandsPlugin(
},
},

config(_, env) {
if (!existsSync(pagesDir)) {
return;
}
if (
env.command !== 'serve' &&
needsIslandClientBundle(pagesDir, /\.island\.(tsx|jsx)$/, false)
) {
async config(_, env) {
if (env.command !== 'serve' && (await hasIslandPages(root, pagesDir))) {
return {
build: {
manifest: true,
rollupOptions: {
input: { 'islands-client': ISLANDS_CLIENT_ENTRY_ID },
environments: {
client: {
build: {
manifest: true,
rollupOptions: {
input: { 'islands-client': ISLANDS_CLIENT_ENTRY_ID },
},
},
},
},
};
}
},
},
islandStylesPlugin(pagesDir),
];
}

Expand Down Expand Up @@ -337,6 +350,8 @@ export async function renderIslandPage(pageObj, assetTags) {
function generateIslandClientEntry(
manifest: Map<string, IslandEntry>,
cssFiles: Set<string>,
styles: ReadonlyArray<string> | null,
needsRuntime: boolean,
isDev: boolean,
): string {
const entries = [...manifest.values()];
Expand All @@ -346,13 +361,17 @@ function generateIslandClientEntry(
}

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

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

// Without islands, no built page gets this script (see
// computePagesNeedingClientJS), so the entry only gives the styles a chunk to
// link. Dev keeps the runtime: it does not reload this module when a page
// gains an island.
if (!needsRuntime && !isDev) {
return lines.join('\n');
}

lines.push('import { initIslands } from "void/pages-client";');
lines.push('const islands = {');
for (const [id, path] of uniqueIslands) {
lines.push(` ${JSON.stringify(id)}: () => import(${JSON.stringify(path)}),`);
Expand Down
30 changes: 30 additions & 0 deletions packages/solid/test/integration/island-css-build.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { join } from 'node:path';
import { defineIslandCssBuildTest } from '../../../../test/island-css-build.ts';

defineIslandCssBuildTest({
packageDir: join(import.meta.dirname, '../..'),
pluginName: 'voidSolid',
files: {
'tsconfig.json': JSON.stringify({
compilerOptions: { jsx: 'preserve', jsxImportSource: 'solid-js' },
}),
'src/shell.css': '.shell { outline-offset: 7px; }',
'src/card.module.css': '.card { text-indent: 9px; }',
'src/badge.css': '.badge { word-spacing: 5px; }',
'src/Badge.tsx': `import './badge.css';
export default function Badge() { return <b class="badge">Badge</b>; }`,
'src/Shell.tsx': `import './shell.css';
import styles from './card.module.css';
import Badge from './Badge';
export default function Shell(props) {
return <main class={'shell ' + styles.card}><Badge />{props.children}</main>;
}`,
'pages/layout.tsx': `import Shell from '../src/Shell';
export default function Layout(props) { return <Shell>{props.children}</Shell>; }`,
'pages/index.island.tsx': 'export default function Home() { return <h1>Home</h1>; }',
// The layout renders Badge without hydrating it; this page hydrates it.
'pages/badge.island.tsx': `import Badge from '../src/Badge' with { island: 'load' };
export default function BadgePage() { return <Badge />; }`,
},
expectedCss: ['.shell{outline-offset:7px}', '{text-indent:9px}', '.badge{word-spacing:5px}'],
});
Loading
Loading