diff --git a/.changeset/plenty-eyes-talk.md b/.changeset/plenty-eyes-talk.md
new file mode 100644
index 000000000..f753bc4f2
--- /dev/null
+++ b/.changeset/plenty-eyes-talk.md
@@ -0,0 +1,5 @@
+---
+'@sveltejs/vite-plugin-svelte': minor
+---
+
+scope css to js module to enable treeshaking scoped css from unused components. Requires vite 6.2
diff --git a/packages/e2e-tests/css-treeshake/__tests__/css-treeshake.spec.ts b/packages/e2e-tests/css-treeshake/__tests__/css-treeshake.spec.ts
new file mode 100644
index 000000000..d9edac1c8
--- /dev/null
+++ b/packages/e2e-tests/css-treeshake/__tests__/css-treeshake.spec.ts
@@ -0,0 +1,37 @@
+import { browserLogs, findAssetFile, getColor, getEl, getText, isBuild } from '~utils';
+import { expect } from 'vitest';
+
+test('should not have failed requests', async () => {
+ browserLogs.forEach((msg) => {
+ expect(msg).not.toMatch('404');
+ });
+});
+
+test('should apply css from used components', async () => {
+ expect(await getText('#app')).toBe('App');
+ expect(await getColor('#app')).toBe('blue');
+ expect(await getText('#a')).toBe('A');
+ expect(await getColor('#a')).toBe('red');
+});
+
+test('should apply css from unused components that contain global styles', async () => {
+ expect(await getEl('head style[src]'));
+ expect(await getColor('#test')).toBe('green'); // from B.svelte
+});
+
+test('should not render unused components', async () => {
+ expect(await getEl('#b')).toBeNull();
+ expect(await getEl('#c')).toBeNull();
+});
+
+if (isBuild) {
+ test('should include unscoped global styles from unused components', async () => {
+ const cssOutput = findAssetFile(/index-.*\.css/);
+ expect(cssOutput).toContain('#test{color:green}'); // from B.svelte
+ });
+ test('should not include scoped styles from unused components', async () => {
+ const cssOutput = findAssetFile(/index-.*\.css/);
+ // from C.svelte
+ expect(cssOutput).not.toContain('.unused');
+ });
+}
diff --git a/packages/e2e-tests/css-treeshake/index.html b/packages/e2e-tests/css-treeshake/index.html
new file mode 100644
index 000000000..5ec38e6d2
--- /dev/null
+++ b/packages/e2e-tests/css-treeshake/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Svelte app
+
+
+
+
+
+
diff --git a/packages/e2e-tests/css-treeshake/package.json b/packages/e2e-tests/css-treeshake/package.json
new file mode 100644
index 000000000..7119507d4
--- /dev/null
+++ b/packages/e2e-tests/css-treeshake/package.json
@@ -0,0 +1,17 @@
+{
+ "name": "e2e-tests-css-treeshake",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "devDependencies": {
+ "@sveltejs/vite-plugin-svelte": "workspace:^",
+ "sass": "^1.85.1",
+ "svelte": "^5.20.5",
+ "vite": "^6.2.0"
+ }
+}
diff --git a/packages/e2e-tests/css-treeshake/src/A.svelte b/packages/e2e-tests/css-treeshake/src/A.svelte
new file mode 100644
index 000000000..ef94d98f3
--- /dev/null
+++ b/packages/e2e-tests/css-treeshake/src/A.svelte
@@ -0,0 +1,7 @@
+A
+
+
diff --git a/packages/e2e-tests/css-treeshake/src/App.svelte b/packages/e2e-tests/css-treeshake/src/App.svelte
new file mode 100644
index 000000000..ca9ca9a52
--- /dev/null
+++ b/packages/e2e-tests/css-treeshake/src/App.svelte
@@ -0,0 +1,13 @@
+
+
+test
+App
+
+
+
diff --git a/packages/e2e-tests/css-treeshake/src/B.svelte b/packages/e2e-tests/css-treeshake/src/B.svelte
new file mode 100644
index 000000000..26f088447
--- /dev/null
+++ b/packages/e2e-tests/css-treeshake/src/B.svelte
@@ -0,0 +1,10 @@
+B
+
+
diff --git a/packages/e2e-tests/css-treeshake/src/C.svelte b/packages/e2e-tests/css-treeshake/src/C.svelte
new file mode 100644
index 000000000..b452d6408
--- /dev/null
+++ b/packages/e2e-tests/css-treeshake/src/C.svelte
@@ -0,0 +1,14 @@
+C
+
+
diff --git a/packages/e2e-tests/css-treeshake/src/barrel.js b/packages/e2e-tests/css-treeshake/src/barrel.js
new file mode 100644
index 000000000..718d05e83
--- /dev/null
+++ b/packages/e2e-tests/css-treeshake/src/barrel.js
@@ -0,0 +1,4 @@
+export { default as A } from './A.svelte';
+// B and C are unused, their css should not be included
+export { default as B } from './B.svelte';
+export { default as C } from './C.svelte';
diff --git a/packages/e2e-tests/css-treeshake/src/main.js b/packages/e2e-tests/css-treeshake/src/main.js
new file mode 100644
index 000000000..071c75dc7
--- /dev/null
+++ b/packages/e2e-tests/css-treeshake/src/main.js
@@ -0,0 +1,3 @@
+import App from './App.svelte';
+import { mount } from 'svelte';
+mount(App, { target: document.body });
diff --git a/packages/e2e-tests/css-treeshake/src/vite-env.d.ts b/packages/e2e-tests/css-treeshake/src/vite-env.d.ts
new file mode 100644
index 000000000..4078e7476
--- /dev/null
+++ b/packages/e2e-tests/css-treeshake/src/vite-env.d.ts
@@ -0,0 +1,2 @@
+///
+///
diff --git a/packages/e2e-tests/css-treeshake/svelte.config.js b/packages/e2e-tests/css-treeshake/svelte.config.js
new file mode 100644
index 000000000..76bab5483
--- /dev/null
+++ b/packages/e2e-tests/css-treeshake/svelte.config.js
@@ -0,0 +1,5 @@
+import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
+
+export default {
+ preprocess: [vitePreprocess()]
+};
diff --git a/packages/e2e-tests/css-treeshake/vite.config.js b/packages/e2e-tests/css-treeshake/vite.config.js
new file mode 100644
index 000000000..6f9c7da64
--- /dev/null
+++ b/packages/e2e-tests/css-treeshake/vite.config.js
@@ -0,0 +1,7 @@
+import { defineConfig } from 'vite';
+import { svelte } from '@sveltejs/vite-plugin-svelte';
+import { env } from 'node:process';
+// https://vitejs.dev/config/
+export default defineConfig({
+ plugins: [svelte()]
+});
diff --git a/packages/vite-plugin-svelte/src/index.js b/packages/vite-plugin-svelte/src/index.js
index 9ec43e9ec..cce486d77 100644
--- a/packages/vite-plugin-svelte/src/index.js
+++ b/packages/vite-plugin-svelte/src/index.js
@@ -118,9 +118,20 @@ export function svelte(inlineOptions) {
};
} else {
if (query.svelte && query.type === 'style') {
- const css = cache.getCSS(svelteRequest);
+ // @ts-expect-error __meta does not exist
+ const { __meta, ...css } = cache.getCSS(svelteRequest);
if (css) {
- return css;
+ if (__meta?.hasUnscopedGlobalCss) {
+ return css; // css contains unscoped global, do not scope to component
+ }
+ return {
+ ...css,
+ meta: {
+ vite: {
+ cssScopeTo: [svelteRequest.filename, 'default']
+ }
+ }
+ };
}
}
// prevent vite asset plugin from loading files as url that should be compiled in transform
diff --git a/packages/vite-plugin-svelte/src/types/compile.d.ts b/packages/vite-plugin-svelte/src/types/compile.d.ts
index d6ba48e0f..b2171155f 100644
--- a/packages/vite-plugin-svelte/src/types/compile.d.ts
+++ b/packages/vite-plugin-svelte/src/types/compile.d.ts
@@ -12,6 +12,9 @@ export interface Code {
code: string;
map?: any;
dependencies?: any[];
+ __meta?: {
+ hasUnscopedGlobalCss?: boolean;
+ };
}
export interface CompileData {
diff --git a/packages/vite-plugin-svelte/src/utils/compile.js b/packages/vite-plugin-svelte/src/utils/compile.js
index 4c0e524c9..58ef02807 100644
--- a/packages/vite-plugin-svelte/src/utils/compile.js
+++ b/packages/vite-plugin-svelte/src/utils/compile.js
@@ -1,5 +1,4 @@
import * as svelte from 'svelte/compiler';
-
import { safeBase64Hash } from './hash.js';
import { log } from './log.js';
@@ -69,18 +68,30 @@ export function createCompileSvelte() {
}
let preprocessed;
- let preprocessors = options.preprocess;
+ let hasUnscopedGlobalCss = false;
+ const preprocessors = options.preprocess
+ ? Array.isArray(options.preprocess)
+ ? [...options.preprocess]
+ : [options.preprocess]
+ : [];
+
if (!options.isBuild && options.emitCss && compileOptions.hmr) {
// inject preprocessor that ensures css hmr works better
- if (!Array.isArray(preprocessors)) {
- preprocessors = preprocessors
- ? [preprocessors, devStylePreprocessor]
- : [devStylePreprocessor];
- } else {
- preprocessors = preprocessors.concat(devStylePreprocessor);
- }
+ preprocessors.push(devStylePreprocessor);
}
- if (preprocessors) {
+
+ if (options.emitCss) {
+ // check if css has unscoped global rules
+ // This is later used to decide if css output can be scoped to the js module for treeshaking
+ preprocessors.push({
+ name: 'test-has-global-style',
+ style({ content }) {
+ hasUnscopedGlobalCss = /(?:^|,)\s*(?::global[\s{(]|@keyframes -global-)/m.test(content);
+ }
+ });
+ }
+
+ if (preprocessors.length > 0) {
try {
preprocessed = await svelte.preprocess(code, preprocessors, { filename }); // full filename here so postcss works
} catch (e) {
@@ -133,6 +144,16 @@ export function createCompileSvelte() {
let compiled;
try {
compiled = svelte.compile(finalCode, { ...finalCompileOptions, filename });
+
+ if (compiled.css && hasUnscopedGlobalCss) {
+ Object.defineProperty(compiled.css, '__meta', {
+ value: { hasUnscopedGlobalCss },
+ writable: false,
+ enumerable: false,
+ configurable: false
+ });
+ }
+
// patch output with partial accept until svelte does it
// TODO remove later
if (
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 5158d51ab..1f36f9d08 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -262,6 +262,21 @@ importers:
specifier: ^6.2.0
version: 6.2.0(@types/node@20.17.23)(sass@1.85.1)(stylus@0.64.0)(yaml@2.7.0)
+ packages/e2e-tests/css-treeshake:
+ devDependencies:
+ '@sveltejs/vite-plugin-svelte':
+ specifier: workspace:^
+ version: link:../../vite-plugin-svelte
+ sass:
+ specifier: ^1.85.1
+ version: 1.85.1
+ svelte:
+ specifier: ^5.22.2
+ version: 5.22.2
+ vite:
+ specifier: ^6.2.0
+ version: 6.2.0(@types/node@20.17.23)(sass@1.85.1)(stylus@0.64.0)(yaml@2.7.0)
+
packages/e2e-tests/custom-extensions:
devDependencies:
'@sveltejs/vite-plugin-svelte':