npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

svelte-shaker

v0.18.1

Published

Tree shaking for Svelte components

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