diff --git a/packages/react/src/plugin-islands.ts b/packages/react/src/plugin-islands.ts index cacb8db78..c7bcce16c 100644 --- a/packages/react/src/plugin-islands.ts +++ b/packages/react/src/plugin-islands.ts @@ -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, @@ -121,6 +124,10 @@ export function islandsPlugin( } }, + buildEnd() { + recordIslandStyles(this, pageScan, pagesDir); + }, + resolveId: { filter: { id: /^virtual:void-islands-(renderer|client)$/ }, handler(source) { @@ -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, + ); } }, }, @@ -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), ]; } @@ -319,6 +331,8 @@ export async function renderIslandPage(pageObj, assetTags) { function generateIslandClientEntry( manifest: Map, cssFiles: Set, + styles: ReadonlyArray | null, + needsRuntime: boolean, isDev: boolean, ): string { const entries = [...manifest.values()]; @@ -329,13 +343,16 @@ function generateIslandClientEntry( const lines: Array = []; - 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 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)) { @@ -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)}),`); diff --git a/packages/react/test/integration/island-css-build.test.ts b/packages/react/test/integration/island-css-build.test.ts new file mode 100644 index 000000000..97149f117 --- /dev/null +++ b/packages/react/test/integration/island-css-build.test.ts @@ -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 Badge; }`, + 'src/Shell.tsx': `import './shell.css'; +import styles from './card.module.css'; +import Badge from './Badge'; +export default function Shell({ children }) { + return
{children}
; +}`, + 'pages/layout.tsx': `import Shell from '../src/Shell'; +export default function Layout({ children }) { return {children}; }`, + 'pages/index.island.tsx': 'export default function Home() { return

Home

; }', + // 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 ; }`, + }, + 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
{children}
; }', + 'pages/index.island.tsx': 'export default function Home() { return

Home

; }', + }, + }); + try { + for (const file of ['index.html', 'about.html']) { + const { html } = build.page(file); + expect(html, file).toContain(''); + expect(html, file).not.toContain(' { + const build = buildIslandApp({ + packageDir, + pluginName: 'voidReact', + files: { + 'tsconfig.json': tsconfig, + 'src/Counter.tsx': 'export default function Counter() { return ; }', + 'pages/index.island.tsx': `import Counter from '../src/Counter' with { island: 'load' }; +export default function Home() { return ; }`, + }, + }); + try { + expect(build.page('index.html').html).toContain(' 0 || + computePagesNeedingClientJS(pageScan, pagesDir, resolvedConfig).size > 0, + isDev, + ); } if (id.startsWith(WRAPPER_PREFIX)) { const meta = wrapperMeta.get(id); @@ -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), ]; } @@ -337,6 +350,8 @@ export async function renderIslandPage(pageObj, assetTags) { function generateIslandClientEntry( manifest: Map, cssFiles: Set, + styles: ReadonlyArray | null, + needsRuntime: boolean, isDev: boolean, ): string { const entries = [...manifest.values()]; @@ -346,13 +361,17 @@ function generateIslandClientEntry( } const lines: Array = []; - 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 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)) { @@ -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)}),`); diff --git a/packages/solid/test/integration/island-css-build.test.ts b/packages/solid/test/integration/island-css-build.test.ts new file mode 100644 index 000000000..0df086474 --- /dev/null +++ b/packages/solid/test/integration/island-css-build.test.ts @@ -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 Badge; }`, + 'src/Shell.tsx': `import './shell.css'; +import styles from './card.module.css'; +import Badge from './Badge'; +export default function Shell(props) { + return
{props.children}
; +}`, + 'pages/layout.tsx': `import Shell from '../src/Shell'; +export default function Layout(props) { return {props.children}; }`, + 'pages/index.island.tsx': 'export default function Home() { return

Home

; }', + // 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 ; }`, + }, + expectedCss: ['.shell{outline-offset:7px}', '{text-indent:9px}', '.badge{word-spacing:5px}'], +}); diff --git a/packages/svelte/src/plugin-islands.ts b/packages/svelte/src/plugin-islands.ts index 215609919..af30c975f 100644 --- a/packages/svelte/src/plugin-islands.ts +++ b/packages/svelte/src/plugin-islands.ts @@ -6,7 +6,10 @@ import { generateComponentManifest, scanPages, resolveEffectiveLayoutChain } fro import type { PageScanResult } from 'void/pages'; import { resolveSpecifier, - needsIslandClientBundle, + hasIslandPages, + recordIslandStyles, + recordedIslandStyles, + islandStylesPlugin, rewriteCssImportForVirtualModule, computePagesNeedingClientJS, watchPagesForRescan, @@ -138,6 +141,10 @@ export function islandsPlugin( } }, + buildEnd() { + recordIslandStyles(this, pageScan, pagesDir); + }, + resolveId: { filter: { id: /^(virtual:void-islands-(renderer|client)|\/@void-island-wrap\/)/, @@ -177,7 +184,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)) { return generateWrapperComponent(id); @@ -227,14 +241,14 @@ export function islandsPlugin( }, }, - config(_, env) { + async config(_, env) { if (!existsSync(pagesDir)) { return; } if (env.command === 'serve') { return { optimizeDeps: { include: ['svelte'] } }; } - if (needsIslandClientBundle(pagesDir, /\.island\.svelte$/, true)) { + if (await hasIslandPages(root, pagesDir)) { return { environments: { client: { @@ -252,18 +266,21 @@ export function islandsPlugin( }, { name: 'void-svelte:islands-style', - // Only the build's client entry imports `.svelte.css?island-style` - // (dev CSS comes from Void's dev CSS collector). - // - The id stays next to the component, so CSS url() resolves as in the - // component, and it misses vite-plugin-svelte's `.svelte?` filter. + // Loads the component styles that the build's client entry imports (dev + // CSS comes from Void's dev CSS collector): vite-plugin-svelte's + // `.svelte?svelte&type=style` ids that the server build recorded, + // or `.svelte.css?island-style` without a server build. // - The CSS comes from vite-plugin-svelte's own preprocess options and // compile, so the scope hashes match the SSR markup. // - The component is compiled here, not loaded with `this.load`: island - // pages and layouts render only on the server, and loading them would - // resolve their server-only imports in the client build. - // - vite-plugin-svelte's `?svelte&type=style` id is not used: it only - // reads CSS already cached on the module, and its cssScopeTo drops the - // CSS when the component is not imported by the client build. + // pages, their layouts and the components they import render only on + // the server, and loading them would resolve their server-only imports + // in the client build. + // - vite-plugin-svelte's own load only reads CSS already cached on the + // compiled component, and its cssScopeTo drops the CSS when the client + // build does not import the component. Loading its id here keeps one + // module, so a component that the client build also imports does not + // bring the same rules twice. apply: 'build', resolveId: { @@ -274,9 +291,20 @@ export function islandsPlugin( }, load: { - filter: { id: /\.svelte\.css\?island-style$/ }, + filter: { id: [/\.svelte\.css\?island-style$/, /\.svelte\?svelte&type=style/] }, + order: 'pre', async handler(id) { - const file = id.slice(0, -'.css?island-style'.length); + const islandStyle = id.endsWith('.css?island-style'); + if ( + !islandStyle && + (this.environment.config.consumer !== 'client' || + !recordedIslandStyles(pagesDir)?.includes(id)) + ) { + return; + } + const file = islandStyle + ? id.slice(0, -'.css?island-style'.length) + : id.slice(0, id.indexOf('?')); const api = resolvedConfig?.plugins.find( (plugin): plugin is SvelteConfigPlugin => plugin.name === 'vite-plugin-svelte:config', )?.api; @@ -309,6 +337,7 @@ export function islandsPlugin( }, }, }, + islandStylesPlugin(pagesDir), ]; } @@ -398,6 +427,8 @@ export async function renderIslandPage(pageObj, assetTags) { function generateIslandClientEntry( manifest: Map, cssFiles: Set, + styles: ReadonlyArray | null, + needsRuntime: boolean, isDev: boolean, ): string { const entries = [...manifest.values()]; @@ -408,13 +439,16 @@ function generateIslandClientEntry( const lines: Array = []; - 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 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 and style blocks 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'); @@ -429,6 +463,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)}),`); diff --git a/packages/svelte/test/integration/island-css-build.test.ts b/packages/svelte/test/integration/island-css-build.test.ts new file mode 100644 index 000000000..aafd2458b --- /dev/null +++ b/packages/svelte/test/integration/island-css-build.test.ts @@ -0,0 +1,64 @@ +import { join } from 'node:path'; +import { expect, it } from 'vite-plus/test'; +import { + buildIslandApp, + countOf, + defineIslandCssBuildTest, +} from '../../../../test/island-css-build.ts'; + +const packageDir = join(import.meta.dirname, '../..'); +const files = { + 'src/shell.css': '.shell { outline-offset: 7px; }', + 'src/card.module.css': '.card { text-indent: 9px; }', + 'src/Badge.svelte': 'Badge\n', + 'src/Shell.svelte': ` +
{@render children()}
+`, + 'pages/layout.svelte': ` +{@render children()}`, + 'pages/index.island.svelte': '

Home

', + // The layout renders Badge without hydrating it; this page hydrates it. + 'pages/badge.island.svelte': ` +`, +}; + +defineIslandCssBuildTest({ + packageDir, + pluginName: 'voidSvelte', + files, + expectedCss: ['.shell{outline-offset:7px}', '{text-indent:9px}', '{word-spacing:5px}'], + check({ file, html, css }) { + // The style block's rule carries the scope class that the server markup has. + const start = html.indexOf('
name.startsWith('svelte-')); + expect(css, file).toContain(`main.${scope}{outline-width:9px}`); + }, +}); + +it('links component styles once when a regular page also uses the layout', () => { + const build = buildIslandApp({ + packageDir, + pluginName: 'voidSvelte', + files: { ...files, 'pages/regular.svelte': '

Regular

' }, + }); + try { + for (const file of ['index.html', 'about.html', 'regular.html']) { + const { css } = build.page(file); + expect(countOf(css, '{outline-width:9px}'), file).toBe(1); + } + } finally { + build.cleanup(); + } +}, 120_000); diff --git a/packages/void/src/pages/islands-plugin.ts b/packages/void/src/pages/islands-plugin.ts index 3e5443c0e..2aab46682 100644 --- a/packages/void/src/pages/islands-plugin.ts +++ b/packages/void/src/pages/islands-plugin.ts @@ -1,6 +1,12 @@ -import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { existsSync, readFileSync } from 'node:fs'; import { dirname, resolve } from 'pathslash'; -import { normalizePath, type ResolvedConfig, type ViteDevServer } from 'vite'; +import { + isCSSRequest, + normalizePath, + type Plugin, + type ResolvedConfig, + type ViteDevServer, +} from 'vite'; import { findIslandImports, findStaticCssImports } from './island-imports.ts'; import { resolveEffectiveLayoutChain, scanPages } from './scan.ts'; import type { PageScanResult } from './types.ts'; @@ -53,48 +59,163 @@ export function resolveSpecifier( } /** - * Check whether the islands client bundle is needed by scanning file contents. - * Returns true if any island/layout file has island imports or CSS, - * or any .md file has a ` + + + +`, + 'pages/layout.vue': ` +`, + 'pages/index.island.vue': '', + // The layout renders Badge without hydrating it; this page hydrates it. + 'pages/badge.island.vue': ` +`, + }, + expectedCss: [ + '.shell{outline-offset:7px}', + 'main{outline-width:9px}', + '{text-indent:9px}', + '.badge{word-spacing:5px}', + ], + check({ file, html, css }) { + // The scoped rule carries the attribute that the server markup has. + const start = html.indexOf('data-v-'); + expect(start, file).toBeGreaterThan(-1); + const scope = html.slice(start, html.indexOf('>', start)).split(' ')[0]; + expect(css, file).toContain(`.note[${scope}]{outline-width:11px}`); + }, +}); diff --git a/test/island-css-build.ts b/test/island-css-build.ts new file mode 100644 index 000000000..cbc670f09 --- /dev/null +++ b/test/island-css-build.ts @@ -0,0 +1,127 @@ +import { execSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { expect, it } from 'vite-plus/test'; + +const MD_PLUGIN = join(import.meta.dirname, '../packages/md/dist/plugin.mjs'); + +type IslandCssApp = { + /** The adapter package directory, e.g. `packages/react`. */ + packageDir: string; + /** The adapter's plugin export in `dist/plugin.mjs`, e.g. `voidReact`. */ + pluginName: string; + /** App files by path. `index` and `about` must ship no JavaScript. */ + files: Record; + /** Rules (as minified CSS) every page must link, found once in the client build. */ + expectedCss: Array; + /** Extra assertions for each page. */ + check?: (page: { file: string; html: string; css: string }) => void; +}; + +/** The stylesheet links of a page, without a regular expression. */ +function stylesheetHrefs(html: string): Array { + const marker = '` resolves to the adapter package itself. + */ +export function buildIslandApp(app: Omit): { + root: string; + page(file: string): { html: string; css: string }; + /** The text of every built file under `dist/`, by path. */ + output(dir: 'client' | 'ssr'): Map; + cleanup(): void; +} { + const root = mkdtempSync(join(app.packageDir, 'test/integration/__tmp_island_css_')); + try { + const plugin = pathToFileURL(join(app.packageDir, 'dist/plugin.mjs')).href; + const files: Record = { + 'vite.config.mjs': `import { voidPlugin } from 'void'; +import { ${app.pluginName} } from ${JSON.stringify(plugin)}; +import { voidMarkdown } from ${JSON.stringify(pathToFileURL(MD_PLUGIN).href)}; +export default { plugins: [voidPlugin(), ${app.pluginName}(), voidMarkdown()] }; +`, + 'void.config.ts': `import { defineConfig } from 'void/config'; +export default defineConfig({ output: 'static', worker: { compatibility_date: '2026-02-24' } }); +`, + 'pages/about.md': '---\ntitle: About\n---\n\n# About\n', + ...app.files, + }; + for (const [file, content] of Object.entries(files)) { + mkdirSync(dirname(join(root, file)), { recursive: true }); + writeFileSync(join(root, file), content); + } + // The native loader keeps the config's imports in the adapter package scope. + execSync('vp build --configLoader native', { cwd: root, stdio: 'pipe' }); + } catch (error) { + rmSync(root, { recursive: true, force: true }); + throw error; + } + const client = join(root, 'dist/client'); + return { + root, + page(file) { + const html = readFileSync(join(client, file), 'utf-8'); + const css = stylesheetHrefs(html) + .map((href) => readFileSync(join(client, href), 'utf-8')) + .join('\n'); + return { html, css }; + }, + output(dir) { + const out = join(root, 'dist', dir); + const files = new Map(); + for (const file of readdirSync(out, { recursive: true, encoding: 'utf-8' })) { + if (file.endsWith('.js') || file.endsWith('.css') || file.endsWith('.html')) { + files.set(file, readFileSync(join(out, file), 'utf-8')); + } + } + return files; + }, + cleanup() { + rmSync(root, { recursive: true, force: true }); + }, + }; +} + +/** How many times `text` contains `part`. */ +export function countOf(text: string, part: string): number { + return text.split(part).length - 1; +} + +/** + * Shared by the four adapters: a layout renders a component that imports CSS. + * Production must link that CSS on island and Markdown pages, as dev does, + * even though the pages ship no JavaScript. + */ +export function defineIslandCssBuildTest(app: IslandCssApp): void { + it('links the CSS that a layout component imports on island and Markdown pages', () => { + const build = buildIslandApp(app); + try { + for (const file of ['index.html', 'about.html']) { + const { html, css } = build.page(file); + for (const rule of app.expectedCss) { + expect(countOf(css, rule), `${file}: ${rule}`).toBe(1); + } + expect(html, file).not.toContain(' file.endsWith('.css')) + .map(([, text]) => text) + .join('\n'); + for (const rule of app.expectedCss) { + expect(countOf(clientCss, rule), rule).toBe(1); + } + } finally { + build.cleanup(); + } + }, 120_000); +}