svelte-shaker 0.12.0 → 0.13.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 +154 -189
- package/dist/analyze.d.ts +89 -10
- package/dist/analyze.js +374 -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 +45 -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 +2 -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 +164 -38
- package/dist/unread.d.ts +17 -0
- package/dist/unread.js +154 -0
- package/dist/vite.d.ts +44 -28
- package/dist/vite.js +125 -38
- 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
|
|
@@ -91,153 +75,134 @@ export default defineConfig({
|
|
|
91
75
|
});
|
|
92
76
|
```
|
|
93
77
|
|
|
94
|
-
|
|
95
|
-
|
|
78
|
+
For plain-Rollup pipelines, wire the shake up yourself with the public engine
|
|
79
|
+
API (`svelte-shaker`) and the file-system helpers in `svelte-shaker/node`. Note
|
|
80
|
+
that monomorphization additionally needs the `?shaker_variant` requests routed
|
|
81
|
+
through your plugin's `resolveId`/`load` hooks; the unused-prop fold / constant
|
|
82
|
+
fold / value-set narrowing shake only needs the `transform` swap. The
|
|
83
|
+
environment-free engine and the in-browser playground parse with svelte/compiler
|
|
84
|
+
— the rsvelte default is a Vite-plugin concern (it loads a Node-only WASM
|
|
85
|
+
module); the engine takes an optional `parse` argument if you want to swap it.
|
|
96
86
|
|
|
97
87
|
### Options
|
|
98
88
|
|
|
99
89
|
```ts
|
|
100
90
|
shaker({
|
|
101
91
|
include: ['src'], // dirs (relative to root) holding every .svelte call site
|
|
102
|
-
|
|
103
|
-
monomorphize: true, //
|
|
104
|
-
|
|
105
|
-
|
|
92
|
+
external: [], // components to freeze — never fold their props (see below)
|
|
93
|
+
monomorphize: true, // default on; `false` disables it for faster builds,
|
|
94
|
+
// or { maxVariants: 16, minSavings: 0.05 } to tune
|
|
95
|
+
verbose: false, // true = per-file size breakdown after the build
|
|
96
|
+
|
|
97
|
+
// Escape hatches — the defaults ARE the Rust path; set these only to opt
|
|
98
|
+
// out (e.g. if you ever hit a bug in it).
|
|
99
|
+
engine: 'auto', // 'auto' (default: Rust/WASM, else JS) | 'js' | 'rust'
|
|
100
|
+
parser: 'rsvelte', // 'rsvelte' (default) | 'svelte' (fallback)
|
|
106
101
|
});
|
|
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
102
|
```
|
|
143
103
|
|
|
144
|
-
- **
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
104
|
+
- **The defaults are the Rust path.** Out of the box svelte-shaker runs the
|
|
105
|
+
native **Rust (WASM) engine** and parses with **rsvelte**, loaded from
|
|
106
|
+
[`@rsvelte/compiler`](https://github.com/baseballyama/rsvelte) (a bundled WASM
|
|
107
|
+
dependency — nothing to install). The Rust (WASM) engine is differentially
|
|
108
|
+
tested to shake **byte-identically** to the JS engine. The parser choice is
|
|
109
|
+
**soundness-neutral**: the engine reads only UTF-16 `start`/`end`, so
|
|
110
|
+
svelte/compiler and rsvelte are differentially tested to produce
|
|
111
|
+
**SSR-equivalent** output — the choice never changes what renders. rsvelte is
|
|
112
|
+
the default as the Rust parser the pipeline is standardizing on (end-to-end
|
|
113
|
+
Rust with the WASM engine); `parser: 'svelte'` stays available as the
|
|
114
|
+
fallback.
|
|
115
|
+
- **`monomorphize`** — the one shaking knob, **on** by default. A measured
|
|
116
|
+
net-win gate only specializes a component when that strictly shrinks the whole
|
|
117
|
+
program, so monomorphization **never bloats**: whatever the knobs are set to,
|
|
118
|
+
a build with `monomorphize` on is never larger, byte for byte, than the same
|
|
119
|
+
build with it off (the 3 always-on passes alone). The knobs only trade off how
|
|
120
|
+
much specialization is *attempted* against build time:
|
|
121
|
+
- `maxVariants` (default `8`) — cap on distinct residual variants per
|
|
122
|
+
component. A child whose call sites produce more distinct shapes than the
|
|
123
|
+
cap can't be specialized at every site, so it keeps its base entirely
|
|
124
|
+
(all-sites-or-nothing — no partial split). Raise it for a large
|
|
125
|
+
design-system component (e.g. a `Button` used with more than 8 prop shapes
|
|
126
|
+
app-wide) you know is worth specializing further.
|
|
127
|
+
- `minSavings` (default `0`, i.e. any strict net reduction) — the net-win
|
|
128
|
+
threshold: a specialization is applied only when it measures
|
|
129
|
+
`Σ_spec < Σ_base × (1 − minSavings)`. Raising it only makes the gate more
|
|
130
|
+
conservative (fewer, bigger wins, faster builds) — no value makes
|
|
131
|
+
monomorphization unsound.
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
monomorphize: { maxVariants: 16, minSavings: 0.05 } // e.g. a variant-heavy
|
|
135
|
+
// design system, while skipping specializations that save under 5%
|
|
136
|
+
```
|
|
137
|
+
- **Escape hatches (`engine` / `parser`).** If you ever hit a bug in the Rust
|
|
138
|
+
path, opt out per axis: `engine: 'js'` forces the JS engine, `parser: 'svelte'`
|
|
139
|
+
forces svelte/compiler (the previous default). `@rsvelte/compiler` is a bundled
|
|
140
|
+
dependency, so the default parser normally just loads; in the unlikely event it
|
|
141
|
+
can't (a broken install), the plugin **throws** rather than silently falling
|
|
142
|
+
back — so the same source always shakes the same on every machine. Reinstall
|
|
143
|
+
dependencies, or set `parser: 'svelte'`.
|
|
144
|
+
- **`external`** — freeze components a `.ts`/`.js` module uses in a way the shake
|
|
145
|
+
can't see, so their props are never folded. The plugin already scans your
|
|
146
|
+
non-`.svelte` modules and auto-freezes any component reached by a static import,
|
|
147
|
+
`export … from`, or a **literal** `import('./X.svelte')` — so a plain
|
|
148
|
+
`mount(Component, { props })` is handled for you. Use `external` for what the
|
|
149
|
+
scan can't follow: a **non-literal** dynamic `import(expr)`, or a call site in a
|
|
150
|
+
module outside `include`. Entries are root-relative or absolute paths naming a
|
|
151
|
+
component file (with its `.svelte` extension) or a directory of them (same basis
|
|
152
|
+
as `include`). It **freezes** the component — the file stays fully analyzed and
|
|
153
|
+
its own call sites still count; only its own prop folding is turned off. It is not
|
|
154
|
+
a scan-exclusion filter. The build **warns** (with the file path) about any module
|
|
155
|
+
the scan couldn't parse — so a mounted component isn't silently left unprotected —
|
|
156
|
+
and about `external` entries that matched no component.
|
|
157
|
+
|
|
158
|
+
## What it removes
|
|
159
|
+
|
|
160
|
+
| Pass | What it removes | Default |
|
|
161
|
+
| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
|
|
162
|
+
| **unused-prop fold** | Props no call site ever passes → fold to the default, drop from `$props()`, strip the attribute at call sites | on |
|
|
163
|
+
| **constant fold** | Props that collapse to one constant app-wide → fold + drop + strip every call site's attribute | on |
|
|
164
|
+
| **value-set narrowing** | With `variant ∈ {primary, secondary}`, delete provably-dead `{#if}`/`{:else if}` arms (prop stays in the signature) | on |
|
|
165
|
+
| **CSS** | `<style>` rules whose class can never be produced given the value sets | on |
|
|
166
|
+
| **monomorphization** | Per-call-site: specialize a component per prop shape (deduped by residual, capped by `maxVariants`) | on (`monomorphize: false` to disable) |
|
|
166
167
|
|
|
167
168
|
Folding also reaches template ternaries (`{cond ? a : b}`) and class-string
|
|
168
|
-
interpolation when the
|
|
169
|
+
interpolation when the parts are provable constants.
|
|
169
170
|
|
|
170
171
|
## Soundness
|
|
171
172
|
|
|
172
173
|
The whole point is to **never change observable behavior**.
|
|
173
174
|
|
|
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.
|
|
175
|
+
- **Differential-SSR verified** — tests server-render the original and the
|
|
176
|
+
shaken component and assert the HTML is identical for every value the app
|
|
177
|
+
actually passes.
|
|
178
|
+
- **Conservative bail** — anything unprovable is left as-is. Whole-component:
|
|
179
|
+
`<svelte:options accessors />` / `customElement`, components that escape as a
|
|
180
|
+
value, or are imported through a barrel (call sites not enumerable). Per-prop:
|
|
181
|
+
spread, callee `...rest`, `bind:`, shadowing, `{@debug}`.
|
|
182
|
+
- **Side effects preserved** — an attribute or value is only removed when it is
|
|
183
|
+
provably pure and unused.
|
|
184
|
+
- **Whole-program fixpoint** — call sites inside deleted branches don't count
|
|
185
|
+
toward a child's prop profile.
|
|
192
186
|
|
|
193
187
|
## Limitations
|
|
194
188
|
|
|
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
|
-
## Running the tests
|
|
213
|
-
|
|
214
|
-
```sh
|
|
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).
|
|
189
|
+
- **Svelte 5 runes only** — Svelte 4 (`export let` / `$:` / `$$props`) is out of
|
|
190
|
+
scope.
|
|
191
|
+
- **Needs `.svelte` source** — libraries shipping compiled JS pass through
|
|
192
|
+
unshaken; distribute via `svelte-package`.
|
|
193
|
+
- **Build only** — whole-program analysis is incompatible with dev/HMR locality,
|
|
194
|
+
so dev is always a pass-through.
|
|
195
|
+
- **`include` must cover the whole app** — a call site outside the scanned dirs
|
|
196
|
+
is invisible, which would make prop elimination unsound. Call sites in
|
|
197
|
+
`.ts`/`.js` modules under `include` (e.g. `mount(Component, { props })`) are
|
|
198
|
+
scanned and the component is frozen automatically; a **non-literal** dynamic
|
|
199
|
+
`import(expr)` can't be followed, so reach for `external` there. The scan covers
|
|
200
|
+
modules **under `include`** — a library that mounts its own component from its own
|
|
201
|
+
bundled `.js`/`.ts` inside `node_modules` is not scanned, so freeze it via
|
|
202
|
+
`external` (with its resolved path) if you hit that.
|
|
203
|
+
|
|
204
|
+
See [`docs/ARCHITECTURE.md`](https://github.com/baseballyama/svelte-shaker/blob/main/docs/ARCHITECTURE.md)
|
|
205
|
+
for the full design and implementation status.
|
|
241
206
|
|
|
242
207
|
## License
|
|
243
208
|
|
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,16 @@ 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>;
|
|
72
117
|
/**
|
|
73
118
|
* Resolved ids of CHILD components this file leaks as a value (escape, docs
|
|
74
119
|
* §4.1) — e.g. `<svelte:component this={Child}>`. `analyze` unions these
|
|
@@ -100,6 +145,14 @@ export interface ExplicitProp {
|
|
|
100
145
|
value: Literal;
|
|
101
146
|
dynamic: boolean;
|
|
102
147
|
afterLastSpread: boolean;
|
|
148
|
+
/**
|
|
149
|
+
* For a `dynamic` write whose value is a single expression (`prop={expr}`, or a
|
|
150
|
+
* known-spread key `{...{prop: expr}}`), the raw expression node — kept so the
|
|
151
|
+
* fixpoint can try to fold it against the OWNING component's constFold env
|
|
152
|
+
* (interprocedural pass-through, docs §13.1). Absent for a literal write, a
|
|
153
|
+
* `bind:` (a two-way write that must never fold), or a multi-part value.
|
|
154
|
+
*/
|
|
155
|
+
expr?: AnyNode | undefined;
|
|
103
156
|
}
|
|
104
157
|
/** How a child component is called at one `<Child .../>` site. */
|
|
105
158
|
export interface CallSite {
|
|
@@ -112,6 +165,13 @@ export interface CallSite {
|
|
|
112
165
|
hadSpread: boolean;
|
|
113
166
|
/** Last-write-wins explicit props at this site, keyed by prop name. */
|
|
114
167
|
explicit: Map<string, ExplicitProp>;
|
|
168
|
+
/**
|
|
169
|
+
* The component that OWNS this call site (renders the `<Child .../>`). The
|
|
170
|
+
* fixpoint uses it to evaluate a forwarded expression (`prop={ownerProp}`)
|
|
171
|
+
* against the owner's fold env — interprocedural pass-through (docs §13.1).
|
|
172
|
+
* `undefined` for callers that read a site outside the graph fixpoint (mono).
|
|
173
|
+
*/
|
|
174
|
+
owner?: ComponentId | undefined;
|
|
115
175
|
}
|
|
116
176
|
export interface AnalyzeResult {
|
|
117
177
|
models: Map<ComponentId, FileModel>;
|
|
@@ -129,7 +189,7 @@ export interface AnalyzeResult {
|
|
|
129
189
|
* (b) recomputing plans (and hence dead spans) from that usage,
|
|
130
190
|
* until the plans stop changing.
|
|
131
191
|
*/
|
|
132
|
-
export declare function analyze(entries: ComponentId | ComponentId[], resolve: Resolve, readFile: ReadFile): Promise<AnalyzeResult>;
|
|
192
|
+
export declare function analyze(entries: ComponentId | ComponentId[], resolve: Resolve, readFile: ReadFile, escaped?: ComponentId[]): Promise<AnalyzeResult>;
|
|
133
193
|
/**
|
|
134
194
|
* The pure, environment-free engine entry (docs/RUST-MIGRATION.md §2): given a
|
|
135
195
|
* fully-resolved, batched {@link AnalyzeInput}, build every component's model and
|
|
@@ -139,6 +199,18 @@ export declare function analyze(entries: ComponentId | ComponentId[], resolve: R
|
|
|
139
199
|
* one batched call in, plans out, no per-edge callback across the boundary.
|
|
140
200
|
*/
|
|
141
201
|
export declare function analyzeInput(input: AnalyzeInput, parseCache?: ParseCache): AnalyzeResult;
|
|
202
|
+
/**
|
|
203
|
+
* Compute every component's plan to a whole-program fixpoint (docs §2.1) from the
|
|
204
|
+
* models' current `bailReasons` (escape stamps, and — on a revert re-run — the
|
|
205
|
+
* cascade's force-bail stamps). Extracted so the revert cascade can RECOMPUTE the
|
|
206
|
+
* whole fixpoint after force-bailing a component, not just patch that one plan:
|
|
207
|
+
* with interprocedural pass-through (docs §13.1) a child's fold can depend on an
|
|
208
|
+
* owner's fold, so force-bailing the owner must un-fold the child too — an
|
|
209
|
+
* in-place patch of only the owner's plan would leave the child's drop stale
|
|
210
|
+
* (unsound). This mirrors the Rust engine, which re-runs `run_fixpoint` after
|
|
211
|
+
* stamping `forceBail` onto the models.
|
|
212
|
+
*/
|
|
213
|
+
export declare function planFixpoint(models: Map<ComponentId, FileModel>): Map<ComponentId, ComponentPlan>;
|
|
142
214
|
/**
|
|
143
215
|
* The Shell-side resolution + IO layer (docs/RUST-MIGRATION.md §2.1): BFS-crawl
|
|
144
216
|
* the component graph from `entries`, resolving every import edge and reading
|
|
@@ -152,14 +224,14 @@ export declare function analyzeInput(input: AnalyzeInput, parseCache?: ParseCach
|
|
|
152
224
|
* import is never crawled — its `<Comp/>` site cannot exist, so it cannot taint a
|
|
153
225
|
* value set), keeping the produced model set — and thus the output — identical.
|
|
154
226
|
*/
|
|
155
|
-
export declare function buildAnalyzeInput(entries: ComponentId | ComponentId[], resolve: Resolve, readFile: ReadFile, parseCache?: ParseCache, parse?: Parse): Promise<AnalyzeInput>;
|
|
227
|
+
export declare function buildAnalyzeInput(entries: ComponentId | ComponentId[], resolve: Resolve, readFile: ReadFile, parseCache?: ParseCache, parse?: Parse, escaped?: ComponentId[]): Promise<AnalyzeInput>;
|
|
156
228
|
/**
|
|
157
229
|
* Synchronous twin of {@link buildAnalyzeInput} for callers that cannot await
|
|
158
230
|
* (an ESLint rule runs synchronously). Byte-for-byte the same crawl with sync
|
|
159
231
|
* `resolve`/`readFile`; the `tests/build-analyze-input-sync` differential test
|
|
160
232
|
* pins it identical to the async path, so keep the two bodies in lockstep.
|
|
161
233
|
*/
|
|
162
|
-
export declare function buildAnalyzeInputSync(entries: ComponentId | ComponentId[], resolve: ResolveSync, readFile: ReadFileSync, parseCache?: ParseCache, parse?: Parse): AnalyzeInput;
|
|
234
|
+
export declare function buildAnalyzeInputSync(entries: ComponentId | ComponentId[], resolve: ResolveSync, readFile: ReadFileSync, parseCache?: ParseCache, parse?: Parse, escaped?: ComponentId[]): AnalyzeInput;
|
|
163
235
|
/**
|
|
164
236
|
* Dead `{#if}` spans per component implied by `plans`, via the SAME shared
|
|
165
237
|
* predicate the transform uses ({@link computeDeadSpans}). A bailed component
|
|
@@ -190,11 +262,16 @@ export interface UnpassedProp {
|
|
|
190
262
|
* `+page.svelte`'s `data`, a framework mount, a not-yet-rendered component);
|
|
191
263
|
* - a prop is reported only when EVERY call site neither names it nor carries a
|
|
192
264
|
* spread that could set it (`readCallSite` already folds `bind:`, known
|
|
193
|
-
* spreads, and `children`/snippet body into `explicit`/`hadSpread`)
|
|
265
|
+
* spreads, and `children`/snippet body into `explicit`/`hadSpread`);
|
|
266
|
+
* - a component in `input.escaped` — one the Shell knows has a consumer OUTSIDE
|
|
267
|
+
* the `.svelte` graph (a `.ts`/`.js` call site, or a user `external`, docs
|
|
268
|
+
* §4.2) — is skipped, because that consumer may pass a prop the crawl cannot see.
|
|
194
269
|
*
|
|
195
|
-
*
|
|
196
|
-
* can only make this UNDER-report (the component looks unused and is skipped)
|
|
197
|
-
*
|
|
270
|
+
* Missing a `.svelte` EDGE (e.g. an unfollowed barrel) only DROPS call sites, so it
|
|
271
|
+
* can only make this UNDER-report (the component looks unused and is skipped). The
|
|
272
|
+
* one way it could OVER-report is a consumer the crawl cannot parse at all — a
|
|
273
|
+
* `.ts`/`.js` call site; `input.escaped` (the Shell's non-`.svelte` scan) closes
|
|
274
|
+
* exactly that hole, so with it supplied the result stays false-positive-free.
|
|
198
275
|
*/
|
|
199
276
|
export declare function findNeverPassedProps(input: AnalyzeInput): Map<ComponentId, UnpassedProp[]>;
|
|
200
277
|
/**
|
|
@@ -218,7 +295,7 @@ export declare function remapToLocalNames<V>(map: Map<string, V>, model: FileMod
|
|
|
218
295
|
* both contributes those literals AND does not poison props it cannot set (docs
|
|
219
296
|
* §4.1, "{...obj} が object literal ならキー展開").
|
|
220
297
|
*/
|
|
221
|
-
export declare function readCallSite(component: AnyNode): CallSite;
|
|
298
|
+
export declare function readCallSite(component: AnyNode, owner?: ComponentId): CallSite;
|
|
222
299
|
/** Decide what to fold for one component from its global usage. */
|
|
223
300
|
/**
|
|
224
301
|
* Whether a declared prop name is unsafe to fold/narrow/drop because it is also
|
|
@@ -226,7 +303,9 @@ export declare function readCallSite(component: AnyNode): CallSite;
|
|
|
226
303
|
* (`{#each as}`, snippet params, `{#await then}`, `let:`, `{@const}`), or used as
|
|
227
304
|
* a `{@debug}` argument (Svelte forbids a literal there). In those scopes the
|
|
228
305
|
* name is a different entity, so folding it would corrupt the binding (often
|
|
229
|
-
* invalid Svelte)
|
|
230
|
-
*
|
|
306
|
+
* invalid Svelte) — or WRITTEN TO (reassigned / `++` / destructure-assigned /
|
|
307
|
+
* `bind:`), in which case it is not a constant and folding it changes what
|
|
308
|
+
* renders after the write. Both constant fold planning ({@link buildPlan}) and
|
|
309
|
+
* monomorphization specialization (mono.ts) must honor this identically.
|
|
231
310
|
*/
|
|
232
311
|
export declare function isFoldBlockedName(model: FileModel, name: string): boolean;
|