Skip to content

Commit 6f50993

Browse files
Brooooooklynclaude
andcommitted
fix(svelte): bundle compiled CSS for island pages and their layouts
Bug: `vp build` / `void deploy` of a Svelte pages app fails when an `.island.svelte` page, or any layout in its chain (plain `layout.svelte` included), has a `<style>` block. The build stops with `[plugin vite-plugin-svelte:compile] .../pages/layout.svelte?island-style&index=0&lang.css ... Expected token }`. `vp dev` does not use this path, so it looks fine there. Repro: in playground/svelte-pages add `<style>nav { padding: 8px; }</style>` to pages/layout.svelte (or a `<style>` block to pages/blog/index.island.svelte) and run `vp build`. Cause: generateIslandClientEntry in packages/svelte/src/plugin-islands.ts imported each raw `<style>` body as `<file>.svelte?island-style&index=N&lang.css`. That id still matches vite-plugin-svelte's `.svelte(?|#|$)` transform filter, so the CSS text was compiled as a Svelte component and the compiler failed on the first `{`. Even without that, the CSS was the raw unscoped source: it would not match the `svelte-xxxx` classes in the SSR markup, and `:global(...)` would ship as an invalid selector. When every page is an island page, this entry is the only CSS source in production. Fix: the client entry now imports one `<file>.svelte.css?island-style` per component that has a `<style>` (Svelte allows one top-level style). The build-only loader reads the component file and compiles it with vite-plugin-svelte's own API (taken from its `vite-plugin-svelte:config` plugin): the app's `preprocess` runs first, then `compileSvelte` with the resolved compiler options. It returns only the compiled CSS, so the scope hashes match the SSR markup. The component is compiled outside the module graph on purpose. Island pages and their layouts render only on the server, so they may import server-only code (`void/durable`, packages with only `node`/`workerd` exports). Loading them with `this.load` would resolve those imports in the client build and fail it. No island page JS enters the client bundle. The id keeps the component's directory, so CSS url() resolves as before, and it no longer matches the `.svelte?` filter. vite-plugin-svelte's own `?svelte&type=style` id is not reused: it only reads CSS already cached on the module, and its cssScopeTo drops the CSS when the component is not imported in the client build. Files the preprocessor reads, such as Sass partials, go to `this.addWatchFile`, as in vite-plugin-svelte's preprocess plugin, so `vite build --watch` rebuilds the island CSS when they change. Test: packages/svelte/test/unit/plugin-islands.test.ts, "bundles the scoped CSS of island pages and their layouts". The styled island page and layout both import a package with only `node`/`workerd` exports. It fails without the fix with the same `Expected token }` errors, and with a `this.load` based loader with `"." is not exported under the conditions`. "watches files the style preprocessor reads" checks that a style preprocessor's `dependencies` are watch files; without the loop, only the component file is watched. Note: the style loader uses vite-plugin-svelte's internal plugin API, which is not in its public types. If `compileSvelte` or `idParser` is missing there, the build stops and asks for the @sveltejs/vite-plugin-svelte version that @void/svelte depends on. A changed call signature (as between 7.2 and 7.3) still fails the build, with a TypeError. "bundles the scoped CSS of island pages and their layouts" runs a real build against the locked version, so a lockfile bump that changes this API fails it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0123r5HnanmW2VUrZ7VWDufH
1 parent fd7837c commit 6f50993

2 files changed

Lines changed: 156 additions & 42 deletions

File tree

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

Lines changed: 60 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { existsSync, readFileSync } from 'node:fs';
22
import { dirname, resolve, relative } from 'pathslash';
3+
import { preprocess, type CompileResult, type PreprocessorGroup } from 'svelte/compiler';
34
import { normalizePath, type Plugin, type ResolvedConfig } from 'vite';
45
import { generateComponentManifest, scanPages, resolveEffectiveLayoutChain } from 'void/pages';
56
import type { PageScanResult } from 'void/pages';
@@ -31,6 +32,19 @@ type MdPluginWithApi = Plugin & {
3132
};
3233
};
3334
type ScannableEntry = { filePath: string; island: boolean | undefined };
35+
/** The shared API of vite-plugin-svelte, set on its `vite-plugin-svelte:config` plugin. */
36+
type SveltePluginApi = {
37+
options: { preprocess?: PreprocessorGroup | Array<PreprocessorGroup> };
38+
idParser: (id: string, ssr: boolean) => unknown;
39+
compileSvelte: (
40+
request: unknown,
41+
code: string,
42+
options: unknown,
43+
environment: unknown,
44+
sourcemap?: unknown,
45+
) => Promise<{ compiled: CompileResult }>;
46+
};
47+
type SvelteConfigPlugin = Plugin & { api?: SveltePluginApi };
3448

3549
export function islandsPlugin(
3650
pagesDir: string,
@@ -238,36 +252,60 @@ export function islandsPlugin(
238252
},
239253
{
240254
name: 'void-svelte:islands-style',
241-
// Only the build's client entry imports `?island-style` (dev CSS comes
242-
// from Void's dev CSS collector), so this raw file reader never needs
243-
// to run on the dev server.
255+
// Only the build's client entry imports `<file>.svelte.css?island-style`
256+
// (dev CSS comes from Void's dev CSS collector).
257+
// - The id stays next to the component, so CSS url() resolves as in the
258+
// component, and it misses vite-plugin-svelte's `.svelte?` filter.
259+
// - The CSS comes from vite-plugin-svelte's own preprocess options and
260+
// compile, so the scope hashes match the SSR markup.
261+
// - The component is compiled here, not loaded with `this.load`: island
262+
// pages and layouts render only on the server, and loading them would
263+
// resolve their server-only imports in the client build.
264+
// - vite-plugin-svelte's `?svelte&type=style` id is not used: it only
265+
// reads CSS already cached on the module, and its cssScopeTo drops the
266+
// CSS when the component is not imported by the client build.
244267
apply: 'build',
245268

246269
resolveId: {
247-
filter: { id: /\?island-style&/ },
270+
filter: { id: /\.svelte\.css\?island-style$/ },
248271
handler(source) {
249272
return source;
250273
},
251274
},
252275

253276
load: {
254-
filter: { id: /\?island-style&/ },
255-
handler(id) {
256-
// ?island-style&index=N&lang.{ext} — return raw CSS of Nth <style> block
257-
const filePath = id.replace(/\?island-style&.*$/, '');
258-
const params = new URLSearchParams(id.slice(id.indexOf('?island-style&') + 1));
259-
const index = parseInt(params.get('index') || '0', 10);
260-
const content = readFileSync(filePath, 'utf-8');
261-
const styleBlockRe = /<style\b[^>]*>([\s\S]*?)<\/style>/g;
262-
let match;
263-
let i = 0;
264-
while ((match = styleBlockRe.exec(content)) !== null) {
265-
if (i === index) {
266-
return match[1];
277+
filter: { id: /\.svelte\.css\?island-style$/ },
278+
async handler(id) {
279+
const file = id.slice(0, -'.css?island-style'.length);
280+
const api = resolvedConfig?.plugins.find(
281+
(plugin): plugin is SvelteConfigPlugin => plugin.name === 'vite-plugin-svelte:config',
282+
)?.api;
283+
if (typeof api?.compileSvelte !== 'function' || typeof api.idParser !== 'function') {
284+
this.error(
285+
'Svelte island page styles need the @sveltejs/vite-plugin-svelte version that @void/svelte depends on.',
286+
);
287+
}
288+
this.addWatchFile(file);
289+
let code = readFileSync(file, 'utf-8');
290+
let map: unknown;
291+
if (api.options.preprocess) {
292+
const processed = await preprocess(code, api.options.preprocess, { filename: file });
293+
code = processed.code;
294+
map = processed.map;
295+
// This compile is outside the module graph, so `vite build --watch`
296+
// only sees files the preprocessor read (Sass partials) if listed.
297+
for (const dependency of processed.dependencies ?? []) {
298+
this.addWatchFile(dependency);
267299
}
268-
i++;
269300
}
270-
return '/* style block not found */';
301+
const { compiled } = await api.compileSvelte(
302+
api.idParser(file, false),
303+
code,
304+
api.options,
305+
this.environment,
306+
map,
307+
);
308+
return { code: compiled.css?.code ?? '', map: compiled.css?.map ?? null };
271309
},
272310
},
273311
},
@@ -371,18 +409,9 @@ function generateIslandClientEntry(
371409
for (const file of cssFiles) {
372410
const content = readFileSync(file, 'utf-8');
373411

374-
const styleRe = /<style\b([^>]*)>/g;
375-
styleRe.lastIndex = 0;
376-
let styleIndex = 0;
377-
let styleMatch;
378-
while ((styleMatch = styleRe.exec(content)) !== null) {
379-
const attrs = styleMatch[1];
380-
const langMatch = attrs.match(/\blang\s*=\s*["'](\w+)["']/);
381-
const lang = langMatch ? langMatch[1] : 'css';
382-
lines.push(
383-
`import ${JSON.stringify(file + '?island-style&index=' + styleIndex + '&lang.' + lang)};`,
384-
);
385-
styleIndex++;
412+
// A Svelte component has at most one top-level <style>.
413+
if (/<style\b/.test(content)) {
414+
lines.push(`import ${JSON.stringify(file + '.css?island-style')};`);
386415
}
387416

388417
for (const item of findStaticCssImports(content, file)) {

‎packages/svelte/test/unit/plugin-islands.test.ts‎

Lines changed: 96 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { mkdirSync, writeFileSync, rmSync } from 'node:fs';
22
import { join } from 'node:path';
3+
import { svelte } from '@sveltejs/vite-plugin-svelte';
4+
import { compile } from 'svelte/compiler';
5+
import { build, normalizePath, type Rolldown } from 'vite';
36
import { describe, expect, it, beforeEach, afterEach } from 'vite-plus/test';
47
import { islandsPlugin } from '../../src/plugin-islands.ts';
58

@@ -180,20 +183,102 @@ describe('islandsPlugin — island style loader', () => {
180183
rmSync(TMP, { recursive: true, force: true });
181184
});
182185

183-
it('island-style loader is build-only', () => {
184-
const plugin = islandsPlugin(PAGES_DIR).find(
185-
(candidate) => candidate.name === 'void-svelte:islands-style',
186+
it('bundles the scoped CSS of island pages and their layouts', async () => {
187+
// Island pages and their layouts render only on the server, so they may
188+
// import packages the client build cannot resolve.
189+
const serverOnlyDir = join(TMP, 'node_modules', 'server-only-pkg');
190+
mkdirSync(serverOnlyDir, { recursive: true });
191+
writeFileSync(
192+
join(serverOnlyDir, 'package.json'),
193+
JSON.stringify({
194+
name: 'server-only-pkg',
195+
type: 'module',
196+
exports: { '.': { node: './index.js', workerd: './index.js' } },
197+
}),
186198
);
187-
expect(plugin?.apply).toBe('build');
199+
writeFileSync(join(serverOnlyDir, 'index.js'), 'export const label = "Nav";');
200+
const layout = `<script>import { label } from 'server-only-pkg'; let { children } = $props();</script>
201+
<nav>{label}</nav>
202+
{@render children()}
203+
<style>nav { padding: 8px; }</style>`;
204+
const page = `<script>import { label } from 'server-only-pkg';</script>
205+
<p>{label}</p>
206+
<style>p { color: red; } :global(body) { margin: 0; }</style>`;
207+
writeFile('layout.svelte', layout);
208+
writeFile('index.island.svelte', page);
188209

210+
const output = (await build({
211+
root: TMP,
212+
configFile: false,
213+
logLevel: 'silent',
214+
plugins: [svelte(), ...islandsPlugin(PAGES_DIR, { root: TMP })],
215+
build: { write: false, rollupOptions: { external: ['void/pages-client'] } },
216+
})) as Rolldown.RolldownOutput;
217+
const css = output.output
218+
.filter((item) => item.type === 'asset' && item.fileName.endsWith('.css'))
219+
.map((item) => String((item as Rolldown.OutputAsset).source))
220+
.join('');
221+
222+
const scopeOf = (source: string, file: string) =>
223+
/svelte-[a-z0-9]+/.exec(
224+
compile(source, { filename: normalizePath(join(PAGES_DIR, file)), generate: 'server' }).js
225+
.code,
226+
)![0];
227+
expect(css).toContain(`nav.${scopeOf(layout, 'layout.svelte')}{padding:8px}`);
228+
expect(css).toContain(`p.${scopeOf(page, 'index.island.svelte')}{color:red}`);
229+
expect(css).toContain('body{margin:0}');
230+
});
231+
232+
it('watches files the style preprocessor reads', async () => {
233+
const partial = join(PAGES_DIR, '_theme.css');
234+
writeFile('_theme.css', 'nav { padding: 8px; }');
189235
writeFile(
190-
'Card.island.svelte',
191-
`<p>Card</p>
192-
<style>p { color: red; }</style>
193-
<style lang="scss">.second { color: blue; }</style>`,
236+
'layout.svelte',
237+
`<script>let { children } = $props();</script>
238+
<nav>Nav</nav>
239+
{@render children()}
240+
<style>nav { margin: 0; }</style>`,
194241
);
195-
const load = plugin!.load as HookWithHandler<(id: string) => string>;
196-
const file = join(PAGES_DIR, 'Card.island.svelte');
197-
expect(load.handler(`${file}?island-style&index=1&lang.css`)).toBe('.second { color: blue; }');
242+
writeFile('index.island.svelte', '<p>Home</p>');
243+
244+
const watched: Array<string> = [];
245+
const plugins = islandsPlugin(PAGES_DIR, { root: TMP });
246+
const stylePlugin = plugins.find((plugin) => plugin.name === 'void-svelte:islands-style')!;
247+
const load = stylePlugin.load as HookWithHandler<
248+
(this: Rolldown.PluginContext, id: string) => unknown
249+
>;
250+
const handler = load.handler;
251+
load.handler = function (id) {
252+
const context = new Proxy(this, {
253+
get(target, key) {
254+
if (key === 'addWatchFile') {
255+
return (file: string) => {
256+
watched.push(normalizePath(file));
257+
target.addWatchFile(file);
258+
};
259+
}
260+
const value = Reflect.get(target, key, target);
261+
return typeof value === 'function' ? value.bind(target) : value;
262+
},
263+
});
264+
return handler.call(context, id);
265+
};
266+
267+
await build({
268+
root: TMP,
269+
configFile: false,
270+
logLevel: 'silent',
271+
plugins: [
272+
svelte({
273+
preprocess: {
274+
style: ({ content }) => ({ code: content, dependencies: [partial] }),
275+
},
276+
}),
277+
...plugins,
278+
],
279+
build: { write: false, rollupOptions: { external: ['void/pages-client'] } },
280+
});
281+
282+
expect(watched).toContain(normalizePath(partial));
198283
});
199284
});

0 commit comments

Comments
 (0)