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.9.0

Published

Tree shaking for Svelte components

Readme

▶ Try it in the browser: https://baseballyama.github.io/svelte-shaker/ — an interactive playground that runs the engine entirely client-side (and is itself built with rsvelte + dogfooded through svelte-shaker).

It runs in your app's production build, before the Svelte compiler, and slims each .svelte file by partially evaluating it against how the whole app actually uses it: props that are never passed (or always passed the same value) are folded to their constant, the dead {#if} arms behind them are deleted, those props are dropped from the $props() signature, and the now-pointless attributes are removed at every call site. The Svelte compiler then only sees the code your app can actually reach.

It is sound first: it never changes what the user sees. When it cannot prove a transform is safe, it leaves the code untouched (bails).

See docs/ARCHITECTURE.md for the full design.

Why this exists (and why a JS bundler can't do it)

Design-system components carry lots of props (Button with variant / size / loading / icon / iconPosition / fullWidth / rounded / href …), but any one app uses only a few. The code behind the unused props — template branches, class computation, reactive statements, imports, CSS — is effectively dead for that app, yet it ships anyway.

It cannot be removed after Svelte compiles, because Svelte emits one generic JS module per component, shared by every caller. In that JS the prop values flow through the runtime ($.prop(...)), so loading / variant are not static JS constants — terser/esbuild/Rollup cannot fold if (loading) to if (false), and the single module has no whole-program information to know which props this particular app never uses.

svelte-shaker works one step earlier, on the pre-compile Svelte source, where the prop's value (its default, or the literal at the call site) is still visible and the template structure is intact. It is essentially a whole-program partial evaluator + dead-code eliminator that understands Svelte, driven by every call site in the app.

The CSS differentiator (what a bundler genuinely can't reach)

Given class="btn btn-{variant}" where the app only ever passes variant ∈ {primary, secondary}, the class btn-danger can never exist at runtime. But the class only appears as a runtime string, so:

  • Svelte's own unused-CSS pruning keeps .btn-danger (it can't see inside the interpolation), and
  • Rollup/terser can't touch it either (the class isn't in the JS at all).

svelte-shaker computes the reachable value set of variant, proves the .btn-danger / .btn-ghost rules can never match any element this component renders, and removes those <style> rules — while keeping .btn, .btn-primary, .btn-secondary. This is verified end-to-end in packages/svelte-shaker/tests/css.test.ts.

Install

pnpm add -D svelte-shaker
# or: npm i -D svelte-shaker / yarn add -D svelte-shaker

Requires svelte@^5.

Usage (Vite)

Add the plugin before svelte() so it hands already-slimmed source to the Svelte compiler. It is build-only by design — dev is a pass-through (see Soundness / Limitations).

// vite.config.ts
import { defineConfig } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';
import { shaker } from 'svelte-shaker/vite';

export default defineConfig({
  plugins: [
    // `include` must cover EVERY call site in the app, or prop elimination
    // would be unsound. Defaults to the Vite root.
    shaker({ include: ['src'] }),
    svelte(),
  ],
});

There is also a plain-Rollup plugin (rollup-plugin-svelte-shaker) for non-Vite pipelines; the Vite plugin is preferred for apps.

Options

shaker({
  include: ['src'], // dirs (relative to root) holding every .svelte call site
  level: 2, //  0 | 1 | 2 — default 2 (L0/L1/L1.5 always on; 2 also enables L2).
  monomorphize: true, // L2 tuning; on by default — object overrides maxVariants/minSavings.
  engine: 'auto', // 'auto' (default) | 'js' | 'rust' — see below.
  parser: 'svelte', // 'svelte' (default) | 'rsvelte' — see below.
});

// L2 is ON by default (it is bail-safe and never bloats). Turn it OFF for faster
// builds, or tune it:
shaker({ include: ['src'], level: 1 }); // L2 off (L0/L1/L1.5 only)
shaker({ include: ['src'], monomorphize: { maxVariants: 16 } }); // raise the variant cap

// Engine: the native Rust (WASM) engine runs the whole shake INCLUDING L2 (it
// calls back to JS only for the net-win gate's compiled-size proxy) and is
// differentially tested to be byte-identical to the JS engine, so it only changes
// speed. It is the default ('auto'); force it (or the JS engine) explicitly with:
shaker({ include: ['src'], engine: 'rust' }); // or engine: 'js'

// Opt into the faster rsvelte parser (~1.46x full build, ~2.2x parse).
// Requires the optional peer `@rsvelte/vite-plugin-svelte-native` (install it
// yourself). Soundness is unchanged — it only affects speed and, occasionally,
// shakes a little more. If the native package can't load it THROWS (no silent
// fallback) so the output stays the same on every machine.
shaker({ include: ['src'], parser: 'rsvelte' });

The rsvelte (Rust) parser

By default the engine parses with svelte/compiler. Setting parser: 'rsvelte' swaps in rsvelte's native (Rust) parser, which dominates the shake pipeline (~85% of the time is parsing): on a real 474-component app the full build runs ~1.46x faster (parse alone ~2.2x).

# rsvelte's native parser is an OPTIONAL peer — install it to opt in:
pnpm add -D @rsvelte/vite-plugin-svelte-native
// vite.config.ts
shaker({ include: ['src'], parser: 'rsvelte' });
  • Soundness is parser-independent. The engine reads only UTF-16 start/end offsets, so the chosen parser never changes what is folded — only how fast. The few differences from the svelte/compiler path are cases where rsvelte happens to shake a little more, each still behavior-preserving.
  • No silent fallback. If parser: 'rsvelte' is requested but the native package can't be loaded (not installed, or no prebuilt binary for the platform), the plugin throws rather than quietly using svelte/compiler — a silent fallback would make the same source shake differently depending on whether the optional binary is present, breaking build reproducibility.

See docs/RUST-MIGRATION.md for the design.

What it does

| Level | What it removes | Default | | -------- | -------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | | L0 | Props no call site ever passes → fold to the default, drop from $props(), strip the attribute at call sites | on | | L1 | Props that collapse to one constant app-wide → fold + drop + strip every call site's attribute | on | | L1.5 | 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 — the bundler-can't differentiator | on | | L2 | Per-call-site monomorphization: specialize a component per prop shape (deduped by residual, capped by maxVariants) | on (set level: 1 to disable) |

Folding also reaches template ternaries ({cond ? a : b}) and class-string interpolation when the condition/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 (comments stripped, whitespace normalized) and assert the HTML is identical for every value the app actually passes (packages/svelte-shaker/tests/diff.ts).
  • Conservative bail. When a transform can't be proven safe, the code is left as-is. Whole-component bails: <svelte:options accessors /> / customElement, and any component that escapes as a value (<svelte:component this={X}>, assigned/passed/stored), or is rendered through a barrel/named import (its call sites aren't enumerable). Per-prop bails: spread that could overwrite it, callee ...rest, bind:, a name shadowed by {#each as} / snippet params / {#await then} / let: / {@const}, or used in {@debug}.
  • Side effects preserved. A call-site attribute is only stripped if its value has no side effects; a value's code is removed only when it is provably pure and unused.
  • Whole-program fixpoint. Call sites inside a folded-away {#if} don't count toward a child's prop profile; analysis iterates to a fixpoint so cascades are consistent with what the transform actually deletes.

Limitations

  • Svelte 5 runes only ($props() / $derived / $effect). Svelte 4 (export let / $: / $$props) is out of scope.
  • Needs .svelte source. Libraries shipping compiled JS can't be shaken (the source has to be visible — that's the whole premise). Distribute via svelte-package. Anything it can't resolve is silently passed through.
  • Build only. It runs in vite build, not in dev/HMR — whole-program analysis is fundamentally incompatible with HMR's locality, and L1.5/CSS depend on negative information ("this value never occurs") that a lazily-loaded dev server can't guarantee. Dev is always a pass-through (and is unoptimized but always correct). A dev: 'coarse' mode is a future opt-in.
  • include must cover the whole app. A call site outside the scanned dirs is invisible, so soundness requires every consumer of a prop to be in scope.
  • Partial-bail boundaries. Spread/rest/bind:/shadowing limit how much can be folded (by design — the engine errs toward keeping code). L2's minSavings, and exclude / unsafe / report options, are reserved but not yet implemented.

Running the tests

pnpm --filter svelte-shaker test     # vitest: eval / basic / shadow / probes2 / css / vite / mono
pnpm format:fix && pnpm all:check    # type-check + lint + format

Bench

packages/svelte-shaker/tests/css.test.ts builds a tiny app (App passes variant="primary" and variant="secondary" to Btn, whose <style> defines .btn-{primary,secondary,danger,ghost}) two ways:

  • control (Svelte + Rollup, no shaker): keeps .btn-danger and .btn-ghost in the emitted CSS — the toolchain cannot prove them dead.
  • shaken: removes .btn-danger / .btn-ghost, keeps .btn / .btn-primary / .btn-secondary, and the rendered HTML is identical for both variants the app passes.

That's the headline result: the same source produces strictly smaller CSS with no behavior change.

Architecture & status

The engine is split into an environment-free Engine (Svelte-aware analysis + transform) behind a stable IR, and a thin Shell (the Vite/Rollup plugin) that owns file IO and module resolution — so the core can later be ported to Rust (rsvelte / OXC). The current implementation status (what's done vs. remaining) is tracked in docs/ARCHITECTURE.md §11.

License

MIT