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':