svelte-shaker 0.13.0 → 0.14.1

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 CHANGED
@@ -67,9 +67,10 @@ import { shaker } from 'svelte-shaker/vite';
67
67
 
68
68
  export default defineConfig({
69
69
  plugins: [
70
- // `include` must cover EVERY call site in the app, or prop elimination
71
- // would be unsound. Defaults to the Vite root.
72
- shaker({ include: ['src'] }),
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'] }),
73
74
  svelte(),
74
75
  ],
75
76
  });
@@ -88,8 +89,9 @@ module); the engine takes an optional `parse` argument if you want to swap it.
88
89
 
89
90
  ```ts
90
91
  shaker({
91
- include: ['src'], // dirs (relative to root) holding every .svelte call site
92
- external: [], // components to freeze never fold their props (see below)
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)
93
95
  monomorphize: true, // default on; `false` disables it for faster builds,
94
96
  // or { maxVariants: 16, minSavings: 0.05 } to tune
95
97
  verbose: false, // true = per-file size breakdown after the build
@@ -101,6 +103,10 @@ shaker({
101
103
  });
102
104
  ```
103
105
 
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
+
104
110
  - **The defaults are the Rust path.** Out of the box svelte-shaker runs the
105
111
  native **Rust (WASM) engine** and parses with **rsvelte**, loaded from
106
112
  [`@rsvelte/compiler`](https://github.com/baseballyama/rsvelte) (a bundled WASM
@@ -141,19 +147,30 @@ shaker({
141
147
  can't (a broken install), the plugin **throws** rather than silently falling
142
148
  back — so the same source always shakes the same on every machine. Reinstall
143
149
  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.
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.
157
174
 
158
175
  ## What it removes
159
176
 
@@ -192,14 +209,17 @@ The whole point is to **never change observable behavior**.
192
209
  unshaken; distribute via `svelte-package`.
193
210
  - **Build only** — whole-program analysis is incompatible with dev/HMR locality,
194
211
  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.
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.
203
223
 
204
224
  See [`docs/ARCHITECTURE.md`](https://github.com/baseballyama/svelte-shaker/blob/main/docs/ARCHITECTURE.md)
205
225
  for the full design and implementation status.
package/dist/analyze.d.ts CHANGED
@@ -18,8 +18,10 @@ export type ReadFileSync = (id: ComponentId) => string;
18
18
  * never be seen and is safe to drop;
19
19
  * - `{ kind: 'all' }` — anything we cannot pin down: a `...rest` (captures
20
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.
21
+ * more than one `$props()` call, `$props()` outside a `let <pat> = …`
22
+ * declarator, or a component that observes slotted content outside `$props()`
23
+ * — a legacy `<slot>` element or a `$$slots` read (both legal in runes mode).
24
+ * Then any input might be observed, so nothing is dropped.
23
25
  */
24
26
  export type ReachableInputs = {
25
27
  kind: 'all';
@@ -114,6 +116,17 @@ export interface FileModel {
114
116
  * the write. We never fold such a prop, exactly like a shadowed one.
115
117
  */
116
118
  writtenNames: Set<string>;
119
+ /**
120
+ * Owner-local bindings that are provably a single primitive CONSTANT, keyed by
121
+ * the LOCAL name a forwarded call-site expression references (docs §13.1
122
+ * interprocedural pass-through). Merged into the owner's fold env so that
123
+ * `<Child {count}/>` — where `count` is an unmutated `let count = $state(0)` or
124
+ * a `const count = 0` — folds in the child exactly as a call-site literal would,
125
+ * feeding BOTH constant fold and value-set narrowing. A static property of the
126
+ * source (independent of the fixpoint's plans), so it is computed ONCE here.
127
+ * See {@link computeScriptConstEnv} for the (conservative) admission rules.
128
+ */
129
+ scriptConstEnv: ReadonlyMap<string, Literal>;
117
130
  /**
118
131
  * Resolved ids of CHILD components this file leaks as a value (escape, docs
119
132
  * §4.1) — e.g. `<svelte:component this={Child}>`. `analyze` unions these
@@ -264,8 +277,9 @@ export interface UnpassedProp {
264
277
  * spread that could set it (`readCallSite` already folds `bind:`, known
265
278
  * spreads, and `children`/snippet body into `explicit`/`hadSpread`);
266
279
  * - 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.
280
+ * the `.svelte` graph (a call site in a non-`.svelte` module, or a user
281
+ * `preserve`, docs §4.2) — is skipped, because that consumer may pass a prop
282
+ * the crawl cannot see.
269
283
  *
270
284
  * Missing a `.svelte` EDGE (e.g. an unfollowed barrel) only DROPS call sites, so it
271
285
  * can only make this UNDER-report (the component looks unused and is skipped). The
package/dist/analyze.js CHANGED
@@ -29,22 +29,22 @@ function fixpointIterationBound(componentCount) {
29
29
  /** Bail reason stamped on a component leaked as a value (docs §4.1 escape). */
30
30
  const ESCAPE_REASON = 'escapes as value (e.g. <svelte:component this={X}>)';
31
31
  /** Bail reason stamped on a component with a consumer OUTSIDE the analyzed
32
- * `.svelte` graph — a `.ts`/`.js` call site the crawl cannot parse, or a
33
- * user-declared `external` (docs §4.2, {@link AnalyzeInput.escaped}). Kept
34
- * byte-identical to the Rust engine's constant so the two agree. */
35
- const EXTERNAL_ESCAPE_REASON = 'has a consumer outside the analyzed .svelte graph';
32
+ * `.svelte` graph — a call site in a non-`.svelte` module the crawl cannot
33
+ * parse, or a user-declared `preserve` (docs §4.2, {@link AnalyzeInput.escaped}).
34
+ * Kept byte-identical to the Rust engine's constant so the two agree. */
35
+ const MODULE_ESCAPE_REASON = 'has a consumer outside the analyzed .svelte graph';
36
36
  /**
37
- * Stamp {@link EXTERNAL_ESCAPE_REASON} on every model in `escaped` that exists in
37
+ * Stamp {@link MODULE_ESCAPE_REASON} on every model in `escaped` that exists in
38
38
  * the program — the single injection point both the whole-program shake and
39
39
  * {@link findNeverPassedProps} share (docs §4.2). Ids not in the program are
40
- * ignored (a stale `external` entry or a scanned `.ts` import to a component
41
- * outside the crawl is simply a no-op, never an error).
40
+ * ignored (a stale `preserve` entry or a scanned import to a component outside
41
+ * the crawl is simply a no-op, never an error).
42
42
  */
43
- function stampExternalEscapes(models, escaped) {
43
+ function stampModuleEscapes(models, escaped) {
44
44
  for (const id of escaped ?? []) {
45
45
  const model = models.get(id);
46
- if (model && !model.bailReasons.includes(EXTERNAL_ESCAPE_REASON))
47
- model.bailReasons.push(EXTERNAL_ESCAPE_REASON);
46
+ if (model && !model.bailReasons.includes(MODULE_ESCAPE_REASON))
47
+ model.bailReasons.push(MODULE_ESCAPE_REASON);
48
48
  }
49
49
  }
50
50
  /**
@@ -86,9 +86,10 @@ export function analyzeInput(input, parseCache) {
86
86
  if (model && !model.bailReasons.includes(ESCAPE_REASON))
87
87
  model.bailReasons.push(ESCAPE_REASON);
88
88
  }
89
- // Components with consumers outside the `.svelte` graph (a `.ts`/`.js` call site
90
- // or a user `external`, docs §4.2) join the same whole-component escape bail.
91
- stampExternalEscapes(models, input.escaped);
89
+ // Components with consumers outside the `.svelte` graph (a call site in a
90
+ // non-`.svelte` module or a user `preserve`, docs §4.2) join the same
91
+ // whole-component escape bail.
92
+ stampModuleEscapes(models, input.escaped);
92
93
  return { models, plans: planFixpoint(models) };
93
94
  }
94
95
  /**
@@ -348,6 +349,27 @@ const EMPTY_ENV = new Map();
348
349
  const EMPTY_SET_ENV = new Map();
349
350
  /** Shared empty {@link OwnerFoldEnv} (no owner, or an owner that folds nothing). */
350
351
  const EMPTY_OWNER_ENV = { fold: EMPTY_ENV, narrow: EMPTY_SET_ENV };
352
+ /**
353
+ * Merge an owner's static script constants ({@link FileModel.scriptConstEnv})
354
+ * with its remapped folded props into a single fold env. The two key spaces are
355
+ * DISJOINT by construction: a folded prop is keyed by the LOCAL binding name its
356
+ * `$props()` destructure introduces, and a script const by its top-level
357
+ * declarator name; a top-level `const`/`let` reusing a `$props()` local name is a
358
+ * JS redeclaration error, so no name can appear in both. The merge is therefore
359
+ * order-independent — folded props are applied last purely to document that
360
+ * invariant — and either operand is returned as-is when the other is empty (the
361
+ * common case: no owner-forwarded expressions, or a prop-less component).
362
+ */
363
+ function mergeScriptConsts(scriptConsts, foldedProps) {
364
+ if (scriptConsts.size === 0)
365
+ return foldedProps;
366
+ if (foldedProps.size === 0)
367
+ return scriptConsts;
368
+ const merged = new Map(scriptConsts);
369
+ for (const [name, value] of foldedProps)
370
+ merged.set(name, value);
371
+ return merged;
372
+ }
351
373
  /**
352
374
  * Recompute every component's plan from the (cascade-filtered) usage, evaluating
353
375
  * forwarded call-site expressions against `prevPlans` — the PREVIOUS fixpoint
@@ -367,13 +389,21 @@ function buildPlans(models, usage, prevPlans) {
367
389
  if (cached)
368
390
  return cached;
369
391
  const model = models.get(owner);
370
- const plan = prevPlans.get(owner);
371
392
  let env = EMPTY_OWNER_ENV;
372
- if (model && plan && !plan.bail && (plan.constFold.size > 0 || plan.narrow.size > 0)) {
373
- env = {
374
- fold: plan.constFold.size > 0 ? remapToLocalNames(plan.constFold, model) : EMPTY_ENV,
375
- narrow: plan.narrow.size > 0 ? remapToLocalNames(plan.narrow, model) : EMPTY_SET_ENV,
376
- };
393
+ if (model) {
394
+ const plan = prevPlans.get(owner);
395
+ // A bailed owner still forwards its own SCRIPT CONSTANTS unchanged — its
396
+ // bail only makes ITS props unobservable, but it keeps rendering its call
397
+ // sites (docs §4.2 "自身のコールサイトは数える"), so `scriptConstEnv` (a
398
+ // static source fact) participates regardless of `plan.bail`. Only the
399
+ // fold/narrow derived from the owner's OWN prop plan is gated on the plan
400
+ // being present and not bailed.
401
+ const foldable = plan !== undefined && !plan.bail;
402
+ const foldedProps = foldable && plan.constFold.size > 0 ? remapToLocalNames(plan.constFold, model) : EMPTY_ENV;
403
+ const narrow = foldable && plan.narrow.size > 0 ? remapToLocalNames(plan.narrow, model) : EMPTY_SET_ENV;
404
+ const fold = mergeScriptConsts(model.scriptConstEnv, foldedProps);
405
+ if (fold.size > 0 || narrow.size > 0)
406
+ env = { fold, narrow };
377
407
  }
378
408
  envCache.set(owner, env);
379
409
  return env;
@@ -470,8 +500,9 @@ export function deadSpansForPlans(models, plans) {
470
500
  * spread that could set it (`readCallSite` already folds `bind:`, known
471
501
  * spreads, and `children`/snippet body into `explicit`/`hadSpread`);
472
502
  * - a component in `input.escaped` — one the Shell knows has a consumer OUTSIDE
473
- * the `.svelte` graph (a `.ts`/`.js` call site, or a user `external`, docs
474
- * §4.2) — is skipped, because that consumer may pass a prop the crawl cannot see.
503
+ * the `.svelte` graph (a call site in a non-`.svelte` module, or a user
504
+ * `preserve`, docs §4.2) — is skipped, because that consumer may pass a prop
505
+ * the crawl cannot see.
475
506
  *
476
507
  * Missing a `.svelte` EDGE (e.g. an unfollowed barrel) only DROPS call sites, so it
477
508
  * can only make this UNDER-report (the component looks unused and is skipped). The
@@ -492,9 +523,10 @@ export function findNeverPassedProps(input) {
492
523
  if (model && !model.bailReasons.includes(ESCAPE_REASON))
493
524
  model.bailReasons.push(ESCAPE_REASON);
494
525
  }
495
- // External consumers (`.ts`/`.js` call sites or `external`) escape too, so a prop
496
- // they pass is never mis-reported as never-passed (docs §4.2).
497
- stampExternalEscapes(models, input.escaped);
526
+ // Consumers outside the `.svelte` graph (non-`.svelte` module call sites or
527
+ // `preserve`) escape too, so a prop they pass is never mis-reported as
528
+ // never-passed (docs §4.2).
529
+ stampModuleEscapes(models, input.escaped);
498
530
  // Every textual call site counts (no cascade dead-span filtering): a prop passed
499
531
  // only at a folded-away site is still author-written, so we do not flag it.
500
532
  const usage = buildUsage(models, new Map());
@@ -637,10 +669,11 @@ function buildModelFromInput(file, edges, parseCache) {
637
669
  }
638
670
  }
639
671
  }
640
- const reachableInputs = computeReachableInputs(instance, props, hasRestProp, propsPattern);
672
+ const reachableInputs = computeReachableInputs(instance, props, hasRestProp, propsPattern, usesLegacySlotInputs(ast));
641
673
  const childCalls = collectChildCalls(ast, imports);
642
674
  const { shadowedNames, debugNames, writtenNames } = collectTemplateBindings(ast, instance, propsDeclaration);
643
675
  const unreadDeclaredProps = computeUnreadDeclaredProps(ast, instance, props, propsPattern, shadowedNames, debugNames, writtenNames);
676
+ const scriptConstEnv = computeScriptConstEnv(ast, instance, ast.module, propsDeclaration, writtenNames);
644
677
  // Escape detection (docs §4.1): an imported component referenced as a *value*
645
678
  // (most notably `<svelte:component this={X}>`, but also assigned / passed /
646
679
  // stored) leaks to a use we cannot follow, so its prop profile is incomplete.
@@ -662,6 +695,7 @@ function buildModelFromInput(file, edges, parseCache) {
662
695
  shadowedNames,
663
696
  debugNames,
664
697
  writtenNames,
698
+ scriptConstEnv,
665
699
  escapedComponents,
666
700
  bailReasons,
667
701
  };
@@ -743,6 +777,195 @@ function computeUnreadDeclaredProps(ast, instance, props, propsPattern, shadowed
743
777
  }
744
778
  return unread;
745
779
  }
780
+ /**
781
+ * Owner-local, provably-constant primitive bindings, keyed by the LOCAL name a
782
+ * forwarded call-site expression references (docs §13.1 interprocedural
783
+ * pass-through). Walks the module then the instance `<script>`'s TOP-LEVEL
784
+ * declarations in order, extending the env sequentially so `const a = 1; const b
785
+ * = a + 1;` both resolve. Every rule is conservative for soundness — a binding
786
+ * is admitted ONLY when its identifier definitely denotes one constant primitive
787
+ * at every call site:
788
+ * - `const x = <expr>` / `let|var x = <expr>` whose `<expr>` constant-evaluates
789
+ * against the env built so far;
790
+ * - `$state(<arg>)` / `$state.raw(<arg>)` are unwrapped to `<arg>` (a bare
791
+ * `$state()` is `undefined`): the reactive wrapper does not change the value a
792
+ * never-written binding forwards. `$derived` / `$props` / any OTHER rune is
793
+ * not unwrapped, so its `CallExpression` never constant-evaluates and is
794
+ * skipped (out of scope);
795
+ * - primitives only — the `Literal` domain excludes object/array initializers,
796
+ * so deep mutation through a `$state` proxy can never be folded away;
797
+ * - the name is NEVER written (reassigned / `++` / destructure-assigned /
798
+ * `bind:`), tested against {@link writtenNames} extended with module-internal
799
+ * writes (docs §4.1: a written binding is not a constant);
800
+ * - the name is bound EXACTLY ONCE across the whole file (its own top-level
801
+ * declarator). A name a template binder (`{#each as}`, snippet param, …) or a
802
+ * nested/duplicate scope also binds is a DIFFERENT entity at some call site,
803
+ * and call-site evaluation ({@link evaluate}) is scope-blind, so folding it
804
+ * could read the wrong entity there (docs §4.1 shadowing; the same soundness
805
+ * argument as {@link isFoldBlockedName}, but on the owner's OWN bindings — for
806
+ * which the file-wide `shadowedNames` cannot be reused: it already contains
807
+ * every top-level script declaration, so it would reject every candidate);
808
+ * - exported bindings (`export const x`) are excluded — they are wrapped in an
809
+ * `ExportNamedDeclaration`, not a bare `VariableDeclaration`, so the top-level
810
+ * scan below skips them; like an escaped component they are reachable from
811
+ * outside the analyzed graph.
812
+ */
813
+ function computeScriptConstEnv(ast, instance, moduleScript, propsDeclaration, writtenNames) {
814
+ const env = new Map();
815
+ // A name is admissible only if bound EXACTLY ONCE anywhere in the file, so no
816
+ // template binder or nested scope can shadow it at a call site.
817
+ const bindingCounts = new Map();
818
+ countBindingNames(moduleScript?.content, bindingCounts);
819
+ countBindingNames(instance?.content, bindingCounts);
820
+ countBindingNames(ast.fragment, bindingCounts);
821
+ // `writtenNames` (from collectTemplateBindings) scans the instance script and
822
+ // the template but NOT the module script, so a module-internal write
823
+ // (`<script module>let n = 0; function inc(){ n++ }</script>`) would be missed.
824
+ // Close that gap here before admitting any module-level binding.
825
+ const written = new Set(writtenNames);
826
+ collectScriptWrites(moduleScript?.content, written);
827
+ // Module script runs before the instance and its bindings are visible to it,
828
+ // so extend module-first, then instance, each in declaration order.
829
+ for (const program of [moduleScript?.content, instance?.content]) {
830
+ for (const stmt of program?.body ?? []) {
831
+ // Only a bare `VariableDeclaration`; an `export const` is wrapped in an
832
+ // `ExportNamedDeclaration` and is deliberately excluded (see doc comment).
833
+ if (stmt.type !== 'VariableDeclaration' || stmt === propsDeclaration)
834
+ continue;
835
+ for (const decl of stmt.declarations ?? []) {
836
+ // A single-identifier binding only: a destructuring `const { a } = …`
837
+ // has no one primitive name to key, so it never folds.
838
+ if (decl.id?.type !== 'Identifier' || !decl.id.name)
839
+ continue;
840
+ const name = decl.id.name;
841
+ if (written.has(name) || bindingCounts.get(name) !== 1)
842
+ continue;
843
+ const value = evalDeclaratorValue(decl.init, env);
844
+ if (value.known)
845
+ env.set(name, value.value);
846
+ }
847
+ }
848
+ }
849
+ return env;
850
+ }
851
+ /**
852
+ * Constant value of a declarator initializer for {@link computeScriptConstEnv},
853
+ * unwrapping the two runes whose argument IS the value a never-written binding
854
+ * holds: `$state(<arg>)` / `$state.raw(<arg>)` (a bare `$state()` /
855
+ * `$state.raw()` is `undefined`). Any other initializer — including every other
856
+ * rune call — is evaluated verbatim, so a non-value rune simply falls to unknown.
857
+ */
858
+ function evalDeclaratorValue(init, env) {
859
+ if (isStateRuneCall(init)) {
860
+ const arg = init?.arguments?.[0];
861
+ if (arg == null)
862
+ return { known: true, value: undefined }; // bare `$state()` -> undefined
863
+ return evaluate(arg, env);
864
+ }
865
+ return evaluate(init, env);
866
+ }
867
+ /**
868
+ * `$state(...)` or `$state.raw(...)` — the two runes whose sole argument is the
869
+ * plain value a never-written binding evaluates to. `$state.snapshot`,
870
+ * `$derived`, `$props`, `$bindable`, etc. are intentionally NOT matched: they are
871
+ * not value-preserving wrappers, so they must stay unknown.
872
+ */
873
+ function isStateRuneCall(node) {
874
+ if (node?.type !== 'CallExpression')
875
+ return false;
876
+ const callee = node.callee;
877
+ if (callee?.type === 'Identifier')
878
+ return callee.name === '$state';
879
+ return (callee?.type === 'MemberExpression' &&
880
+ callee.computed !== true &&
881
+ callee.object?.type === 'Identifier' &&
882
+ callee.object.name === '$state' &&
883
+ callee.property?.type === 'Identifier' &&
884
+ callee.property.name === 'raw');
885
+ }
886
+ /**
887
+ * Increment `counts` for every name a binding introduces in `root` (a `<script>`
888
+ * Program or the template fragment): variable declarators (including
889
+ * destructuring), function ids and parameters, and every template binder
890
+ * ({@link collectTemplateBindings} covers the same binders). A name whose total
891
+ * count exceeds one is bound in more than one place — a nested/duplicate
892
+ * declaration or a template binder — so it is shadowed at some scope and is
893
+ * disqualified from {@link computeScriptConstEnv}.
894
+ */
895
+ function countBindingNames(root, counts) {
896
+ if (!root)
897
+ return;
898
+ const bump = (name) => {
899
+ if (name)
900
+ counts.set(name, (counts.get(name) ?? 0) + 1);
901
+ };
902
+ const bumpPattern = (pattern) => {
903
+ const names = new Set();
904
+ addPatternNames(pattern, names);
905
+ for (const n of names)
906
+ bump(n);
907
+ };
908
+ // A `try {} catch (e) {}` param is intentionally NOT counted: a call-site
909
+ // expression only resolves against the TOP-LEVEL script scope, which a
910
+ // catch-block-scoped name can never enter, so it cannot shadow a fold there.
911
+ walk(root, null, {
912
+ _(node, { next }) {
913
+ switch (node.type) {
914
+ case 'VariableDeclarator':
915
+ bumpPattern(node.id);
916
+ break;
917
+ case 'FunctionDeclaration':
918
+ case 'FunctionExpression':
919
+ case 'ArrowFunctionExpression':
920
+ if (node.id?.type === 'Identifier')
921
+ bump(node.id.name);
922
+ for (const p of node.params ?? [])
923
+ bumpPattern(p);
924
+ break;
925
+ case 'EachBlock':
926
+ bumpPattern(node.context);
927
+ if (typeof node.index === 'string')
928
+ bump(node.index);
929
+ break;
930
+ case 'SnippetBlock':
931
+ if (node.expression?.type === 'Identifier')
932
+ bump(node.expression.name);
933
+ for (const p of node.parameters ?? [])
934
+ bumpPattern(p);
935
+ break;
936
+ case 'AwaitBlock':
937
+ bumpPattern(node.value);
938
+ bumpPattern(node.error);
939
+ break;
940
+ case 'LetDirective':
941
+ bump(node.name);
942
+ break;
943
+ case 'ConstTag':
944
+ for (const d of node.declaration?.declarations ?? [])
945
+ bumpPattern(d.id);
946
+ break;
947
+ }
948
+ next();
949
+ },
950
+ });
951
+ }
952
+ /** Add every name a `<script>` Program WRITES (bare-identifier assignment /
953
+ * update targets, at any nesting) to `out` — the module-script counterpart of
954
+ * the write collection {@link collectTemplateBindings} runs over the instance. */
955
+ function collectScriptWrites(program, out) {
956
+ if (!program)
957
+ return;
958
+ walk(program, null, {
959
+ AssignmentExpression(node, { next }) {
960
+ collectWrittenNames(node, out);
961
+ next();
962
+ },
963
+ UpdateExpression(node, { next }) {
964
+ collectWrittenNames(node, out);
965
+ next();
966
+ },
967
+ });
968
+ }
746
969
  /**
747
970
  * Collect every name bound by a TEMPLATE scope (so a same-named prop is a
748
971
  * different entity there) and every name used as a `{@debug}` argument.
@@ -1612,7 +1835,15 @@ function parseModuleBody(code, id) {
1612
1835
  * `$props.id()` (a member call) is NOT a `$props()` call — the props object never
1613
1836
  * leaks through it — so it does not count and does not affect the result.
1614
1837
  */
1615
- function computeReachableInputs(instance, props, hasRestProp, propsPattern) {
1838
+ function computeReachableInputs(instance, props, hasRestProp, propsPattern, usesSlotInputs) {
1839
+ // A legacy `<slot>` (or a bare `$$slots` read) observes slotted content —
1840
+ // inputs that arrive OUTSIDE `$props()` (in Svelte 5 terms, the synthetic
1841
+ // `children` input and named-slot / `let:` inputs a call site supplies as body
1842
+ // content). The `$props()` shape cannot model them, so the reverse pass must
1843
+ // treat every input as observable, or it would delete the slot-carrying body at
1844
+ // each call site. This holds whether or not an instance script exists.
1845
+ if (usesSlotInputs)
1846
+ return { kind: 'all' };
1616
1847
  // No instance script -> no `$props()` -> the component reads no input at all.
1617
1848
  if (!instance)
1618
1849
  return { kind: 'names', names: new Set() };
@@ -1633,6 +1864,32 @@ function computeReachableInputs(instance, props, hasRestProp, propsPattern) {
1633
1864
  return { kind: 'all' };
1634
1865
  return { kind: 'names', names: new Set(props.map((p) => p.name)) };
1635
1866
  }
1867
+ /** True when the component observes slotted content outside `$props()`: a legacy
1868
+ * `<slot>` element, or a read of the `$$slots` identifier (legal in runes mode,
1869
+ * unlike `$$props`/`$$restProps`). Either signal means {@link
1870
+ * computeReachableInputs} cannot model the inputs and must fall back to ALL.
1871
+ * `$$slots` can appear in the instance script OR a template expression
1872
+ * (`{#if $$slots.default}`), so both trees are scanned; its `$$` prefix cannot be
1873
+ * a user binding, so no shadowing check is needed. */
1874
+ function usesLegacySlotInputs(ast) {
1875
+ return (nodeSignalsSlotInputs(ast.fragment) || (!!ast.instance && nodeSignalsSlotInputs(ast.instance)));
1876
+ }
1877
+ function nodeSignalsSlotInputs(root) {
1878
+ let found = false;
1879
+ walk(root, null, {
1880
+ SlotElement(_node, { stop }) {
1881
+ found = true;
1882
+ stop();
1883
+ },
1884
+ Identifier(node, { stop }) {
1885
+ if (node.name === '$$slots') {
1886
+ found = true;
1887
+ stop();
1888
+ }
1889
+ },
1890
+ });
1891
+ return found;
1892
+ }
1636
1893
  /** True when a `$props()` ObjectPattern binds a prop whose external name is not a
1637
1894
  * plain identifier (a string-literal or computed key), so {@link declared_props}
1638
1895
  * did not capture it. */
package/dist/engine.d.ts CHANGED
@@ -47,13 +47,13 @@ export declare class DevShaker {
47
47
  /** Last-seen source per file, so an update re-reads only what changed. */
48
48
  private readonly codeCache;
49
49
  /** Components with a consumer outside the `.svelte` graph (docs §4.2): a
50
- * `.ts`/`.js` call site or a user `external`. The Shell recomputes and
50
+ * `.ts`/`.js` call site or a user `preserve`. The Shell recomputes and
51
51
  * {@link setEscaped}s this whenever a non-`.svelte` module changes. */
52
52
  private escaped;
53
53
  /** Current slimmed output per `.svelte` id (the live shake result). */
54
54
  private output;
55
55
  constructor(files: ComponentId | ComponentId[], resolve: Resolve, readFile: ReadFile, mode?: DevMode, parse?: Parse, escaped?: ComponentId[]);
56
- /** Replace the external-escape set (docs §4.2). The Shell calls this before
56
+ /** Replace the module-escape set (docs §4.2). The Shell calls this before
57
57
  * {@link update} when a non-`.svelte` module changed the set of components
58
58
  * reached from `.ts`/`.js`, so the next shake bails them. */
59
59
  setEscaped(escaped: ComponentId[]): void;
package/dist/engine.js CHANGED
@@ -24,7 +24,7 @@ export class DevShaker {
24
24
  /** Last-seen source per file, so an update re-reads only what changed. */
25
25
  codeCache = new Map();
26
26
  /** Components with a consumer outside the `.svelte` graph (docs §4.2): a
27
- * `.ts`/`.js` call site or a user `external`. The Shell recomputes and
27
+ * `.ts`/`.js` call site or a user `preserve`. The Shell recomputes and
28
28
  * {@link setEscaped}s this whenever a non-`.svelte` module changes. */
29
29
  escaped;
30
30
  /** Current slimmed output per `.svelte` id (the live shake result). */
@@ -38,7 +38,7 @@ export class DevShaker {
38
38
  this.parse = parse;
39
39
  this.escaped = escaped;
40
40
  }
41
- /** Replace the external-escape set (docs §4.2). The Shell calls this before
41
+ /** Replace the module-escape set (docs §4.2). The Shell calls this before
42
42
  * {@link update} when a non-`.svelte` module changed the set of components
43
43
  * reached from `.ts`/`.js`, so the next shake bails them. */
44
44
  setEscaped(escaped) {
@@ -7,38 +7,43 @@ import type { Resolve, ReadFile } from './analyze.js';
7
7
  * uses this to decide whether a changed file can shift the escape set (docs §4.2).
8
8
  */
9
9
  export declare function isScannableModule(file: string): boolean;
10
- /** The components a set of `external` prefixes freezes (docs §4.2). The
11
- * {@link partitionExternal} projection that drops the "matched nothing" diagnostic
10
+ /** The components a set of `preserve` prefixes protects (docs §4.2). The
11
+ * {@link partitionPreserve} projection that drops the "matched nothing" diagnostic
12
12
  * — kept for callers that only need the ids. */
13
- export declare function matchExternal(external: string[] | undefined, root: string, components: Iterable<ComponentId>): ComponentId[];
13
+ export declare function matchPreserve(preserve: string[] | undefined, root: string, components: Iterable<ComponentId>): ComponentId[];
14
14
  /**
15
15
  * The whole escape set for a build (docs §4.2), plus the diagnostics a Shell must
16
16
  * surface. `escaped` is the union of components a non-`.svelte` module imports
17
- * (found by scanning) and those the user named via `external`. `unscannable` are
17
+ * (found by scanning) and those the user named via `preserve`. `unscannable` are
18
18
  * modules the scan could not read/parse (a component mounted from one is NOT
19
- * escaped — the Shell warns and the user lists it in `external`); `unmatchedExternal`
20
- * are `external` entries that matched no component (a typo leaves the intended
21
- * component un-frozen). Returning them as data lets each Shell — the Vite plugin,
19
+ * escaped — the Shell warns and the user lists it in `preserve`); `unmatchedPreserve`
20
+ * are `preserve` entries that matched no component (a typo leaves the intended
21
+ * component unpreserved). Returning them as data lets each Shell — the Vite plugin,
22
22
  * or a future `eslint-plugin-svelte` rule — report in its own voice.
23
23
  */
24
24
  export interface EscapeScanResult {
25
25
  escaped: ComponentId[];
26
26
  unscannable: ComponentId[];
27
- unmatchedExternal: string[];
27
+ unmatchedPreserve: string[];
28
28
  }
29
29
  /**
30
- * Compute the {@link EscapeScanResult} from "include roots + resolver + component
30
+ * Compute the {@link EscapeScanResult} from "entry roots + resolver + component
31
31
  * set". The one helper a Shell calls (the only escape-scan symbol re-exported from
32
32
  * `svelte-shaker/node`).
33
33
  */
34
34
  export declare function computeEscapedComponents(opts: {
35
- /** Absolute include roots (already resolved against the project root). */
36
- includeDirs: string[];
37
- /** Project root, for resolving relative `external` entries. */
35
+ /**
36
+ * Absolute crawl-entry roots (already resolved against the project root) — the
37
+ * Vite plugin's `entries`. The SAME roots drive two different walks: collecting
38
+ * the `.svelte` crawl entries, and (here) collecting the non-`.svelte` modules to
39
+ * scan for call sites that escape the component graph.
40
+ */
41
+ entryDirs: string[];
42
+ /** Project root, for resolving relative `preserve` entries. */
38
43
  root: string;
39
- /** User-declared `external` prefixes (root-relative or absolute), if any. */
40
- external?: string[] | undefined;
41
- /** The crawled `.svelte` component ids, for matching `external` prefixes. */
44
+ /** User-declared `preserve` prefixes (root-relative or absolute), if any. */
45
+ preserve?: string[] | undefined;
46
+ /** The crawled `.svelte` component ids, for matching `preserve` prefixes. */
42
47
  components: Iterable<ComponentId>;
43
48
  resolve: Resolve;
44
49
  readFile: ReadFile;
@@ -1,7 +1,7 @@
1
1
  // ----------------------------------------------------------------------
2
2
  // Internal Shell helper (docs/ARCHITECTURE.md §4.2): find the components used from
3
3
  // OUTSIDE the analyzed `.svelte` graph — a `.ts`/`.js` call site the crawl cannot
4
- // parse, or a user-declared `external`. NOT part of the `svelte-shaker/node` public
4
+ // parse, or a user-declared `preserve`. NOT part of the `svelte-shaker/node` public
5
5
  // surface: only `computeEscapedComponents` is re-exported there (`scan.ts`). The
6
6
  // Vite Shell imports the rest of this module directly.
7
7
  // ----------------------------------------------------------------------
@@ -57,13 +57,13 @@ function collectNonSvelteModules(dir) {
57
57
  * Every module specifier a JS/TS module statically references: `import … from`,
58
58
  * `export … from` / `export *`, and a dynamic `import('…')` whose argument is a
59
59
  * STRING LITERAL. A computed dynamic import (`import(expr)`) is unknowable here —
60
- * the `external` option (docs §4.2) is the sound fallback for it. Parsed via the
60
+ * the `preserve` option (docs §4.2) is the sound fallback for it. Parsed via the
61
61
  * Svelte parser's `<script module lang="ts">` wrapper (the same TS-capable parse
62
62
  * the engine's barrel-following uses). Returns `null` when the module does NOT
63
63
  * parse — the wrapper handles TS but not JSX, and rejects some exotic syntax
64
64
  * (`.jsx`/`.tsx` bodies, bleeding-edge TS), and a parse failure hides any call site
65
65
  * inside, so the caller must surface it (a mounted component would go un-escaped);
66
- * `external` is the fix for that file.
66
+ * `preserve` is the fix for that file.
67
67
  */
68
68
  function moduleImportSpecifiers(code, id) {
69
69
  let module;
@@ -71,7 +71,7 @@ function moduleImportSpecifiers(code, id) {
71
71
  module = parseSvelte(`<script module lang="ts">\n${code}\n</script>`, id).module;
72
72
  }
73
73
  catch {
74
- return null; // unparseable — the caller reports it (soundness hole, `external` fixes it)
74
+ return null; // unparseable — the caller reports it (soundness hole, `preserve` fixes it)
75
75
  }
76
76
  const program = module?.content;
77
77
  if (!program)
@@ -115,7 +115,7 @@ function moduleImportSpecifiers(code, id) {
115
115
  * A module that cannot be read or parsed is collected into `unscannable` (NOT
116
116
  * silently dropped): a call site inside it is invisible, so the caller must warn.
117
117
  */
118
- async function collectExternalEscapes(modules, resolve, readFile) {
118
+ async function collectModuleEscapes(modules, resolve, readFile) {
119
119
  const escaped = new Set();
120
120
  const unscannable = new Set();
121
121
  for (const id of modules) {
@@ -141,22 +141,22 @@ async function collectExternalEscapes(modules, resolve, readFile) {
141
141
  return { escaped, unscannable };
142
142
  }
143
143
  /**
144
- * Partition user-declared `external` prefixes (docs §4.2) against a known component
145
- * set into the components they FREEZE and the entries that matched NOTHING. Each
144
+ * Partition user-declared `preserve` prefixes (docs §4.2) against a known component
145
+ * set into the components they PRESERVE and the entries that matched NOTHING. Each
146
146
  * entry is a Vite-root-relative or absolute path naming EITHER a component file
147
147
  * (exact match) OR a directory (every component under it) — the same "directory or
148
- * file prefix" basis as `include`, with no glob dependency. An entry matching no
148
+ * file prefix" basis as `entries`, with no glob dependency. An entry matching no
149
149
  * component is almost always a typo / wrong path (or a missing `.svelte`
150
150
  * extension), so it is returned in `unmatched` for the caller to surface rather
151
- * than being a silent no-op that leaves the intended component un-frozen.
151
+ * than being a silent no-op that leaves the intended component unpreserved.
152
152
  */
153
- function partitionExternal(external, root, components) {
153
+ function partitionPreserve(preserve, root, components) {
154
154
  const matched = new Set();
155
155
  const unmatched = [];
156
- if (!external || external.length === 0)
156
+ if (!preserve || preserve.length === 0)
157
157
  return { matched, unmatched };
158
158
  const ids = [...components];
159
- for (const entry of external) {
159
+ for (const entry of preserve) {
160
160
  // `path.resolve` leaves an absolute entry as-is and resolves a relative one
161
161
  // against `root` — exactly "Vite-root-relative or absolute".
162
162
  const prefix = path.resolve(root, entry);
@@ -169,25 +169,25 @@ function partitionExternal(external, root, components) {
169
169
  }
170
170
  return { matched, unmatched };
171
171
  }
172
- /** The components a set of `external` prefixes freezes (docs §4.2). The
173
- * {@link partitionExternal} projection that drops the "matched nothing" diagnostic
172
+ /** The components a set of `preserve` prefixes protects (docs §4.2). The
173
+ * {@link partitionPreserve} projection that drops the "matched nothing" diagnostic
174
174
  * — kept for callers that only need the ids. */
175
- export function matchExternal(external, root, components) {
176
- return [...partitionExternal(external, root, components).matched];
175
+ export function matchPreserve(preserve, root, components) {
176
+ return [...partitionPreserve(preserve, root, components).matched];
177
177
  }
178
178
  /**
179
- * Compute the {@link EscapeScanResult} from "include roots + resolver + component
179
+ * Compute the {@link EscapeScanResult} from "entry roots + resolver + component
180
180
  * set". The one helper a Shell calls (the only escape-scan symbol re-exported from
181
181
  * `svelte-shaker/node`).
182
182
  */
183
183
  export async function computeEscapedComponents(opts) {
184
- const { escaped, unscannable } = await collectExternalEscapes(opts.includeDirs.flatMap(collectNonSvelteModules), opts.resolve, opts.readFile);
185
- const { matched, unmatched } = partitionExternal(opts.external, opts.root, opts.components);
184
+ const { escaped, unscannable } = await collectModuleEscapes(opts.entryDirs.flatMap(collectNonSvelteModules), opts.resolve, opts.readFile);
185
+ const { matched, unmatched } = partitionPreserve(opts.preserve, opts.root, opts.components);
186
186
  for (const id of matched)
187
187
  escaped.add(id);
188
188
  return {
189
189
  escaped: [...escaped],
190
190
  unscannable: [...unscannable].sort(),
191
- unmatchedExternal: unmatched,
191
+ unmatchedPreserve: unmatched,
192
192
  };
193
193
  }
package/dist/ir.d.ts CHANGED
@@ -39,12 +39,13 @@ export interface AnalyzeInput {
39
39
  /**
40
40
  * Components with at least one consumer OUTSIDE the analyzed `.svelte` graph —
41
41
  * a call site in a `.ts`/`.js` module (`mount(Comp, …)`, a lazy `import()`), or
42
- * a user-declared `external` (docs/ARCHITECTURE.md §4.2). The Shell computes
42
+ * a user-declared `preserve` (docs/ARCHITECTURE.md §4.2). The Shell computes
43
43
  * this set (its FS scan cannot parse `.ts` call sites); the engine unions it
44
44
  * into the same whole-component escape bail auto-detected escapes use, so these
45
45
  * components are never folded and never reported as never-passed — while their
46
- * OWN call sites still count toward their children. Omitted/`[]` means "no
47
- * external consumers", keeping the output byte-for-byte unchanged.
46
+ * OWN call sites still count toward their children. Omitted/`[]` means every
47
+ * consumer is inside the crawled `.svelte` graph, keeping the output
48
+ * byte-for-byte unchanged.
48
49
  */
49
50
  escaped?: ComponentId[];
50
51
  }
package/dist/parse.d.ts CHANGED
@@ -21,6 +21,8 @@ export interface AnyNode {
21
21
  init?: AnyNode | undefined;
22
22
  key?: AnyNode | undefined;
23
23
  property?: AnyNode | undefined;
24
+ /** MemberExpression object (`$state` in `$state.raw`). */
25
+ object?: AnyNode | undefined;
24
26
  source?: AnyNode | undefined;
25
27
  local?: AnyNode | undefined;
26
28
  /** ImportSpecifier / ExportSpecifier exported-name slot. */
@@ -46,6 +48,8 @@ export interface AnyNode {
46
48
  members?: AnyNode[] | undefined;
47
49
  body?: AnyNode[] | undefined;
48
50
  declarations?: AnyNode[] | undefined;
51
+ /** CallExpression / NewExpression arguments (`0` in `$state(0)`). */
52
+ arguments?: (AnyNode | null)[] | undefined;
49
53
  specifiers?: AnyNode[] | undefined;
50
54
  nodes?: AnyNode[] | undefined;
51
55
  /** SnippetBlock parameters; ArrayPattern elements; DebugTag identifiers. */
Binary file
package/dist/transform.js CHANGED
@@ -19,6 +19,21 @@ export function transformAll(models, plans) {
19
19
  }
20
20
  /** Shared empty local fold env for a bailed owner (nothing forwards a constant). */
21
21
  const EMPTY_LOCAL_ENV = new Map();
22
+ /**
23
+ * Merge an owner's static script constants with its remapped folded props for the
24
+ * phase-2 side-effect check. Both are keyed by LOCAL name and are disjoint (a
25
+ * `$props()` local and a top-level script const cannot share a name — JS
26
+ * redeclaration), so the merge is order-independent; either operand is returned
27
+ * as-is when the other is empty (the common case). Mirrors analyze.ts's
28
+ * `mergeScriptConsts` (kept separate so neither module imports the other).
29
+ */
30
+ function mergeLocalConstEnv(scriptConsts, foldedProps) {
31
+ if (scriptConsts.size === 0)
32
+ return foldedProps;
33
+ if (foldedProps.size === 0)
34
+ return scriptConsts;
35
+ return new Map([...scriptConsts, ...foldedProps]);
36
+ }
22
37
  /**
23
38
  * Phases 1–2, shared by {@link transformAll} and {@link transformAllWithMono}:
24
39
  * fold each component body and drop its folded props (phase 1), then strip the
@@ -68,11 +83,13 @@ function runBasePhases(models, plans) {
68
83
  // skipping any call site phase 1 folded away (its attributes went with it).
69
84
  for (const model of models.values()) {
70
85
  const plan = plans.get(model.id);
71
- // A forwarded expression (`<Child prop={ownerProp}/>`) was substituted to a
72
- // literal in phase 1 when `ownerProp` is folded; its attribute is then just
73
- // as removable as a written literal. Give phase 2 the owner's fold env
74
- // (local-keyed, as the expression references props) so it can recognize that.
75
- const ownerEnv = plan.bail ? EMPTY_LOCAL_ENV : remapToLocalNames(plan.constFold, model);
86
+ // A forwarded expression (`<Child prop={ownerProp}/>`) that the owner proves
87
+ // constant a folded prop OR an owner-local script constant (docs §13.1) — is
88
+ // side-effect-free, so once the child drops the prop its attribute is as
89
+ // removable as a written literal. Give phase 2 the owner's fold env plus its
90
+ // `scriptConstEnv`, both local-keyed as the expression references props/locals.
91
+ const foldEnv = plan.bail ? EMPTY_LOCAL_ENV : remapToLocalNames(plan.constFold, model);
92
+ const ownerEnv = mergeLocalConstEnv(model.scriptConstEnv, foldEnv);
76
93
  removeCallSiteAttributes(model, dropped, strings.get(model.id), editedSpans.get(model.id) ?? [], ownerEnv);
77
94
  }
78
95
  // Phase 2.5 — reverse (docs §PR4) + unread declared (docs §PR7): delete the
package/dist/unread.js CHANGED
@@ -81,7 +81,8 @@ export function collectUnread(models, plans) {
81
81
  // (b): a prop is droppable when it survived every site's veto (and had the
82
82
  // structural gates). A child with NO call sites keeps every `true` here — safe,
83
83
  // since the child does not read the prop, so its own render is unchanged whether
84
- // it is declared or not (and external consumers, if any, bail the child).
84
+ // it is declared or not (and a consumer outside the `.svelte` graph, if any,
85
+ // bails the child).
85
86
  for (const [id, perProp] of dropEligible) {
86
87
  const set = new Set();
87
88
  for (const [name, ok] of perProp)
package/dist/vite.d.ts CHANGED
@@ -1,32 +1,57 @@
1
1
  import type { Plugin } from 'vite';
2
2
  import { type DevMode } from './engine.js';
3
3
  import { type MonomorphizeOptions } from './mono.js';
4
+ /**
5
+ * Options for the {@link shaker} plugin. The set below is exhaustive: any other
6
+ * key throws at build start rather than being ignored, because an option we never
7
+ * read is an option the user thinks is applied and is not.
8
+ */
4
9
  export interface ShakerOptions {
5
10
  /**
6
- * Directories (relative to the Vite root) to scan for `.svelte` components.
7
- * Defaults to the Vite root itself. Every `.svelte` file found is treated as
8
- * a call-site source, so the union of these dirs must contain the whole app
9
- * for prop elimination to be sound (docs/ARCHITECTURE.md §4.2).
11
+ * Directories (relative to the Vite root) the component crawl STARTS from —
12
+ * the same semantics as SvelteKit's `config.kit.prerender.entries`: you list
13
+ * the roots, and everything reachable from them is pulled in by following the
14
+ * import graph. Defaults to the Vite root itself. No glob: each entry is a
15
+ * directory (or a single component file), matched on a plain path-prefix basis.
16
+ *
17
+ * This is NOT an include filter, and it does not bound what gets rewritten: a
18
+ * library component in `node_modules` is never under an entry root, yet it is
19
+ * still crawled and shaken, because a component that IS under one imports it.
20
+ *
21
+ * The soundness contract runs the other way. Every `.svelte` file found is
22
+ * treated as a call-site source, so the union of these roots must cover EVERY
23
+ * call site in the app (docs/ARCHITECTURE.md §4.2). Narrowing `entries` to a
24
+ * subset of the app does not shake less — it hides call sites, and a prop the
25
+ * shaker cannot see being passed is folded away.
10
26
  */
11
- include?: string[];
27
+ entries?: string[];
12
28
  /**
13
- * Components that have a consumer the shake cannot see, so they must never be
14
- * folded or narrowed (docs/ARCHITECTURE.md §4.2). Each entry is a Vite-root-
15
- * relative or absolute path naming EITHER a component file or a directory of
16
- * them — the same "directory or file prefix" basis as {@link include}, no glob.
29
+ * Components whose PROP INTERFACE must be left exactly as written, because they
30
+ * have a consumer the shake cannot see (docs/ARCHITECTURE.md §4.2). Each entry
31
+ * is a Vite-root-relative or absolute path naming EITHER a component file or a
32
+ * directory of them — the same "directory or file prefix" basis as
33
+ * {@link entries}, no glob.
34
+ *
35
+ * What is preserved is the prop interface, NOT the component's presence in the
36
+ * bundle: this is unrelated to Rollup/Vite's `external`, and it never keeps a
37
+ * file out of the bundle or out of the analysis.
38
+ *
39
+ * Reach for it when a consumer lives outside the `.svelte` graph and the shaker
40
+ * cannot observe that call site: a `mount()` behind a NON-literal dynamic
41
+ * `import(expr)`, or a module outside the {@link entries} roots. Consumers
42
+ * reached by a static `.ts`/`.js` import are already found by the non-`.svelte`
43
+ * module scan, so those need no entry here.
17
44
  *
18
- * The non-`.svelte` module scan already escapes components reached by a static
19
- * `.ts`/`.js` import; `external` is the sound fallback for what the scan cannot
20
- * see most importantly a NON-literal dynamic `import(expr)`, or a call site in
21
- * a module outside the include roots.
45
+ * A-semantics listing a component does NOT exclude it from the scan: the file
46
+ * stays fully in the analysis (its own `<Child/>` call sites keep counting toward
47
+ * its children's profiles), and only the component ITSELF has its prop folding /
48
+ * never-passed reporting turned off. It is not a way to make the shaker ignore
49
+ * a file.
22
50
  *
23
- * A-semantics this FREEZES the named component, it does NOT exclude it from the
24
- * scan: the file stays fully in the analysis (its own `<Child/>` call sites keep
25
- * counting toward its children's profiles), and only the component ITSELF has its
26
- * prop folding / never-passed reporting turned off. It is not a way to make the
27
- * shaker ignore a file.
51
+ * Over-listing errs SAFE, the opposite of {@link entries}: a component preserved
52
+ * without needing it is merely shaken less, never wrongly.
28
53
  */
29
- external?: string[];
54
+ preserve?: string[];
30
55
  /**
31
56
  * Per-call-site monomorphization tuning (docs §13.2). Monomorphization is ON
32
57
  * by default because it is bail-safe and never bloats (the measured net-win
package/dist/vite.js CHANGED
@@ -99,6 +99,62 @@ function resolveMono(options) {
99
99
  const overrides = typeof options.monomorphize === 'object' ? options.monomorphize : {};
100
100
  return { ...DEFAULT_MONO_OPTIONS, enabled: true, ...overrides };
101
101
  }
102
+ /**
103
+ * Options renamed in the pre-1.0 cleanup: stale key -> what to write instead and
104
+ * why the old name described the wrong thing. Both renames dropped a name the
105
+ * ecosystem had already spent on a DIFFERENT meaning, so the message has to say
106
+ * more than "renamed" — a user who reaches for the old key is usually carrying
107
+ * the old key's ecosystem meaning with it.
108
+ */
109
+ const RENAMED_OPTIONS = {
110
+ include: 'the "include" option was renamed to "entries": rename `include:` to `entries:`. It was ' +
111
+ 'never a file filter — it lists the directories the component crawl STARTS from, and ' +
112
+ 'everything reachable from them (including library components under node_modules) is ' +
113
+ 'shaken whether or not it sits under one.',
114
+ external: 'the "external" option was renamed to "preserve": rename `external:` to `preserve:`. It ' +
115
+ 'has nothing to do with Rollup/Vite `external` — it never keeps a file out of the bundle. ' +
116
+ 'It names components whose prop interface must be left as written because a consumer the ' +
117
+ 'shaker cannot see (a non-literal dynamic `import(expr)`, a module outside `entries`) ' +
118
+ 'passes props to them.',
119
+ };
120
+ /**
121
+ * Every key {@link ShakerOptions} accepts. The `satisfies` is the point of the
122
+ * table: adding an option to the interface without listing it here fails
123
+ * `tsc --noEmit`, so the accept-list can never drift behind the type and start
124
+ * rejecting a valid config.
125
+ */
126
+ const KNOWN_OPTIONS = {
127
+ entries: true,
128
+ preserve: true,
129
+ monomorphize: true,
130
+ engine: true,
131
+ dev: true,
132
+ parser: true,
133
+ verbose: true,
134
+ };
135
+ /**
136
+ * Reject anything that is not an option we act on. A Vite config is external
137
+ * input, so this is a boundary check, not a courtesy: an ignored key still
138
+ * builds, but what the user configured quietly does not apply — `entries` falls
139
+ * back to the Vite root, `preserve` to an empty set, and that last one ships an
140
+ * over-shaken component.
141
+ *
142
+ * A typo (`preserv:`) fails in exactly that way, which is why unknown keys are
143
+ * rejected on the same footing as the pre-rename keys ({@link RENAMED_OPTIONS})
144
+ * rather than only the ones we happen to have a migration note for. TypeScript's
145
+ * excess-property check only fires on an object literal written inline, so a
146
+ * config built up in a variable — or any JS config — reaches us unguarded.
147
+ */
148
+ function assertValidOptions(options) {
149
+ for (const key of Object.keys(options)) {
150
+ if (Object.hasOwn(RENAMED_OPTIONS, key))
151
+ throw new Error(`[vite-plugin-svelte-shaker] ${RENAMED_OPTIONS[key]}`);
152
+ if (!Object.hasOwn(KNOWN_OPTIONS, key))
153
+ throw new Error(`[vite-plugin-svelte-shaker] unknown option "${key}". Valid options are: ` +
154
+ `${Object.keys(KNOWN_OPTIONS).join(', ')}. Check the spelling — an option we ` +
155
+ `do not read is an option that does not apply.`);
156
+ }
157
+ }
102
158
  /**
103
159
  * Source-level Svelte tree-shaking as a Vite plugin (docs/ARCHITECTURE.md §6).
104
160
  *
@@ -119,6 +175,7 @@ function resolveMono(options) {
119
175
  * variant id (dedup), so they share one compiled module.
120
176
  */
121
177
  export function shaker(options = {}) {
178
+ assertValidOptions(options);
122
179
  const mono = resolveMono(options);
123
180
  let shaken = {};
124
181
  /** Variant request id (`<childPath>?shaker_variant=<n>`) -> residual source. */
@@ -129,22 +186,24 @@ export function shaker(options = {}) {
129
186
  let log = (msg) => console.info(`[svelte-shaker] ${msg}`);
130
187
  let warn = (msg) => console.warn(`[svelte-shaker] ${msg}`);
131
188
  // Surface the escape scan's diagnostics (docs §4.2) as build warnings: modules the
132
- // scan could not parse (a component mounted from one is left un-frozen — a real
133
- // soundness hole), and `external` entries that matched no component (a typo leaves
134
- // the intended component un-frozen). Both are actionable and name the offenders,
189
+ // scan could not parse (a component mounted from one is left unpreserved — a real
190
+ // soundness hole), and `preserve` entries that matched no component (a typo leaves
191
+ // the intended component unpreserved). Both are actionable and name the offenders,
135
192
  // but never fail the build — the shake is still emitted.
136
193
  const reportEscapeDiagnostics = (result) => {
137
194
  if (result.unscannable.length > 0) {
138
- warn(`external call-site scan could not parse ${result.unscannable.length} module(s), so a ` +
139
- `.svelte component mounted from one of them is NOT protected and may be over-shaken. ` +
140
- `If a component is mounted from any of these, add it to the \`external\` option:\n` +
195
+ warn(`could not parse ${result.unscannable.length} non-\`.svelte\` module(s), so any ` +
196
+ `component mounted from one of them is invisible to the shake and may be ` +
197
+ `over-shaken (props it is really passed folded away). If any of these mounts a ` +
198
+ `component, list that component in the \`preserve\` option to keep its props:\n` +
141
199
  result.unscannable.map((f) => ` - ${path.relative(root, f) || f}`).join('\n'));
142
200
  }
143
- if (result.unmatchedExternal.length > 0) {
144
- warn(`\`external\` matched no component for ${result.unmatchedExternal.length} entr` +
145
- `${result.unmatchedExternal.length === 1 ? 'y' : 'ies'} (check the path and that a ` +
146
- `file entry keeps its \`.svelte\` extension); the intended component is NOT frozen:\n` +
147
- result.unmatchedExternal.map((e) => ` - ${e}`).join('\n'));
201
+ if (result.unmatchedPreserve.length > 0) {
202
+ warn(`\`preserve\` matched no component for ${result.unmatchedPreserve.length} entr` +
203
+ `${result.unmatchedPreserve.length === 1 ? 'y' : 'ies'} (check the path, and that a ` +
204
+ `file entry keeps its \`.svelte\` extension); the intended component is NOT ` +
205
+ `preserved, so its props can still be folded:\n` +
206
+ result.unmatchedPreserve.map((e) => ` - ${e}`).join('\n'));
148
207
  }
149
208
  };
150
209
  // Dev (serve) shaking is opt-in (docs §6.2); `null` keeps dev a pass-through.
@@ -203,22 +262,24 @@ export function shaker(options = {}) {
203
262
  async configureServer(server) {
204
263
  if (!devMode)
205
264
  return;
206
- const dirs = (options.include ?? ['.']).map((p) => path.resolve(root, p));
207
- const entries = dirs.flatMap(collectSvelteFiles);
265
+ const dirs = (options.entries ?? ['.']).map((p) => path.resolve(root, p));
266
+ // The engine's entry components, collected from the entry DIRS: `entries`
267
+ // names the roots to crawl from, these are the `.svelte` files under them.
268
+ const entryComponents = dirs.flatMap(collectSvelteFiles);
208
269
  const read = (id) => fs.readFileSync(id, 'utf-8');
209
270
  const underDirs = (file) => dirs.some((d) => file === d || file.startsWith(d + path.sep));
210
271
  // Re-scan the non-`.svelte` modules for `.svelte` call sites and re-apply
211
- // `external` (docs §4.2). Dev uses the same relative-only `fsResolve` the dev
272
+ // `preserve` (docs §4.2). Dev uses the same relative-only `fsResolve` the dev
212
273
  // crawl uses, so the escape scope matches the crawl scope — dev is never more
213
274
  // unsound than build. The sorted-join key lets a change that does not move the
214
275
  // escape set skip the reload.
215
276
  let escapedKey = '';
216
277
  const currentEscaped = async () => {
217
278
  const result = await computeEscapedComponents({
218
- includeDirs: dirs,
279
+ entryDirs: dirs,
219
280
  root,
220
- external: options.external,
221
- components: entries,
281
+ preserve: options.preserve,
282
+ components: entryComponents,
222
283
  resolve: fsResolve,
223
284
  readFile: read,
224
285
  });
@@ -226,7 +287,7 @@ export function shaker(options = {}) {
226
287
  escapedKey = [...result.escaped].sort().join('\n');
227
288
  return result.escaped;
228
289
  };
229
- devShaker = new DevShaker(entries, fsResolve, read, devMode, getParse(), await currentEscaped());
290
+ devShaker = new DevShaker(entryComponents, fsResolve, read, devMode, getParse(), await currentEscaped());
230
291
  shaken = await devShaker.init();
231
292
  const applyDelta = (result) => {
232
293
  for (const [id, code] of Object.entries(result.changed))
@@ -301,9 +362,11 @@ export function shaker(options = {}) {
301
362
  async buildStart() {
302
363
  if (devShaker)
303
364
  return;
304
- const dirs = (options.include ?? ['.']).map((p) => path.resolve(root, p));
305
- const entries = dirs.flatMap(collectSvelteFiles);
306
- if (entries.length === 0) {
365
+ const dirs = (options.entries ?? ['.']).map((p) => path.resolve(root, p));
366
+ // The engine's entry components, collected from the entry DIRS: `entries`
367
+ // names the roots to crawl from, these are the `.svelte` files under them.
368
+ const entryComponents = dirs.flatMap(collectSvelteFiles);
369
+ if (entryComponents.length === 0) {
307
370
  shaken = {};
308
371
  variantSources = new Map();
309
372
  return;
@@ -334,13 +397,13 @@ export function shaker(options = {}) {
334
397
  };
335
398
  // Components used from OUTSIDE the `.svelte` graph must not be folded (docs
336
399
  // §4.2): those a non-`.svelte` module imports (found by the scan) plus any the
337
- // user froze via `external`. Hand the engine that escape set, and surface the
338
- // scan's diagnostics (unparseable modules / unmatched `external`) as warnings.
400
+ // user pinned via `preserve`. Hand the engine that escape set, and surface the
401
+ // scan's diagnostics (unparseable modules / unmatched `preserve`) as warnings.
339
402
  const escapeScan = await computeEscapedComponents({
340
- includeDirs: dirs,
403
+ entryDirs: dirs,
341
404
  root,
342
- external: options.external,
343
- components: entries,
405
+ preserve: options.preserve,
406
+ components: entryComponents,
344
407
  resolve,
345
408
  readFile: read,
346
409
  });
@@ -367,12 +430,12 @@ export function shaker(options = {}) {
367
430
  // Native Rust engine — byte-identical to the JS engine, including
368
431
  // monomorphization.
369
432
  if (mono.enabled) {
370
- const result = await svelteShakerWasmWithMono(wasm, entries, resolve, read, mono, getParse(), escaped);
433
+ const result = await svelteShakerWasmWithMono(wasm, entryComponents, resolve, read, mono, getParse(), escaped);
371
434
  shaken = result.files;
372
435
  variantSources = result.variants;
373
436
  }
374
437
  else {
375
- shaken = await svelteShakerWasm(wasm, entries, resolve, read, getParse(), escaped);
438
+ shaken = await svelteShakerWasm(wasm, entryComponents, resolve, read, getParse(), escaped);
376
439
  variantSources = new Map();
377
440
  }
378
441
  reportSizes(shaken, read, root, options.verbose === true, log);
@@ -381,12 +444,12 @@ export function shaker(options = {}) {
381
444
  if (!mono.enabled) {
382
445
  // JS engine, monomorphization off: byte-for-byte the unused-prop fold /
383
446
  // constant fold / value-set narrowing output.
384
- shaken = await svelteShaker(entries, resolve, read, getParse(), escaped);
447
+ shaken = await svelteShaker(entryComponents, resolve, read, getParse(), escaped);
385
448
  variantSources = new Map();
386
449
  reportSizes(shaken, read, root, options.verbose === true, log);
387
450
  return;
388
451
  }
389
- const result = await svelteShakerWithMono(entries, resolve, read, mono, variantSpecifier, getParse(), escaped);
452
+ const result = await svelteShakerWithMono(entryComponents, resolve, read, mono, variantSpecifier, getParse(), escaped);
390
453
  shaken = result.files;
391
454
  variantSources = new Map();
392
455
  for (const v of result.mono.variants.values())
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-shaker",
3
- "version": "0.13.0",
3
+ "version": "0.14.1",
4
4
  "description": "Tree shaking for Svelte components",
5
5
  "keywords": [
6
6
  "dead-code-elimination",