Skip to content

Repository files navigation

svelte-shaker

svelte-shaker

A sound, source-level tree-shaker for Svelte 5 (runes) components.

▶ 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@^5

Nothing 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-16 start/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 explicit parser: 'rsvelte' that @rsvelte/compiler can'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 with monomorphize on 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 (default 8) — 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. a Button used with more than 8 prop shapes app-wide) you know is worth specializing further.
    • minSavings (default 0, 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 in vite dev too. Off by default: dev is a pass-through, which is always correct and keeps HMR simple. Opt in with dev: 'incremental' — re-parses only the changed files and re-runs the whole-program fixpoint over a long-lived incremental engine (the intended mode) — or dev: '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's external, and it never keeps a file out of the bundle or out of the analysis.

    You need it when the consumer lives outside the .svelte graph and the shaker can't observe the call site — a mount() behind a non-literal dynamic import(expr), or a module outside the entries roots. Consumers reached by a static import, export … from, or a literal import('./X.svelte') are found by the plugin's own scan of your non-.svelte modules, so a plain mount(Component, { props }) is already handled for you.

    Each entry is a root-relative or absolute path naming a component file (with its .svelte extension) or a directory of them (same path-prefix basis as entries). 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 preserve entries that matched no component.

  • devOnly — glob patterns (matched with picomatch against 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 .svelte seed scan and the non-.svelte escape scan), so a Foo.test.svelte or a Button.test.ts can 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 devOnly replaces this list (predictable semantics) — spread it to extend: devOnly: [...DEFAULT_DEV_ONLY, 'src/dev/**'] (import DEFAULT_DEV_ONLY from svelte-shaker/vite). Pass devOnly: [] to count every file (the pre-devOnly behavior).

    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.svelte under a route dir named __tests__) has its distinct prop values stop blocking folds, the same failure mode as leaving it out of entries — which is why the defaults are narrow. See docs/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 (like entries, no glob). The resolved Vite build.outDir is 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's build/ (adapter-static): it sits outside build.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 output

    Distinct from devOnly: that marks non-shipping source files (tests, stories) by glob; exclude prunes whole generated-output directories that are not source at all. Like entries, 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-safe build.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 .svelte source — libraries shipping compiled JS pass through unshaken; distribute via svelte-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 dev option.
  • entries must cover the whole app — the crawl starts there, and every .svelte file it finds is a call-site source. A call site outside those roots is invisible, so narrowing entries does not shake less, it shakes wrongly. (Components reached from the roots — including library ones in node_modules — are crawled and shaken without being listed.) Call sites in .ts/.js modules under the roots (e.g. mount(Component, { props })) are scanned and the component's props are kept automatically; a non-literal dynamic import(expr) can't be followed, so reach for preserve there. That scan covers modules under the entries roots only — a library that mounts its own component from its own bundled .js/.ts inside node_modules is not scanned, so list it in preserve (with its resolved path) if you hit that.

See docs/ARCHITECTURE.md for the full design and implementation status.

License

MIT

About

Build-time dead code elimination for Svelte 5 via whole program partial evaluation.

Topics

Resources

Security policy

Stars

24 stars

Watchers

2 watching

Forks

Releases

Contributors

Languages