svelte-shaker
v0.18.1
Published
Tree shaking for Svelte components
Maintainers
Readme
▶ Try it in the browser: https://baseballyama.github.io/svelte-shaker/ — the playground runs the engine entirely client-side.
svelte-shaker runs in your production build, before the Svelte compiler, and
slims each .svelte file by partially evaluating it against how your whole
app actually uses it: props no call site passes (or that always receive the
same value) are folded to their constant, the dead {#if} arms behind them are
deleted, the props are dropped from $props(), the attributes are removed at
every call site, and <style> rules whose class can never be produced are
stripped.
It is sound first: it never changes what renders. When a transform can't be proven safe, the code is left untouched (bails).
Why a JS bundler can't do this
Design-system components carry many props (variant / size / loading / icon …),
but any one app uses only a few — yet the code behind the unused props still
ships. A minifier can fold a component-local constant (it compiles to plain
JS). A prop is different: Svelte emits one generic JS module per component,
shared by every caller, and the prop's value reaches it through runtime
indirection ($.prop(...)), so turning if (loading) into if (false) would
take constant propagation across component boundaries — something neither
Rollup nor terser performs, even when every call site passes the same literal.
svelte-shaker works one step earlier, on the pre-compile source, where
call-site values and template structure are still visible.
The clearest win is CSS: given class="btn btn-{variant}" where the app only
ever passes primary / secondary, the class btn-danger can never exist at
runtime — but it only appears as a runtime string, so neither Svelte's own
unused-CSS pruning nor the bundler can prove that. svelte-shaker computes the
reachable value set of variant and removes the .btn-danger rule.
Install
npm i -D svelte-shaker # requires svelte@^5Nothing else to install. The plugin picks its engine automatically (see
Options): if a native (napi) Rust binary loads it runs there —
parsing with rsvelte in process, by far the fastest; otherwise the shake runs on the
JS engine with svelte/compiler, which needs no prebuilt binary and always
works. Both shake byte-identically, so the fallback costs only build time.
engine and parser let you pin the choice.
Usage (Vite)
Add the plugin before svelte(). By default it runs only in vite build —
dev/HMR is a pass-through (opt into dev shaking with the dev option, see
Options). The engine is chosen automatically (native Rust if a binary
loads, else JS), and every path falls back cleanly (see Options).
// vite.config.ts
import { defineConfig } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';
import { shaker } from 'svelte-shaker/vite';
export default defineConfig({
plugins: [
// `entries` is where the component crawl STARTS, not a file filter. It
// must cover EVERY call site in the app, or prop elimination would be
// unsound. Defaults to the Vite root.
shaker({ entries: ['src'] }),
svelte(),
],
});For plain-Rollup pipelines, wire the shake up yourself with the public engine
API (svelte-shaker) and the file-system helpers in svelte-shaker/node. Note
that monomorphization additionally needs the ?shaker_variant requests routed
through your plugin's resolveId/load hooks; the unused-prop fold / constant
fold / value-set narrowing shake only needs the transform swap. The
environment-free engine and the in-browser playground parse with svelte/compiler
— the Vite plugin's rsvelte selection is a plugin concern (it loads a Node-only
module); the engine takes an optional parse argument if you want to swap it.
Options
shaker({
entries: ['src'], // dirs (relative to root) the crawl starts from; they must
// hold every .svelte call site in the app. Not a glob, not a filter.
preserve: [], // components whose props must never be folded (see below)
devOnly: [...], // glob patterns of files that never ship (tests, stories); they
// stop counting as call sites. Defaults to tests/mocks/stories; replaces, spread
// to extend.
exclude: [], // build-output dirs to skip walking (a SvelteKit adapter's `build/`,
// a `dist/`). The Vite `build.outDir` is always skipped; add other generated
// output here. Not source — see below.
monomorphize: true, // default on; `false` disables it for faster builds,
// or { maxVariants: 16, minSavings: 0.05 } to tune
verbose: false, // true = per-file size breakdown after the build
// Engine is auto-selected; set these only to pin.
engine: 'auto', // 'auto' (native Rust if it loads, else JS) | 'js' | 'rust'
parser: undefined, // JS engine only (native always uses in-process rsvelte);
// defaults to svelte/compiler; set 'rsvelte' to pin rsvelte instead
dev: false, // default off: dev is a pass-through. 'incremental' (re-parse only
// changed files) | 'coarse' (re-analyze everything) opts in; never monomorphizes
});That list is exhaustive: any other key fails the build, naming the key and the
options that do exist. A typo would otherwise be ignored — and a misspelled
preserve ships the component you meant to protect, over-shaken.
engine— which engine runs the shake. There are two. The native (napi Rust) engine parses with rsvelte in process and keeps the ASTs Rust-side, so no whole-program AST crosses a boundary — by far the fastest — but it ships as a per-platform prebuilt binary that may not exist for every install. The JS engine needs no prebuilt binary and always works.'auto'(default) uses the native engine if a binary loads, else the JS engine.'rust'forces the native engine, throwing if it can't be loaded;'js'forces the JS engine. Both are differentially tested to shake byte-identically, so this is speed-only — it never changes what ships.parser— how the JS engine parses.svelte. It does not apply to the native engine, which always parses with rsvelte in process. It defaults to svelte/compiler, because on the JS engine rsvelte's parse is ~2× slower with no downstream benefit. The choice is soundness-neutral — the engine reads only UTF-16start/end, so both parsers are differentially tested to produce byte-identical output, never changing what renders.parser: 'svelte'also forces the native engine off (it can't honor svelte/compiler), so the shake uses the JS engine. With an explicitparser: 'rsvelte'that@rsvelte/compilercan't satisfy, the plugin throws rather than silently swapping (so the same source can't shake differently on another machine);parser: 'svelte'is the opt-out.monomorphize— the one shaking knob, on by default. A measured net-win gate only specializes a component when that strictly shrinks the whole program, so monomorphization never bloats: whatever the knobs are set to, a build withmonomorphizeon is never larger, byte for byte, than the same build with it off (the 3 always-on passes alone). The knobs only trade off how much specialization is attempted against build time:maxVariants(default8) — cap on distinct residual variants per component. A child whose call sites produce more distinct shapes than the cap can't be specialized at every site, so it keeps its base entirely (all-sites-or-nothing — no partial split). Raise it for a large design-system component (e.g. aButtonused with more than 8 prop shapes app-wide) you know is worth specializing further.minSavings(default0, i.e. any strict net reduction) — the net-win threshold: a specialization is applied only when it measuresΣ_spec < Σ_base × (1 − minSavings). Raising it only makes the gate more conservative (fewer, bigger wins, faster builds) — no value makes monomorphization unsound.
monomorphize: { maxVariants: 16, minSavings: 0.05 } // e.g. a variant-heavy // design system, while skipping specializations that save under 5%dev— whether to shake invite devtoo. Off by default: dev is a pass-through, which is always correct and keeps HMR simple. Opt in withdev: 'incremental'— re-parses only the changed files and re-runs the whole-program fixpoint over a long-lived incremental engine (the intended mode) — ordev: 'coarse', which re-analyzes the whole program on every change (the slow but trivially-correct safety valve). Monomorphization is never applied in dev; only the always-on passes (unused-prop fold / constant fold / value-set narrowing) run.preserve— keep a component's prop interface exactly as written, because something the shake can't see passes props to it. What is preserved is the props, not the file's presence in the bundle: this is unrelated to Rollup/Vite'sexternal, and it never keeps a file out of the bundle or out of the analysis.You need it when the consumer lives outside the
.sveltegraph and the shaker can't observe the call site — amount()behind a non-literal dynamicimport(expr), or a module outside theentriesroots. Consumers reached by a static import,export … from, or a literalimport('./X.svelte')are found by the plugin's own scan of your non-.sveltemodules, so a plainmount(Component, { props })is already handled for you.Each entry is a root-relative or absolute path naming a component file (with its
.svelteextension) or a directory of them (same path-prefix basis asentries). The file stays fully analyzed and its own call sites still count toward its children — only that component's own prop folding is turned off. It is not a scan-exclusion filter.When in doubt, list it. Unlike
entries, over-listing errs safe: a component preserved without needing it is just shaken less, never wrongly.The build warns (with the file path) about any module the scan couldn't parse — so a mounted component isn't silently left unprotected — and about
preserveentries that matched no component.devOnly— glob patterns (matched withpicomatchagainst each file's path relative to the Vite root) naming files that never ship in the production bundle — colocated tests, mocks, Storybook stories. A matched file stops counting as a component consumer in both directory scans (the.svelteseed scan and the non-.svelteescape scan), so aFoo.test.svelteor aButton.test.tscan no longer pessimize the shake. It defaults to:// the built-in default (import DEFAULT_DEV_ONLY to extend it) devOnly: ['**/*.test.*', '**/*.spec.*', '**/__tests__/**', '**/__mocks__/**', '**/*.stories.*'];Passing
devOnlyreplaces this list (predictable semantics) — spread it to extend:devOnly: [...DEFAULT_DEV_ONLY, 'src/dev/**'](importDEFAULT_DEV_ONLYfromsvelte-shaker/vite). PassdevOnly: []to count every file (the pre-devOnlybehavior).List only files that never ship. A matched file isn't excluded from the shake — one the app actually imports is still crawled and shaken; it just stops counting as a call site. So a file that really ships but matches a pattern (a
+page.svelteunder a route dir named__tests__) has its distinct prop values stop blocking folds, the same failure mode as leaving it out ofentries— which is why the defaults are narrow. Seedocs/ARCHITECTURE.md§8.1.1 for the full argument.exclude— directories the scans must not walk at all: a compiled, generated tree that is not source. Each entry is a Vite-root-relative or absolute path naming a directory, matched on a plain path-prefix basis (likeentries, no glob). The resolved Vitebuild.outDiris always excluded automatically — it is the destination the build overwrites, so it holds no source the app depends on. Use this option for output dirs the plugin can't infer, most importantly a SvelteKit adapter'sbuild/(adapter-static): it sits outsidebuild.outDir, and left unpruned the escape scan parses megabytes of minified output looking for call sites it can never contain, which can dominate the crawl.shaker({ entries: ['.'], exclude: ['build'] }); // skip adapter-static outputDistinct from
devOnly: that marks non-shipping source files (tests, stories) by glob;excludeprunes whole generated-output directories that are not source at all. Likeentries, over-listing errs unsafe — a pruned directory's call sites stop counting, exactly as if it were outside the crawl — so name only generated output, never source. That is why there is no default beyond the always-safebuild.outDir.
What it removes
| Pass | What it removes | Default |
| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
| unused-prop fold | Props no call site ever passes → fold to the default, drop from $props(), strip the attribute at call sites | on |
| constant fold | Props that collapse to one constant app-wide → fold + drop + strip every call site's attribute | on |
| value-set narrowing | With variant ∈ {primary, secondary}, delete provably-dead {#if}/{:else if} arms (prop stays in the signature) | on |
| CSS | <style> rules whose class can never be produced given the value sets | on |
| monomorphization | Per-call-site: specialize a component per prop shape (deduped by residual, capped by maxVariants) | on (monomorphize: false to disable) |
Folding also reaches template ternaries ({cond ? a : b}) and class-string
interpolation when the parts are provable constants.
Soundness
The whole point is to never change observable behavior.
- Differential-SSR verified — tests server-render the original and the shaken component and assert the HTML is identical for every value the app actually passes.
- Conservative bail — anything unprovable is left as-is. Whole-component:
<svelte:options accessors />/customElement, components that escape as a value, or are imported through a barrel (call sites not enumerable). Per-prop: spread, callee...rest,bind:, shadowing,{@debug}. - Side effects preserved — an attribute or value is only removed when it is provably pure and unused.
- Whole-program fixpoint — call sites inside deleted branches don't count toward a child's prop profile.
Limitations
- Svelte 5 runes only — Svelte 4 (
export let/$:/$$props) is out of scope. - Needs
.sveltesource — libraries shipping compiled JS pass through unshaken; distribute viasvelte-package. - Build-first — whole-program analysis is incompatible with dev/HMR locality,
so dev is a pass-through by default; opt into incremental dev shaking with the
devoption. entriesmust cover the whole app — the crawl starts there, and every.sveltefile it finds is a call-site source. A call site outside those roots is invisible, so narrowingentriesdoes not shake less, it shakes wrongly. (Components reached from the roots — including library ones innode_modules— are crawled and shaken without being listed.) Call sites in.ts/.jsmodules under the roots (e.g.mount(Component, { props })) are scanned and the component's props are kept automatically; a non-literal dynamicimport(expr)can't be followed, so reach forpreservethere. That scan covers modules under theentriesroots only — a library that mounts its own component from its own bundled.js/.tsinsidenode_modulesis not scanned, so list it inpreserve(with its resolved path) if you hit that.
See docs/ARCHITECTURE.md
for the full design and implementation status.
License
MIT
