svelte-shaker 0.12.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +178 -193
- package/dist/analyze.d.ts +100 -10
- package/dist/analyze.js +594 -48
- package/dist/css.d.ts +15 -2
- package/dist/css.js +58 -7
- package/dist/dead.js +87 -2
- package/dist/engine.d.ts +11 -3
- package/dist/engine.js +15 -4
- package/dist/escape-scan.d.ts +50 -0
- package/dist/escape-scan.js +193 -0
- package/dist/eval.d.ts +8 -1
- package/dist/eval.js +16 -2
- package/dist/index.d.ts +8 -8
- package/dist/index.js +35 -47
- package/dist/ir.d.ts +16 -5
- package/dist/mono.d.ts +5 -5
- package/dist/mono.js +26 -24
- package/dist/parse.d.ts +6 -2
- package/dist/reverse.d.ts +48 -0
- package/dist/reverse.js +137 -0
- package/dist/revert-cascade.d.ts +34 -0
- package/dist/revert-cascade.js +95 -0
- package/dist/rsvelte-parse.d.ts +10 -9
- package/dist/rsvelte-parse.js +41 -19
- package/dist/scan.d.ts +1 -0
- package/dist/scan.js +4 -1
- package/dist/svelte_shaker_engine.js +5 -5
- package/dist/svelte_shaker_engine_bg.wasm +0 -0
- package/dist/transform.d.ts +20 -5
- package/dist/transform.js +181 -38
- package/dist/unread.d.ts +17 -0
- package/dist/unread.js +154 -0
- package/dist/vite.d.ts +74 -33
- package/dist/vite.js +193 -43
- package/dist/wasm-engine.d.ts +13 -13
- package/dist/wasm-engine.js +24 -36
- package/package.json +9 -16
package/README.md
CHANGED
|
@@ -4,76 +4,60 @@
|
|
|
4
4
|
|
|
5
5
|
<h1 align="center">svelte-shaker</h1>
|
|
6
6
|
|
|
7
|
-
<p align="center">
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
and the template structure is intact. It is essentially a **whole-program partial
|
|
44
|
-
evaluator + dead-code eliminator that understands Svelte**, driven by every call
|
|
45
|
-
site in the app.
|
|
46
|
-
|
|
47
|
-
### The CSS differentiator (what a bundler genuinely can't reach)
|
|
48
|
-
|
|
49
|
-
Given `class="btn btn-{variant}"` where the app only ever passes
|
|
50
|
-
`variant ∈ {primary, secondary}`, the class `btn-danger` can never exist at
|
|
51
|
-
runtime. But the class only appears as a runtime string, so:
|
|
52
|
-
|
|
53
|
-
- Svelte's own unused-CSS pruning keeps `.btn-danger` (it can't see inside the
|
|
54
|
-
interpolation), and
|
|
55
|
-
- Rollup/terser can't touch it either (the class isn't in the JS at all).
|
|
56
|
-
|
|
57
|
-
svelte-shaker computes the reachable value set of `variant`, proves the
|
|
58
|
-
`.btn-danger` / `.btn-ghost` rules can never match any element this component
|
|
59
|
-
renders, and **removes those `<style>` rules** — while keeping `.btn`,
|
|
60
|
-
`.btn-primary`, `.btn-secondary`. This is verified end-to-end in
|
|
61
|
-
`packages/svelte-shaker/tests/css.test.ts`.
|
|
7
|
+
<p align="center">
|
|
8
|
+
<strong>A sound, source-level tree-shaker for Svelte 5 (runes) components.</strong>
|
|
9
|
+
</p>
|
|
10
|
+
|
|
11
|
+
**▶ Try it in the browser: https://baseballyama.github.io/svelte-shaker/** — the
|
|
12
|
+
playground runs the engine entirely client-side.
|
|
13
|
+
|
|
14
|
+
svelte-shaker runs in your production build, **before** the Svelte compiler, and
|
|
15
|
+
slims each `.svelte` file by partially evaluating it against how your **whole
|
|
16
|
+
app** actually uses it: props no call site passes (or that always receive the
|
|
17
|
+
same value) are folded to their constant, the dead `{#if}` arms behind them are
|
|
18
|
+
deleted, the props are dropped from `$props()`, the attributes are removed at
|
|
19
|
+
every call site, and `<style>` rules whose class can never be produced are
|
|
20
|
+
stripped.
|
|
21
|
+
|
|
22
|
+
It is **sound first**: it never changes what renders. When a transform can't be
|
|
23
|
+
proven safe, the code is left untouched (bails).
|
|
24
|
+
|
|
25
|
+
## Why a JS bundler can't do this
|
|
26
|
+
|
|
27
|
+
Design-system components carry many props (`variant / size / loading / icon …`),
|
|
28
|
+
but any one app uses only a few — yet the code behind the unused props still
|
|
29
|
+
ships. A minifier _can_ fold a component-local constant (it compiles to plain
|
|
30
|
+
JS). A prop is different: Svelte emits **one generic JS module per component**,
|
|
31
|
+
shared by every caller, and the prop's value reaches it through runtime
|
|
32
|
+
indirection (`$.prop(...)`), so turning `if (loading)` into `if (false)` would
|
|
33
|
+
take constant propagation across component boundaries — something neither
|
|
34
|
+
Rollup nor terser performs, even when every call site passes the same literal.
|
|
35
|
+
svelte-shaker works one step earlier, on the pre-compile source, where
|
|
36
|
+
call-site values and template structure are still visible.
|
|
37
|
+
|
|
38
|
+
The clearest win is CSS: given `class="btn btn-{variant}"` where the app only
|
|
39
|
+
ever passes `primary` / `secondary`, the class `btn-danger` can never exist at
|
|
40
|
+
runtime — but it only appears as a runtime string, so neither Svelte's own
|
|
41
|
+
unused-CSS pruning nor the bundler can prove that. svelte-shaker computes the
|
|
42
|
+
reachable value set of `variant` and removes the `.btn-danger` rule.
|
|
62
43
|
|
|
63
44
|
## Install
|
|
64
45
|
|
|
65
46
|
```sh
|
|
66
|
-
|
|
67
|
-
# or: npm i -D svelte-shaker / yarn add -D svelte-shaker
|
|
47
|
+
npm i -D svelte-shaker # requires svelte@^5
|
|
68
48
|
```
|
|
69
49
|
|
|
70
|
-
|
|
50
|
+
Nothing else to install. By default the plugin parses with rsvelte, loaded from
|
|
51
|
+
`@rsvelte/compiler` (a bundled WASM dependency — no peer, no platform-specific
|
|
52
|
+
binary). `parser: 'svelte'` falls back to svelte/compiler if you ever need it
|
|
53
|
+
(see [Options](#options)).
|
|
71
54
|
|
|
72
55
|
## Usage (Vite)
|
|
73
56
|
|
|
74
|
-
Add the plugin **before** `svelte()
|
|
75
|
-
|
|
76
|
-
|
|
57
|
+
Add the plugin **before** `svelte()`. It runs only in `vite build` — dev/HMR is
|
|
58
|
+
a pass-through by design. Out of the box it runs the native **Rust (WASM)
|
|
59
|
+
engine** and parses with **rsvelte**; both fall back cleanly (see
|
|
60
|
+
[Options](#options)).
|
|
77
61
|
|
|
78
62
|
```ts
|
|
79
63
|
// vite.config.ts
|
|
@@ -83,161 +67,162 @@ import { shaker } from 'svelte-shaker/vite';
|
|
|
83
67
|
|
|
84
68
|
export default defineConfig({
|
|
85
69
|
plugins: [
|
|
86
|
-
// `
|
|
87
|
-
//
|
|
88
|
-
|
|
70
|
+
// `entries` is where the component crawl STARTS, not a file filter. It
|
|
71
|
+
// must cover EVERY call site in the app, or prop elimination would be
|
|
72
|
+
// unsound. Defaults to the Vite root.
|
|
73
|
+
shaker({ entries: ['src'] }),
|
|
89
74
|
svelte(),
|
|
90
75
|
],
|
|
91
76
|
});
|
|
92
77
|
```
|
|
93
78
|
|
|
94
|
-
|
|
95
|
-
|
|
79
|
+
For plain-Rollup pipelines, wire the shake up yourself with the public engine
|
|
80
|
+
API (`svelte-shaker`) and the file-system helpers in `svelte-shaker/node`. Note
|
|
81
|
+
that monomorphization additionally needs the `?shaker_variant` requests routed
|
|
82
|
+
through your plugin's `resolveId`/`load` hooks; the unused-prop fold / constant
|
|
83
|
+
fold / value-set narrowing shake only needs the `transform` swap. The
|
|
84
|
+
environment-free engine and the in-browser playground parse with svelte/compiler
|
|
85
|
+
— the rsvelte default is a Vite-plugin concern (it loads a Node-only WASM
|
|
86
|
+
module); the engine takes an optional `parse` argument if you want to swap it.
|
|
96
87
|
|
|
97
88
|
### Options
|
|
98
89
|
|
|
99
90
|
```ts
|
|
100
91
|
shaker({
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
92
|
+
entries: ['src'], // dirs (relative to root) the crawl starts from; they must
|
|
93
|
+
// hold every .svelte call site in the app. Not a glob, not a filter.
|
|
94
|
+
preserve: [], // components whose props must never be folded (see below)
|
|
95
|
+
monomorphize: true, // default on; `false` disables it for faster builds,
|
|
96
|
+
// or { maxVariants: 16, minSavings: 0.05 } to tune
|
|
97
|
+
verbose: false, // true = per-file size breakdown after the build
|
|
98
|
+
|
|
99
|
+
// Escape hatches — the defaults ARE the Rust path; set these only to opt
|
|
100
|
+
// out (e.g. if you ever hit a bug in it).
|
|
101
|
+
engine: 'auto', // 'auto' (default: Rust/WASM, else JS) | 'js' | 'rust'
|
|
102
|
+
parser: 'rsvelte', // 'rsvelte' (default) | 'svelte' (fallback)
|
|
106
103
|
});
|
|
107
|
-
|
|
108
|
-
// L2 is ON by default (it is bail-safe and never bloats). Turn it OFF for faster
|
|
109
|
-
// builds, or tune it:
|
|
110
|
-
shaker({ include: ['src'], level: 1 }); // L2 off (L0/L1/L1.5 only)
|
|
111
|
-
shaker({ include: ['src'], monomorphize: { maxVariants: 16 } }); // raise the variant cap
|
|
112
|
-
|
|
113
|
-
// Engine: the native Rust (WASM) engine runs the whole shake INCLUDING L2 (it
|
|
114
|
-
// calls back to JS only for the net-win gate's compiled-size proxy) and is
|
|
115
|
-
// differentially tested to be byte-identical to the JS engine, so it only changes
|
|
116
|
-
// speed. It is the default ('auto'); force it (or the JS engine) explicitly with:
|
|
117
|
-
shaker({ include: ['src'], engine: 'rust' }); // or engine: 'js'
|
|
118
|
-
|
|
119
|
-
// Opt into the faster rsvelte parser (~1.46x full build, ~2.2x parse).
|
|
120
|
-
// Requires the optional peer `@rsvelte/vite-plugin-svelte-native` (install it
|
|
121
|
-
// yourself). Soundness is unchanged — it only affects speed and, occasionally,
|
|
122
|
-
// shakes a little more. If the native package can't load it THROWS (no silent
|
|
123
|
-
// fallback) so the output stays the same on every machine.
|
|
124
|
-
shaker({ include: ['src'], parser: 'rsvelte' });
|
|
125
|
-
```
|
|
126
|
-
|
|
127
|
-
### The rsvelte (Rust) parser
|
|
128
|
-
|
|
129
|
-
By default the engine parses with `svelte/compiler`. Setting `parser: 'rsvelte'`
|
|
130
|
-
swaps in [rsvelte](https://github.com/rsvelte/rsvelte)'s native (Rust) parser,
|
|
131
|
-
which dominates the shake pipeline (~85% of the time is parsing): on a real
|
|
132
|
-
474-component app the full build runs **~1.46x faster** (parse alone ~2.2x).
|
|
133
|
-
|
|
134
|
-
```sh
|
|
135
|
-
# rsvelte's native parser is an OPTIONAL peer — install it to opt in:
|
|
136
|
-
pnpm add -D @rsvelte/vite-plugin-svelte-native
|
|
137
|
-
```
|
|
138
|
-
|
|
139
|
-
```ts
|
|
140
|
-
// vite.config.ts
|
|
141
|
-
shaker({ include: ['src'], parser: 'rsvelte' });
|
|
142
104
|
```
|
|
143
105
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
- **
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
106
|
+
That list is exhaustive: any other key **fails the build**, naming the key and the
|
|
107
|
+
options that do exist. A typo would otherwise be ignored — and a misspelled
|
|
108
|
+
`preserve` ships the component you meant to protect, over-shaken.
|
|
109
|
+
|
|
110
|
+
- **The defaults are the Rust path.** Out of the box svelte-shaker runs the
|
|
111
|
+
native **Rust (WASM) engine** and parses with **rsvelte**, loaded from
|
|
112
|
+
[`@rsvelte/compiler`](https://github.com/baseballyama/rsvelte) (a bundled WASM
|
|
113
|
+
dependency — nothing to install). The Rust (WASM) engine is differentially
|
|
114
|
+
tested to shake **byte-identically** to the JS engine. The parser choice is
|
|
115
|
+
**soundness-neutral**: the engine reads only UTF-16 `start`/`end`, so
|
|
116
|
+
svelte/compiler and rsvelte are differentially tested to produce
|
|
117
|
+
**SSR-equivalent** output — the choice never changes what renders. rsvelte is
|
|
118
|
+
the default as the Rust parser the pipeline is standardizing on (end-to-end
|
|
119
|
+
Rust with the WASM engine); `parser: 'svelte'` stays available as the
|
|
120
|
+
fallback.
|
|
121
|
+
- **`monomorphize`** — the one shaking knob, **on** by default. A measured
|
|
122
|
+
net-win gate only specializes a component when that strictly shrinks the whole
|
|
123
|
+
program, so monomorphization **never bloats**: whatever the knobs are set to,
|
|
124
|
+
a build with `monomorphize` on is never larger, byte for byte, than the same
|
|
125
|
+
build with it off (the 3 always-on passes alone). The knobs only trade off how
|
|
126
|
+
much specialization is *attempted* against build time:
|
|
127
|
+
- `maxVariants` (default `8`) — cap on distinct residual variants per
|
|
128
|
+
component. A child whose call sites produce more distinct shapes than the
|
|
129
|
+
cap can't be specialized at every site, so it keeps its base entirely
|
|
130
|
+
(all-sites-or-nothing — no partial split). Raise it for a large
|
|
131
|
+
design-system component (e.g. a `Button` used with more than 8 prop shapes
|
|
132
|
+
app-wide) you know is worth specializing further.
|
|
133
|
+
- `minSavings` (default `0`, i.e. any strict net reduction) — the net-win
|
|
134
|
+
threshold: a specialization is applied only when it measures
|
|
135
|
+
`Σ_spec < Σ_base × (1 − minSavings)`. Raising it only makes the gate more
|
|
136
|
+
conservative (fewer, bigger wins, faster builds) — no value makes
|
|
137
|
+
monomorphization unsound.
|
|
138
|
+
|
|
139
|
+
```ts
|
|
140
|
+
monomorphize: { maxVariants: 16, minSavings: 0.05 } // e.g. a variant-heavy
|
|
141
|
+
// design system, while skipping specializations that save under 5%
|
|
142
|
+
```
|
|
143
|
+
- **Escape hatches (`engine` / `parser`).** If you ever hit a bug in the Rust
|
|
144
|
+
path, opt out per axis: `engine: 'js'` forces the JS engine, `parser: 'svelte'`
|
|
145
|
+
forces svelte/compiler (the previous default). `@rsvelte/compiler` is a bundled
|
|
146
|
+
dependency, so the default parser normally just loads; in the unlikely event it
|
|
147
|
+
can't (a broken install), the plugin **throws** rather than silently falling
|
|
148
|
+
back — so the same source always shakes the same on every machine. Reinstall
|
|
149
|
+
dependencies, or set `parser: 'svelte'`.
|
|
150
|
+
- **`preserve`** — keep a component's **prop interface** exactly as written, because
|
|
151
|
+
something the shake can't see passes props to it. What is preserved is the props,
|
|
152
|
+
**not** the file's presence in the bundle: this is unrelated to Rollup/Vite's
|
|
153
|
+
`external`, and it never keeps a file out of the bundle or out of the analysis.
|
|
154
|
+
|
|
155
|
+
You need it when the consumer lives outside the `.svelte` graph and the shaker
|
|
156
|
+
can't observe the call site — a `mount()` behind a **non-literal** dynamic
|
|
157
|
+
`import(expr)`, or a module outside the `entries` roots. Consumers reached by a
|
|
158
|
+
static import, `export … from`, or a **literal** `import('./X.svelte')` are found
|
|
159
|
+
by the plugin's own scan of your non-`.svelte` modules, so a plain
|
|
160
|
+
`mount(Component, { props })` is already handled for you.
|
|
161
|
+
|
|
162
|
+
Each entry is a root-relative or absolute path naming a component file (with its
|
|
163
|
+
`.svelte` extension) or a directory of them (same path-prefix basis as `entries`).
|
|
164
|
+
The file stays fully analyzed and its own call sites still count toward its
|
|
165
|
+
children — only that component's own prop folding is turned off. It is not a
|
|
166
|
+
scan-exclusion filter.
|
|
167
|
+
|
|
168
|
+
**When in doubt, list it.** Unlike `entries`, over-listing errs safe: a component
|
|
169
|
+
preserved without needing it is just shaken less, never wrongly.
|
|
170
|
+
|
|
171
|
+
The build **warns** (with the file path) about any module the scan couldn't
|
|
172
|
+
parse — so a mounted component isn't silently left unprotected — and about
|
|
173
|
+
`preserve` entries that matched no component.
|
|
174
|
+
|
|
175
|
+
## What it removes
|
|
176
|
+
|
|
177
|
+
| Pass | What it removes | Default |
|
|
178
|
+
| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
|
|
179
|
+
| **unused-prop fold** | Props no call site ever passes → fold to the default, drop from `$props()`, strip the attribute at call sites | on |
|
|
180
|
+
| **constant fold** | Props that collapse to one constant app-wide → fold + drop + strip every call site's attribute | on |
|
|
181
|
+
| **value-set narrowing** | With `variant ∈ {primary, secondary}`, delete provably-dead `{#if}`/`{:else if}` arms (prop stays in the signature) | on |
|
|
182
|
+
| **CSS** | `<style>` rules whose class can never be produced given the value sets | on |
|
|
183
|
+
| **monomorphization** | Per-call-site: specialize a component per prop shape (deduped by residual, capped by `maxVariants`) | on (`monomorphize: false` to disable) |
|
|
166
184
|
|
|
167
185
|
Folding also reaches template ternaries (`{cond ? a : b}`) and class-string
|
|
168
|
-
interpolation when the
|
|
186
|
+
interpolation when the parts are provable constants.
|
|
169
187
|
|
|
170
188
|
## Soundness
|
|
171
189
|
|
|
172
190
|
The whole point is to **never change observable behavior**.
|
|
173
191
|
|
|
174
|
-
- **Differential-SSR verified
|
|
175
|
-
component
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
in `{@debug}`.
|
|
186
|
-
- **Side effects preserved.** A call-site attribute is only stripped if its value
|
|
187
|
-
has no side effects; a value's code is removed only when it is provably pure
|
|
188
|
-
and unused.
|
|
189
|
-
- **Whole-program fixpoint.** Call sites inside a folded-away `{#if}` don't count
|
|
190
|
-
toward a child's prop profile; analysis iterates to a fixpoint so cascades are
|
|
191
|
-
consistent with what the transform actually deletes.
|
|
192
|
+
- **Differential-SSR verified** — tests server-render the original and the
|
|
193
|
+
shaken component and assert the HTML is identical for every value the app
|
|
194
|
+
actually passes.
|
|
195
|
+
- **Conservative bail** — anything unprovable is left as-is. Whole-component:
|
|
196
|
+
`<svelte:options accessors />` / `customElement`, components that escape as a
|
|
197
|
+
value, or are imported through a barrel (call sites not enumerable). Per-prop:
|
|
198
|
+
spread, callee `...rest`, `bind:`, shadowing, `{@debug}`.
|
|
199
|
+
- **Side effects preserved** — an attribute or value is only removed when it is
|
|
200
|
+
provably pure and unused.
|
|
201
|
+
- **Whole-program fixpoint** — call sites inside deleted branches don't count
|
|
202
|
+
toward a child's prop profile.
|
|
192
203
|
|
|
193
204
|
## Limitations
|
|
194
205
|
|
|
195
|
-
- **Svelte 5 runes only** (
|
|
196
|
-
|
|
197
|
-
- **Needs `.svelte` source
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
pnpm --filter svelte-shaker test # vitest: eval / basic / shadow / probes2 / css / vite / mono
|
|
216
|
-
pnpm format:fix && pnpm all:check # type-check + lint + format
|
|
217
|
-
```
|
|
218
|
-
|
|
219
|
-
### Bench
|
|
220
|
-
|
|
221
|
-
`packages/svelte-shaker/tests/css.test.ts` builds a tiny app
|
|
222
|
-
(`App` passes `variant="primary"` and `variant="secondary"` to `Btn`, whose
|
|
223
|
-
`<style>` defines `.btn-{primary,secondary,danger,ghost}`) two ways:
|
|
224
|
-
|
|
225
|
-
- **control** (Svelte + Rollup, no shaker): keeps `.btn-danger` and `.btn-ghost`
|
|
226
|
-
in the emitted CSS — the toolchain cannot prove them dead.
|
|
227
|
-
- **shaken**: removes `.btn-danger` / `.btn-ghost`, keeps `.btn` /
|
|
228
|
-
`.btn-primary` / `.btn-secondary`, and the rendered HTML is identical for both
|
|
229
|
-
variants the app passes.
|
|
230
|
-
|
|
231
|
-
That's the headline result: the same source produces strictly smaller CSS with no
|
|
232
|
-
behavior change.
|
|
233
|
-
|
|
234
|
-
## Architecture & status
|
|
235
|
-
|
|
236
|
-
The engine is split into an environment-free **Engine** (Svelte-aware analysis +
|
|
237
|
-
transform) behind a stable IR, and a thin **Shell** (the Vite/Rollup plugin) that
|
|
238
|
-
owns file IO and module resolution — so the core can later be ported to Rust
|
|
239
|
-
(rsvelte / OXC). The current implementation status (what's done vs. remaining) is
|
|
240
|
-
tracked in [`docs/ARCHITECTURE.md` §11](https://github.com/baseballyama/svelte-shaker/blob/main/docs/ARCHITECTURE.md#11-実装状況implementation-status).
|
|
206
|
+
- **Svelte 5 runes only** — Svelte 4 (`export let` / `$:` / `$$props`) is out of
|
|
207
|
+
scope.
|
|
208
|
+
- **Needs `.svelte` source** — libraries shipping compiled JS pass through
|
|
209
|
+
unshaken; distribute via `svelte-package`.
|
|
210
|
+
- **Build only** — whole-program analysis is incompatible with dev/HMR locality,
|
|
211
|
+
so dev is always a pass-through.
|
|
212
|
+
- **`entries` must cover the whole app** — the crawl starts there, and every
|
|
213
|
+
`.svelte` file it finds is a call-site source. A call site outside those roots
|
|
214
|
+
is invisible, so narrowing `entries` does not shake less, it shakes _wrongly_.
|
|
215
|
+
(Components reached _from_ the roots — including library ones in `node_modules`
|
|
216
|
+
— are crawled and shaken without being listed.) Call sites in `.ts`/`.js`
|
|
217
|
+
modules under the roots (e.g. `mount(Component, { props })`) are scanned and the
|
|
218
|
+
component's props are kept automatically; a **non-literal** dynamic `import(expr)`
|
|
219
|
+
can't be followed, so reach for `preserve` there. That scan covers modules
|
|
220
|
+
**under the `entries` roots only** — a library that mounts its own component
|
|
221
|
+
from its own bundled `.js`/`.ts` inside `node_modules` is not scanned, so list
|
|
222
|
+
it in `preserve` (with its resolved path) if you hit that.
|
|
223
|
+
|
|
224
|
+
See [`docs/ARCHITECTURE.md`](https://github.com/baseballyama/svelte-shaker/blob/main/docs/ARCHITECTURE.md)
|
|
225
|
+
for the full design and implementation status.
|
|
241
226
|
|
|
242
227
|
## License
|
|
243
228
|
|
package/dist/analyze.d.ts
CHANGED
|
@@ -8,6 +8,25 @@ export type ReadFile = (id: ComponentId) => Promise<string> | string;
|
|
|
8
8
|
* {@link buildAnalyzeInputSync}. */
|
|
9
9
|
export type ResolveSync = (source: string, importer: ComponentId) => ComponentId | null;
|
|
10
10
|
export type ReadFileSync = (id: ComponentId) => string;
|
|
11
|
+
/**
|
|
12
|
+
* The set of input names a child component can ever OBSERVE at runtime (docs
|
|
13
|
+
* §PR4 reverse analysis). In runes there is no `$$props`/`$$restProps`, so a
|
|
14
|
+
* component reads an input only through its `$props()` destructure:
|
|
15
|
+
* - `{ kind: 'names' }` — a clean, rest-free ObjectPattern `$props()` (or no
|
|
16
|
+
* `$props()` at all, giving the empty set): the child can observe EXACTLY
|
|
17
|
+
* these declared external names, so a call-site input NOT in the set can
|
|
18
|
+
* never be seen and is safe to drop;
|
|
19
|
+
* - `{ kind: 'all' }` — anything we cannot pin down: a `...rest` (captures
|
|
20
|
+
* undeclared inputs), an Identifier/Array binding (`let p = $props()`),
|
|
21
|
+
* more than one `$props()` call, or `$props()` outside a `let <pat> = …`
|
|
22
|
+
* declarator. Then any input might be observed, so nothing is dropped.
|
|
23
|
+
*/
|
|
24
|
+
export type ReachableInputs = {
|
|
25
|
+
kind: 'all';
|
|
26
|
+
} | {
|
|
27
|
+
kind: 'names';
|
|
28
|
+
names: Set<string>;
|
|
29
|
+
};
|
|
11
30
|
/** One declared prop in a `$props()` destructuring. */
|
|
12
31
|
export interface PropDecl {
|
|
13
32
|
/** The EXTERNAL prop name — the destructure KEY (`prop` in `prop: alias`).
|
|
@@ -47,6 +66,22 @@ export interface FileModel {
|
|
|
47
66
|
propsDeclaration?: AnyNode | undefined;
|
|
48
67
|
propsPattern?: AnyNode | undefined;
|
|
49
68
|
hasRestProp: boolean;
|
|
69
|
+
/**
|
|
70
|
+
* The inputs this component can observe (docs §PR4). Drives the reverse pass:
|
|
71
|
+
* a call site of THIS component may drop an input outside {@link
|
|
72
|
+
* ReachableInputs}. Computed syntactically from the `$props()` shape.
|
|
73
|
+
*/
|
|
74
|
+
reachableInputs: ReachableInputs;
|
|
75
|
+
/**
|
|
76
|
+
* EXTERNAL names of props this component DECLARES but never READS (docs §PR7):
|
|
77
|
+
* destructured out of a clean `$props()` yet with zero value-position reference
|
|
78
|
+
* to their local binding anywhere in the instance script or template. Such a
|
|
79
|
+
* prop is invisible to the child, so its call-site attribute is dead and — when
|
|
80
|
+
* safe — the declaration can be dropped. Source-only (independent of the call
|
|
81
|
+
* sites), so it is computed ONCE here, never inside the fixpoint; the transform
|
|
82
|
+
* gates its use on the component's plan not being bailed.
|
|
83
|
+
*/
|
|
84
|
+
unreadDeclaredProps: Set<string>;
|
|
50
85
|
/**
|
|
51
86
|
* Every `<Child .../>` instance THIS component renders, with the child it
|
|
52
87
|
* resolves to and the AST node (so the fixpoint can test whether the site
|
|
@@ -69,6 +104,27 @@ export interface FileModel {
|
|
|
69
104
|
* dropping the prop dangles the reference — we never fold a prop named here.
|
|
70
105
|
*/
|
|
71
106
|
debugNames: Set<string>;
|
|
107
|
+
/**
|
|
108
|
+
* Names the component WRITES TO — reassigns (`p = …`, `p += …`), mutates with
|
|
109
|
+
* `++`/`--`, destructure-assigns (`({ p } = obj)`), or two-way `bind:`s. A
|
|
110
|
+
* written prop is not a constant even when every call site passes the same
|
|
111
|
+
* literal: the write changes it at runtime, so folding it would substitute the
|
|
112
|
+
* literal into the write's target (`"a" = …`, `0++`, `bind:value={"a"}`) —
|
|
113
|
+
* invalid Svelte — and, more importantly, silently change what renders after
|
|
114
|
+
* the write. We never fold such a prop, exactly like a shadowed one.
|
|
115
|
+
*/
|
|
116
|
+
writtenNames: Set<string>;
|
|
117
|
+
/**
|
|
118
|
+
* Owner-local bindings that are provably a single primitive CONSTANT, keyed by
|
|
119
|
+
* the LOCAL name a forwarded call-site expression references (docs §13.1
|
|
120
|
+
* interprocedural pass-through). Merged into the owner's fold env so that
|
|
121
|
+
* `<Child {count}/>` — where `count` is an unmutated `let count = $state(0)` or
|
|
122
|
+
* a `const count = 0` — folds in the child exactly as a call-site literal would,
|
|
123
|
+
* feeding BOTH constant fold and value-set narrowing. A static property of the
|
|
124
|
+
* source (independent of the fixpoint's plans), so it is computed ONCE here.
|
|
125
|
+
* See {@link computeScriptConstEnv} for the (conservative) admission rules.
|
|
126
|
+
*/
|
|
127
|
+
scriptConstEnv: ReadonlyMap<string, Literal>;
|
|
72
128
|
/**
|
|
73
129
|
* Resolved ids of CHILD components this file leaks as a value (escape, docs
|
|
74
130
|
* §4.1) — e.g. `<svelte:component this={Child}>`. `analyze` unions these
|
|
@@ -100,6 +156,14 @@ export interface ExplicitProp {
|
|
|
100
156
|
value: Literal;
|
|
101
157
|
dynamic: boolean;
|
|
102
158
|
afterLastSpread: boolean;
|
|
159
|
+
/**
|
|
160
|
+
* For a `dynamic` write whose value is a single expression (`prop={expr}`, or a
|
|
161
|
+
* known-spread key `{...{prop: expr}}`), the raw expression node — kept so the
|
|
162
|
+
* fixpoint can try to fold it against the OWNING component's constFold env
|
|
163
|
+
* (interprocedural pass-through, docs §13.1). Absent for a literal write, a
|
|
164
|
+
* `bind:` (a two-way write that must never fold), or a multi-part value.
|
|
165
|
+
*/
|
|
166
|
+
expr?: AnyNode | undefined;
|
|
103
167
|
}
|
|
104
168
|
/** How a child component is called at one `<Child .../>` site. */
|
|
105
169
|
export interface CallSite {
|
|
@@ -112,6 +176,13 @@ export interface CallSite {
|
|
|
112
176
|
hadSpread: boolean;
|
|
113
177
|
/** Last-write-wins explicit props at this site, keyed by prop name. */
|
|
114
178
|
explicit: Map<string, ExplicitProp>;
|
|
179
|
+
/**
|
|
180
|
+
* The component that OWNS this call site (renders the `<Child .../>`). The
|
|
181
|
+
* fixpoint uses it to evaluate a forwarded expression (`prop={ownerProp}`)
|
|
182
|
+
* against the owner's fold env — interprocedural pass-through (docs §13.1).
|
|
183
|
+
* `undefined` for callers that read a site outside the graph fixpoint (mono).
|
|
184
|
+
*/
|
|
185
|
+
owner?: ComponentId | undefined;
|
|
115
186
|
}
|
|
116
187
|
export interface AnalyzeResult {
|
|
117
188
|
models: Map<ComponentId, FileModel>;
|
|
@@ -129,7 +200,7 @@ export interface AnalyzeResult {
|
|
|
129
200
|
* (b) recomputing plans (and hence dead spans) from that usage,
|
|
130
201
|
* until the plans stop changing.
|
|
131
202
|
*/
|
|
132
|
-
export declare function analyze(entries: ComponentId | ComponentId[], resolve: Resolve, readFile: ReadFile): Promise<AnalyzeResult>;
|
|
203
|
+
export declare function analyze(entries: ComponentId | ComponentId[], resolve: Resolve, readFile: ReadFile, escaped?: ComponentId[]): Promise<AnalyzeResult>;
|
|
133
204
|
/**
|
|
134
205
|
* The pure, environment-free engine entry (docs/RUST-MIGRATION.md §2): given a
|
|
135
206
|
* fully-resolved, batched {@link AnalyzeInput}, build every component's model and
|
|
@@ -139,6 +210,18 @@ export declare function analyze(entries: ComponentId | ComponentId[], resolve: R
|
|
|
139
210
|
* one batched call in, plans out, no per-edge callback across the boundary.
|
|
140
211
|
*/
|
|
141
212
|
export declare function analyzeInput(input: AnalyzeInput, parseCache?: ParseCache): AnalyzeResult;
|
|
213
|
+
/**
|
|
214
|
+
* Compute every component's plan to a whole-program fixpoint (docs §2.1) from the
|
|
215
|
+
* models' current `bailReasons` (escape stamps, and — on a revert re-run — the
|
|
216
|
+
* cascade's force-bail stamps). Extracted so the revert cascade can RECOMPUTE the
|
|
217
|
+
* whole fixpoint after force-bailing a component, not just patch that one plan:
|
|
218
|
+
* with interprocedural pass-through (docs §13.1) a child's fold can depend on an
|
|
219
|
+
* owner's fold, so force-bailing the owner must un-fold the child too — an
|
|
220
|
+
* in-place patch of only the owner's plan would leave the child's drop stale
|
|
221
|
+
* (unsound). This mirrors the Rust engine, which re-runs `run_fixpoint` after
|
|
222
|
+
* stamping `forceBail` onto the models.
|
|
223
|
+
*/
|
|
224
|
+
export declare function planFixpoint(models: Map<ComponentId, FileModel>): Map<ComponentId, ComponentPlan>;
|
|
142
225
|
/**
|
|
143
226
|
* The Shell-side resolution + IO layer (docs/RUST-MIGRATION.md §2.1): BFS-crawl
|
|
144
227
|
* the component graph from `entries`, resolving every import edge and reading
|
|
@@ -152,14 +235,14 @@ export declare function analyzeInput(input: AnalyzeInput, parseCache?: ParseCach
|
|
|
152
235
|
* import is never crawled — its `<Comp/>` site cannot exist, so it cannot taint a
|
|
153
236
|
* value set), keeping the produced model set — and thus the output — identical.
|
|
154
237
|
*/
|
|
155
|
-
export declare function buildAnalyzeInput(entries: ComponentId | ComponentId[], resolve: Resolve, readFile: ReadFile, parseCache?: ParseCache, parse?: Parse): Promise<AnalyzeInput>;
|
|
238
|
+
export declare function buildAnalyzeInput(entries: ComponentId | ComponentId[], resolve: Resolve, readFile: ReadFile, parseCache?: ParseCache, parse?: Parse, escaped?: ComponentId[]): Promise<AnalyzeInput>;
|
|
156
239
|
/**
|
|
157
240
|
* Synchronous twin of {@link buildAnalyzeInput} for callers that cannot await
|
|
158
241
|
* (an ESLint rule runs synchronously). Byte-for-byte the same crawl with sync
|
|
159
242
|
* `resolve`/`readFile`; the `tests/build-analyze-input-sync` differential test
|
|
160
243
|
* pins it identical to the async path, so keep the two bodies in lockstep.
|
|
161
244
|
*/
|
|
162
|
-
export declare function buildAnalyzeInputSync(entries: ComponentId | ComponentId[], resolve: ResolveSync, readFile: ReadFileSync, parseCache?: ParseCache, parse?: Parse): AnalyzeInput;
|
|
245
|
+
export declare function buildAnalyzeInputSync(entries: ComponentId | ComponentId[], resolve: ResolveSync, readFile: ReadFileSync, parseCache?: ParseCache, parse?: Parse, escaped?: ComponentId[]): AnalyzeInput;
|
|
163
246
|
/**
|
|
164
247
|
* Dead `{#if}` spans per component implied by `plans`, via the SAME shared
|
|
165
248
|
* predicate the transform uses ({@link computeDeadSpans}). A bailed component
|
|
@@ -190,11 +273,16 @@ export interface UnpassedProp {
|
|
|
190
273
|
* `+page.svelte`'s `data`, a framework mount, a not-yet-rendered component);
|
|
191
274
|
* - a prop is reported only when EVERY call site neither names it nor carries a
|
|
192
275
|
* spread that could set it (`readCallSite` already folds `bind:`, known
|
|
193
|
-
* spreads, and `children`/snippet body into `explicit`/`hadSpread`)
|
|
276
|
+
* spreads, and `children`/snippet body into `explicit`/`hadSpread`);
|
|
277
|
+
* - a component in `input.escaped` — one the Shell knows has a consumer OUTSIDE
|
|
278
|
+
* the `.svelte` graph (a `.ts`/`.js` call site, or a user `preserve`, docs
|
|
279
|
+
* §4.2) — is skipped, because that consumer may pass a prop the crawl cannot see.
|
|
194
280
|
*
|
|
195
|
-
*
|
|
196
|
-
* can only make this UNDER-report (the component looks unused and is skipped)
|
|
197
|
-
*
|
|
281
|
+
* Missing a `.svelte` EDGE (e.g. an unfollowed barrel) only DROPS call sites, so it
|
|
282
|
+
* can only make this UNDER-report (the component looks unused and is skipped). The
|
|
283
|
+
* one way it could OVER-report is a consumer the crawl cannot parse at all — a
|
|
284
|
+
* `.ts`/`.js` call site; `input.escaped` (the Shell's non-`.svelte` scan) closes
|
|
285
|
+
* exactly that hole, so with it supplied the result stays false-positive-free.
|
|
198
286
|
*/
|
|
199
287
|
export declare function findNeverPassedProps(input: AnalyzeInput): Map<ComponentId, UnpassedProp[]>;
|
|
200
288
|
/**
|
|
@@ -218,7 +306,7 @@ export declare function remapToLocalNames<V>(map: Map<string, V>, model: FileMod
|
|
|
218
306
|
* both contributes those literals AND does not poison props it cannot set (docs
|
|
219
307
|
* §4.1, "{...obj} が object literal ならキー展開").
|
|
220
308
|
*/
|
|
221
|
-
export declare function readCallSite(component: AnyNode): CallSite;
|
|
309
|
+
export declare function readCallSite(component: AnyNode, owner?: ComponentId): CallSite;
|
|
222
310
|
/** Decide what to fold for one component from its global usage. */
|
|
223
311
|
/**
|
|
224
312
|
* Whether a declared prop name is unsafe to fold/narrow/drop because it is also
|
|
@@ -226,7 +314,9 @@ export declare function readCallSite(component: AnyNode): CallSite;
|
|
|
226
314
|
* (`{#each as}`, snippet params, `{#await then}`, `let:`, `{@const}`), or used as
|
|
227
315
|
* a `{@debug}` argument (Svelte forbids a literal there). In those scopes the
|
|
228
316
|
* name is a different entity, so folding it would corrupt the binding (often
|
|
229
|
-
* invalid Svelte)
|
|
230
|
-
*
|
|
317
|
+
* invalid Svelte) — or WRITTEN TO (reassigned / `++` / destructure-assigned /
|
|
318
|
+
* `bind:`), in which case it is not a constant and folding it changes what
|
|
319
|
+
* renders after the write. Both constant fold planning ({@link buildPlan}) and
|
|
320
|
+
* monomorphization specialization (mono.ts) must honor this identically.
|
|
231
321
|
*/
|
|
232
322
|
export declare function isFoldBlockedName(model: FileModel, name: string): boolean;
|