ivue 2.2.2 → 2.4.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.
@@ -12,6 +12,12 @@ tracking), methods become stable bound functions. Instances stay plain objects.
12
12
  Follow the rules below exactly — every deviation is either a compile error or a
13
13
  silent no-op at runtime.
14
14
 
15
+ The manual reads in three parts: the **`Reactive()` instance world**
16
+ (the class and SFC templates, ownership, typing, watches, stores, keyed
17
+ state), the **static world** (`Static()`, shared stores, and reading
18
+ your own statics — everything from `ivue/extras`), and the **style
19
+ contract** (naming, spacing, the self-review checklist).
20
+
15
21
  ## Setup — ivue must be installed
16
22
 
17
23
  `import { Reactive } from 'ivue'` resolves only when the package is a
@@ -30,7 +36,6 @@ path and skip the install; never add the dependency alongside a vendored copy.
30
36
 
31
37
  ```ts
32
38
  import { Reactive } from 'ivue'; // in this app: 'src/utils/ivue'
33
- import { Static } from 'ivue/extras';
34
39
  import {
35
40
  ref,
36
41
  shallowRef,
@@ -43,10 +48,6 @@ import {
43
48
  import { useProjectStore } from 'src/stores/project.store';
44
49
 
45
50
  class $Box {
46
- static get DEFAULT_HEIGHT() {
47
- return 4;
48
- }
49
-
50
51
  // Constructor runs SYNCHRONOUSLY where you `new` — in setup() that
51
52
  // means the constructor body IS setup code, and the whole toolbox
52
53
  // works here:
@@ -73,7 +74,7 @@ class $Box {
73
74
  // RAW: read AND write via .value. shallowRef for big structures you
74
75
  // REPLACE wholesale.
75
76
  get height() {
76
- return ref((this.constructor as typeof $Box).DEFAULT_HEIGHT);
77
+ return ref(4);
77
78
  }
78
79
  get rows() {
79
80
  return shallowRef<Row[]>([]);
@@ -179,13 +180,18 @@ class $Box {
179
180
  }
180
181
 
181
182
  export namespace Box {
182
- export const $Class = Static($Box); // static anchor — children `extends` this
183
+ export const $Class = $Box; // raw — children `extends` this
183
184
  export let Class = Reactive($Class); // reactive — you `new` this
184
185
  // the type of every unwrapping surface (defineExpose, reactive())
185
186
  export type Instance = typeof Class.Instance;
186
187
  }
187
188
  ```
188
189
 
190
+ A class with NO static members exports exactly this shape. Only a class
191
+ that DECLARES statics anchors them — `export const $Class =
192
+ Static($Box)` — and reads them from instance code through `self`; both
193
+ live in the static-world sections below.
194
+
189
195
  ### The optional `Model` line (domain entity graphs)
190
196
 
191
197
  When classes hold and pass RAW instances of each other — entity
@@ -348,6 +354,23 @@ call site. Rules that keep it clean:
348
354
  nobody keeps it; here a named plain getter costs zero bytes, so there is
349
355
  no excuse. Templates read as prose: bindings, names, and events — never
350
356
  expressions.
357
+ - **The rule covers EVERY binding kind, not just `v-if`** — the common
358
+ leaks are display strings, disabled states, and class objects:
359
+
360
+ | leaked into the template | derived on the class |
361
+ | --- | --- |
362
+ | interpolating `sending ? 'Sending…' : 'Send to ' + recipients.length` | interpolating `model.sendButtonLabel` |
363
+ | `:disabled="!model.canSend \|\| sending"` | `:disabled="model.sendDisabled"` |
364
+ | `:class="{ active: view === tab.name }"` | `:class="{ active: app.isOpen(tab.name) }"` |
365
+ | `row.name \|\| '—'` in a `v-for` cell | `Format.Class.orDash(row.name)` |
366
+ | `:style` width from `(day.count / peak) * 100 + '%'` | `:style` width from `model.barWidth(day)` |
367
+
368
+ Each right-hand form is a prototype member: unit-testable without
369
+ mounting anything, greppable by name, typed, and hot-graftable. The
370
+ one thing that stays in the template is STRUCTURE — `v-if`/`v-else`
371
+ branching on a named condition or a data field (`v-if="entry.nextSlug"`)
372
+ and `v-for` over a collection. Branching on data is structure;
373
+ COMPUTING with data is logic, and logic lives on the class.
351
374
 
352
375
  ## The outliving instance (module singleton, entity)
353
376
 
@@ -364,14 +387,32 @@ class $Session {
364
387
  // Outliving instance: $watch/$watchEffect register in the
365
388
  // instance's lazy effectScope — there is no component scope here
366
389
  // to reap plain watch.
390
+ // WATCHERS live behind a method, not inline in the constructor —
391
+ // the constructor calls it once, and the instance can RESTART its
392
+ // watchers after a keep-state stop (see suspend() below).
367
393
  constructor() {
394
+ this.startWatchers();
395
+ // If constructed INSIDE some scope, auto-wire teardown instead:
396
+ // getCurrentScope() && onScopeDispose(() => this.$stopEffects());
397
+ }
398
+
399
+ startWatchers() {
368
400
  this.$watch(
369
401
  () => this.user.value,
370
402
  (user, previousUser) => this.onUserChanged(user, previousUser),
371
403
  );
372
404
  this.$watchEffect(() => this.persist());
373
- // If constructed INSIDE some scope, auto-wire teardown instead:
374
- // getCurrentScope() && onScopeDispose(() => this.$stopEffects());
405
+ }
406
+
407
+ // SUSPEND / RESUME: { reset: false } stops the watchers ONLY — every
408
+ // cached cell survives with its current value. startWatchers() in a
409
+ // fresh scope resumes. (Default $stopEffects() also CLEARS the cells:
410
+ // the next touch re-runs initializers — disposal is a reset.)
411
+ suspend() {
412
+ this.$stopEffects({ reset: false });
413
+ }
414
+ resume() {
415
+ this.startWatchers();
375
416
  }
376
417
 
377
418
  // CLEANUP composes as an ORDINARY method — no hooks, no reserved
@@ -417,11 +458,13 @@ session.dispose();
417
458
  | ✅ `new X.Class(props, emit)` — raw instance everywhere | ❌ wrap in `reactive(instance)` or any shallow-unwrap view as the standard |
418
459
  | ✅ destructure ALL template-touched Refs/Computeds + element refs, grouped | ❌ destructure plain getters or methods — snapshots a dead value / loses nothing but clarity |
419
460
  | ✅ state bindings in templates; dotted `box.x` only for plain getters/methods | ❌ reach a Ref through the instance in a template — `v-if="box.someRef"` is always-truthy |
461
+ | ✅ labels, disabled states, and class conditions as named getters/methods (`model.sendButtonLabel`, `model.sendDisabled`) | ❌ ternaries, `\|\|`/`&&` chains, comparisons, or string-building inside template expressions |
420
462
  | ✅ `defineExpose(box as X.Instance)` | ❌ `defineExpose(box)` raw — readonly-accessor writes will type-error for consumers |
421
463
  | ✅ constructor runs init; register hooks/watchers there | ❌ add an `init()` method expecting auto-call — ivue never calls it |
422
464
  | ✅ plain `watch` in component-scoped constructors; `$watch` + a `$stopEffects` dispose path for outliving instances | ❌ default to `this.$watch` in a component-scoped class — its scope silently outlives unmount |
423
465
  | ✅ compose cleanup as an ordinary method — `dispose() { /* non-Vue cleanup */ this.$stopEffects(); }` | ❌ expect a teardown hook — ivue auto-calls NOTHING (no `init()`, no `stopEffects()`) |
424
466
  | ✅ a class with static members anchors them: `const $Class = Static($X)` (`ivue/extras`) | ❌ `extends X.Class` — the mutable slot is an eager snapshot of one generation; always extend `$Class` |
467
+ | ✅ instance code reads its own statics through `this.self` (the one cast per class); hoist `const self = this.self` for 2+ reads or any loop | ❌ per-site `(this.constructor as typeof $X)` casts — each one is an unchecked class-name assertion |
425
468
 
426
469
  ## The unwrapping-surface typing invariant
427
470
 
@@ -465,7 +508,11 @@ until mount — use `?.` in watch getters).
465
508
  its leaf reads subscribe directly (non-intuitive but structural).
466
509
  - The source MUST be the FUNCTION form. `watch(instance.plainGetter, cb)` passes a
467
510
  dead snapshot and never fires.
468
- - `$stopEffects()` stops the instance scope and clears cached Refs/Computeds;
511
+ - `$stopEffects()` stops the instance scope and clears cached Refs/Computeds
512
+ (the next touch re-materializes — disposal is a reset);
513
+ `$stopEffects({ reset: false })` stops the WATCHERS only — every cached
514
+ cell survives with its current value, and `startWatchers()` in a fresh
515
+ scope resumes (the suspend/resume pattern above);
469
516
  instances that never `$watch` allocate no scope. There are NO hooks — richer
470
517
  cleanup is an ordinary method that does its work and then calls
471
518
  `$stopEffects()` itself. Every outliving instance needs an OWNER that calls
@@ -483,6 +530,220 @@ until mount — use `?.` in watch getters).
483
530
  - Watch CALLBACKS delegate to methods (the thin-closure rule):
484
531
  `watch(source, (newValue, oldValue) => this.onChanged(newValue, oldValue))`.
485
532
 
533
+ ## computed() and watch callbacks delegate to methods
534
+
535
+ A reactive closure is cached per instance. Keep that closure as a small
536
+ pointer to behavior on the prototype: **closures connect; methods contain
537
+ logic.**
538
+
539
+ ```ts
540
+ // ✅ THIN — the closure only delegates; logic stays named and testable
541
+ get sortedItems() {
542
+ return computed(() => this.sortItems());
543
+ }
544
+ sortItems() {
545
+ return [...this.items.value].sort(byPrice);
546
+ }
547
+
548
+ // ✅ same rule for watch callbacks wired in constructors
549
+ watch(value, (newValue, oldValue) =>
550
+ this.onValueChanged(newValue, oldValue),
551
+ );
552
+
553
+ // ❌ FAT — logic is anonymous and duplicated inside the cached closure
554
+ get sortedItems() {
555
+ return computed(() => [...this.items.value].sort(byPrice));
556
+ }
557
+ ```
558
+
559
+ Also buys: guaranteed-minimum memory (the thin closure captures nothing but
560
+ the instance — a fat closure silently pins any getter-scope local for the
561
+ instance's lifetime) and direct testability (`instance.sortItems()`).
562
+ Reactivity is unaffected — reads inside the method are tracked through the
563
+ computed's evaluation exactly as if inlined.
564
+
565
+ Do NOT "optimize" the arrow away to `computed(this.sortItems)`: it works
566
+ (ivue methods are lazy-bound) but Vue 3.4+ passes the previous value as the
567
+ getter's first argument, so a method that later gains an optional parameter
568
+ silently receives stale data. Always the arrow.
569
+
570
+ `$`-prefixed singleton getters are frozen caches too — keep their bodies to
571
+ a single composable/service call (`return useThing()`), nothing more.
572
+
573
+ ## The store pattern: a singleton behind `use()`, injected by `$`-getter
574
+
575
+ Shared application state (session, navigation, toasts, the current user)
576
+ is a STORE — one ivue class published as a module singleton — never a
577
+ model passed down as a prop. Prop-drilling a shared model
578
+ (`<ChildView :app="app" />`, `constructor(public app: AppModel.Instance)`)
579
+ threads one object through every component and constructor signature it
580
+ crosses; the store pattern deletes the thread.
581
+
582
+ ```ts
583
+ // app/AppStore.ts — the store IS an ivue class; `use()` owns the singleton
584
+ class $AppStore {
585
+ get authenticated() {
586
+ return ref(false);
587
+ }
588
+
589
+ notify(message: string) {
590
+ /* ... */
591
+ }
592
+ }
593
+
594
+ export namespace AppStore {
595
+ export const $Class = $AppStore;
596
+ export let Class = Reactive($Class);
597
+ export type Instance = typeof Class.Instance;
598
+
599
+ let singleton: Instance | null = null;
600
+ export function use(): Instance {
601
+ return (singleton ??= new Class());
602
+ }
603
+ }
604
+ ```
605
+
606
+ Consumers never receive it — they REACH for it:
607
+
608
+ ```ts
609
+ // any model — the `$`-getter caches the store per instance, forever
610
+ class $SubscribersModel {
611
+ protected get $app() {
612
+ return AppStore.use();
613
+ }
614
+
615
+ async refresh() {
616
+ try {
617
+ /* ... */
618
+ } catch (error) {
619
+ this.$app.reportFailure(error);
620
+ }
621
+ }
622
+ }
623
+ ```
624
+
625
+ ```vue
626
+ <script setup lang="ts">
627
+ // any component — call use() directly; no prop, no provide/inject
628
+ import { AppStore } from '../app/AppStore';
629
+
630
+ const app = AppStore.use();
631
+ const { authenticated } = app;
632
+ </script>
633
+
634
+ <template>
635
+ <button v-if="authenticated" @click="app.logout()">Lock</button>
636
+ </template>
637
+ ```
638
+
639
+ Why this shape and not alternatives:
640
+
641
+ - **`use()` is lazy** — the singleton constructs on first touch, after the
642
+ app exists, so module-load order and circular imports stay non-events
643
+ (the same late-read property as every cross-module reference).
644
+ - **The `$`-getter is the injection point** — cached whole, per instance,
645
+ on first read. A model names its dependency once; every method reads
646
+ `this.$app` with zero lookup cost and zero constructor plumbing.
647
+ - **Tests swap the slot, not the callers** — `AppStore.Class = $TestStore`
648
+ before the first `use()` (or reset the singleton) and every consumer
649
+ gets the double through the same seam.
650
+ - A store is component-OUTLIVING by definition: watchers inside it use
651
+ `this.$watch`/`$watchEffect`, never plain `watch`, and lifecycle hooks
652
+ never belong in it.
653
+ - Pass PROPS for what is genuinely per-instance input (a row, a slug, a
654
+ config knob). Reach for the STORE for what is genuinely shared. A prop
655
+ named `app`, `store`, or `session` is the tell that a store is being
656
+ drilled.
657
+
658
+ ## Keyed reactivity — the third state shape
659
+
660
+ Ref-getters express NAMED members; `shallowRef` expresses wholesale-replaced
661
+ structures. When state is KEYED — sparse, unbounded, indexed by ids or
662
+ coordinates unknown until runtime (cells by (row,col), entities by id, rows
663
+ of a stream) — a getter per key is impossible. Hold **collections of
664
+ reactive primitives as plain values** and materialize per observation:
665
+
666
+ ```ts
667
+ class $Sheet {
668
+ // Plain readonly fields — the COLLECTIONS aren't reactive;
669
+ // their VALUES are.
670
+ private readonly cellVersions = new Map<number, Ref<number>>();
671
+
672
+ /**
673
+ * READ path: get-OR-CREATE, then subscribe — observation
674
+ * materializes.
675
+ */
676
+ private trackCell(cellKey: number): void {
677
+ let versionRef = this.cellVersions.get(cellKey);
678
+ if (!versionRef) {
679
+ versionRef = ref(0);
680
+ this.cellVersions.set(cellKey, versionRef);
681
+ }
682
+ // subscribes whatever effect is currently running
683
+ void versionRef.value;
684
+ }
685
+
686
+ /**
687
+ * WRITE path: PEEK-ONLY — unobserved keys allocate nothing,
688
+ * notify no one.
689
+ */
690
+ private bumpCell(cellKey: number): void {
691
+ const versionRef = this.cellVersions.get(cellKey);
692
+ if (versionRef) versionRef.value++;
693
+ }
694
+ }
695
+ ```
696
+
697
+ The read/write ASYMMETRY is the pattern: reads get-or-create (cost is priced
698
+ by observation), while writes to unobserved keys allocate no signal. Rules that keep it honest:
699
+
700
+ - Ground truth lives in plain storage (typed arrays, Maps); the refs are
701
+ VERSION SIGNALS, not value holders — bump to invalidate, readers re-derive.
702
+ - Per-key cached computeds follow the same shape (`Map<key, ComputedRef>`),
703
+ bodies delegating to methods (the thin-closure rule), and MUST have an explicit release/
704
+ eviction path — keyed overlays cannot GC on their own (the Map holds
705
+ strong refs; attached watchers subscribe permanently).
706
+ - Coarse tiers are the same pattern at lower resolution: one ref covering
707
+ many keys (a block of rows, a whole-collection version counter) for
708
+ subscribers that span many keys — one integer where naive design puts a
709
+ million nodes.
710
+ - No wrapper needed: `ref()`/`computed()` are first-class values from
711
+ `@vue/reactivity`; Maps of them inside a `Reactive()` class compose with
712
+ everything (methods stay bound and `$watch` works).
713
+
714
+ | state shape | expression |
715
+ | ---------------------------- | ----------------------------------------------------- |
716
+ | named members | `get x() { return ref(v) }` |
717
+ | wholesale-replaced structure | `get rows() { return shallowRef<Row[]>([]) }` |
718
+ | keyed / sparse / unbounded | `Map<key, Ref>` + get-or-create track, peek-only bump |
719
+
720
+ Same invariant at three granularities — nothing exists until observed: getters
721
+ price MEMBERS, keyed collections price KEYS. (Proven at 20M cells / 4.7
722
+ bytes each — see the flyweight grid.)
723
+
724
+ ## Generic classes (brief)
725
+
726
+ `ReactiveClass<C>` cannot carry `<T>` through (no higher-kinded types), but
727
+ `Reactive(X) === X` by identity — so cast `Class` back to the raw
728
+ constructor and apply `ReactiveInstance` explicitly for `Instance`:
729
+
730
+ ```ts
731
+ class $Scroller<T extends BaseItem> {
732
+ get items() {
733
+ return ref<T[]>([]);
734
+ }
735
+ }
736
+
737
+ export namespace Scroller {
738
+ export const $Class = $Scroller;
739
+ // the cast keeps <T> available at `new` sites
740
+ export let Class = Reactive($Class) as unknown as typeof $Class;
741
+ export type Instance<T extends BaseItem> =
742
+ ReactiveInstance<$Scroller<T>>;
743
+ }
744
+ // consumer of a template ref: ShallowUnwrapRef<Scroller.Instance<T>>
745
+ ```
746
+
486
747
  ## Circular references resolve by construction
487
748
 
488
749
  The hoisted-namespace + getter convention makes late cross-module references
@@ -509,8 +770,51 @@ files, git, parsers, clocks: never constructed, only called and swapped — use
509
770
  router/queue/listener callback, bound to the RECEIVING class.
510
771
  - **Get-only statics named `$…` compute once PER RECEIVER.** The `$` prefix
511
772
  promises stable identity, NOT immutability — a mutable memo table is a
512
- legitimate `$`-cache. Non-`$` static getters stay LIVE: those are the knobs
513
- test subclasses pinch.
773
+ legitimate `$`-cache. Non-`$` static getters stay LIVE: the settings a
774
+ subclass or test double overrides.
775
+ - **A SHARED STORE never lives in receiver-space.** Per-receiver caching
776
+ means a subclass reading `this.$store` silently forks a fresh copy — the
777
+ registry-fork trap. The store is a `static readonly` FIELD on the
778
+ declaring class — one reference, inherited through the prototype chain,
779
+ never receiver-cached — so every receiver read (`this.$store`,
780
+ `this.constructor.$store`) resolves to the one store with no special
781
+ case anywhere; the `$`-getter pins by returning the field:
782
+ ```ts
783
+ class $Registry {
784
+ protected static readonly sharedRegistrations = new Map<object, Registration>();
785
+ protected static get $registrations() {
786
+ return this.sharedRegistrations; // the field IS the pin
787
+ }
788
+ }
789
+ ```
790
+ Two questions place every static value:
791
+
792
+ 1. **Should a subclass get its own copy?** Yes → per-receiver
793
+ `$`-cache. That is what memos and per-class tuning want: forking on
794
+ subclass is the feature. No → it is a SHARED store (a registry, a
795
+ ledger — forking is the bug), and it lives in a `static readonly`
796
+ field as above.
797
+ 2. **Shared store: can its initializer run at module load?** A field
798
+ initializer runs while modules are still loading, so it may only
799
+ hold a dependency-free value — a bare `new Map()`, a literal. The
800
+ moment construction needs ANOTHER module's class, the field holds a
801
+ `LazyShared` cell instead (`import { LazyShared } from
802
+ 'ivue/extras'`), and the `$`-getter reads through it:
803
+ ```ts
804
+ protected static readonly sharedBackend = new LazyShared(
805
+ () => new SearchBackend.Class(),
806
+ );
807
+ protected static get $backend() {
808
+ return this.sharedBackend.value;
809
+ }
810
+ ```
811
+ Each step is safe on its own terms. Storing the cell eagerly is
812
+ safe because a thunk evaluates nothing at load. Running the thunk
813
+ on first read is safe because by then every import cycle has
814
+ resolved. And sharing is safe because the memoized value lives
815
+ INSIDE the cell — every access path, subclass receivers and
816
+ per-receiver `$`-caches over the cell included, converges on the
817
+ one constructed singleton.
514
818
 
515
819
  THE ANCHOR RULE — a class that declares static members wraps them ONCE, at
516
820
  `$Class`, so subclasses and test doubles inherit working semantics by
@@ -577,85 +881,56 @@ Take the first rung that applies:
577
881
  ```ts
578
882
  protected get tooltipDwellSeconds() { return 0.4; }
579
883
  ```
580
- 2. **Something outside reads it** (tests pinching the knob, another class)
581
- → keep the static and read it live off the receiver:
884
+ 2. **Something outside reads it** (a test overriding the knob, another class)
885
+ → keep the static and read it through **`self`** the one cast per
886
+ class, declared beside the statics it types — DIRECTLY at each call
887
+ site:
582
888
  ```ts
583
- protected get tooltipDwellSeconds() {
584
- return (this.constructor as typeof $Tooltip).TOOLTIP_DWELL_SECONDS;
889
+ protected get self() {
890
+ return this.constructor as typeof $Tooltip;
891
+ }
892
+
893
+ show() {
894
+ this.dwellTimer.start(this.self.TOOLTIP_DWELL_SECONDS);
585
895
  }
586
896
  ```
587
- `this.constructor` is the actual class: the subclass when subclassed, and
588
- an engine class that INHERITS `$Class` for a plain reactive instance, so
589
- statics resolve in both cases. TypeScript types `constructor` as
590
- `Function`, so the one cast is required and is the honest cost.
897
+ An instance getter over a static earns its place when it genuinely
898
+ derives mixing in instance state or transforming the value; a
899
+ plain read stays a direct `this.self.X` at the call site, so the
900
+ knob keeps one name and one override surface (the static).
901
+ `this.constructor` is the actual class — the subclass when subclassed,
902
+ and an engine class that INHERITS `$Class` for a plain reactive
903
+ instance — so statics resolve late-bound in both cases.
904
+ TypeScript types `constructor` as bare
905
+ `Function`, so ONE cast is unavoidable; `self` is where it lives.
906
+ Never scatter per-site `(this.constructor as typeof $X)` casts: each
907
+ is an unchecked assertion that the class name is right, and the
908
+ copy-paste error it invites typechecks silently against the wrong
909
+ statics. Rules that keep `self` honest:
910
+ - **Plain getter, never `$self`** — a `$`-cache would spend a
911
+ per-instance slot on what `this.constructor` hands back for free.
912
+ - **One read → `this.self.X` inline. Two or more reads, or any
913
+ loop → hoist:** `const self = this.self;` as the first line, then
914
+ `self.X` throughout. Measured (Node 26): the de-opted `self` getter
915
+ costs ~2 ns/read over an inline cast — noise for a single read —
916
+ while the hoisted form runs at ~0.4 ns/iter in loops, CHEAPER than
917
+ the inline cast, because the engine hoists the class as a loop
918
+ constant.
919
+ - **A subclass that adds statics redeclares `self`** with its own
920
+ `typeof $Sub` (a covariant override); a subclass that only tunes
921
+ inherited statics needs nothing — `self` is already late-bound.
922
+ - **`self` is NOT the namespace slot.** `this.self` is the class you
923
+ were constructed from; `Namespace.Class` is the live mutable slot a
924
+ kernel may have re-pointed since. Receiver statics (constants,
925
+ per-class tuning, `$`-caches) read through `self`; late-bound
926
+ capability dispatch reads through `Namespace.Class`. Blurring them
927
+ trades typo bugs for staleness bugs.
591
928
  3. **Overriding must NOT happen** → name the class directly,
592
929
  `$Tooltip.TOOLTIP_DWELL_SECONDS`, and let the code say so.
593
930
 
594
931
  Never introduce a `protected get <ClassName>()` self-reference getter. It is a
595
- cast wearing a getter costume: it looks live and is not.
596
-
597
- ## Generic classes (brief)
598
-
599
- `ReactiveClass<C>` cannot carry `<T>` through (no higher-kinded types), but
600
- `Reactive(X) === X` by identity — so cast `Class` back to the raw
601
- constructor and apply `ReactiveInstance` explicitly for `Instance`:
602
-
603
- ```ts
604
- class $Scroller<T extends BaseItem> {
605
- get items() {
606
- return ref<T[]>([]);
607
- }
608
- }
609
-
610
- export namespace Scroller {
611
- export const $Class = $Scroller;
612
- // the cast keeps <T> available at `new` sites
613
- export let Class = Reactive($Class) as unknown as typeof $Class;
614
- export type Instance<T extends BaseItem> =
615
- ReactiveInstance<$Scroller<T>>;
616
- }
617
- // consumer of a template ref: ShallowUnwrapRef<Scroller.Instance<T>>
618
- ```
619
-
620
- ## computed() and watch callbacks delegate to methods
621
-
622
- A reactive closure is cached per instance. Keep that closure as a small
623
- pointer to behavior on the prototype: **closures connect; methods contain
624
- logic.**
625
-
626
- ```ts
627
- // ✅ THIN — the closure only delegates; logic stays named and testable
628
- get sortedItems() {
629
- return computed(() => this.sortItems());
630
- }
631
- sortItems() {
632
- return [...this.items.value].sort(byPrice);
633
- }
634
-
635
- // ✅ same rule for watch callbacks wired in constructors
636
- watch(value, (newValue, oldValue) =>
637
- this.onValueChanged(newValue, oldValue),
638
- );
639
-
640
- // ❌ FAT — logic is anonymous and duplicated inside the cached closure
641
- get sortedItems() {
642
- return computed(() => [...this.items.value].sort(byPrice));
643
- }
644
- ```
645
-
646
- Also buys: guaranteed-minimum memory (the thin closure captures nothing but
647
- the instance — a fat closure silently pins any getter-scope local for the
648
- instance's lifetime) and direct testability (`instance.sortItems()`).
649
- Reactivity is unaffected — reads inside the method are tracked through the
650
- computed's evaluation exactly as if inlined.
651
-
652
- Do NOT "optimize" the arrow away to `computed(this.sortItems)`: it works
653
- (ivue methods are lazy-bound) but Vue 3.4+ passes the previous value as the
654
- getter's first argument, so a method that later gains an optional parameter
655
- silently receives stale data. Always the arrow.
656
-
657
- `$`-prefixed singleton getters are frozen caches too — keep their bodies to
658
- a single composable/service call (`return useThing()`), nothing more.
932
+ cast wearing a getter costume: it looks live and is not — `self` is its
933
+ honest replacement.
659
934
 
660
935
  ## Naming: unfold to the domain
661
936
 
@@ -688,72 +963,6 @@ like prose — don't ruin it with letter soup:
688
963
  // ✅ watch(value, (newValue, oldValue) => this.onChanged(…))
689
964
  ```
690
965
 
691
- ## Keyed reactivity — the third state shape
692
-
693
- Ref-getters express NAMED members; `shallowRef` expresses wholesale-replaced
694
- structures. When state is KEYED — sparse, unbounded, indexed by ids or
695
- coordinates unknown until runtime (cells by (row,col), entities by id, rows
696
- of a stream) — a getter per key is impossible. Hold **collections of
697
- reactive primitives as plain values** and materialize per observation:
698
-
699
- ```ts
700
- class $Sheet {
701
- // Plain readonly fields — the COLLECTIONS aren't reactive;
702
- // their VALUES are.
703
- private readonly cellVersions = new Map<number, Ref<number>>();
704
-
705
- /**
706
- * READ path: get-OR-CREATE, then subscribe — observation
707
- * materializes.
708
- */
709
- private trackCell(cellKey: number): void {
710
- let versionRef = this.cellVersions.get(cellKey);
711
- if (!versionRef) {
712
- versionRef = ref(0);
713
- this.cellVersions.set(cellKey, versionRef);
714
- }
715
- // subscribes whatever effect is currently running
716
- void versionRef.value;
717
- }
718
-
719
- /**
720
- * WRITE path: PEEK-ONLY — unobserved keys allocate nothing,
721
- * notify no one.
722
- */
723
- private bumpCell(cellKey: number): void {
724
- const versionRef = this.cellVersions.get(cellKey);
725
- if (versionRef) versionRef.value++;
726
- }
727
- }
728
- ```
729
-
730
- The read/write ASYMMETRY is the pattern: reads get-or-create (cost is priced
731
- by observation), while writes to unobserved keys allocate no signal. Rules that keep it honest:
732
-
733
- - Ground truth lives in plain storage (typed arrays, Maps); the refs are
734
- VERSION SIGNALS, not value holders — bump to invalidate, readers re-derive.
735
- - Per-key cached computeds follow the same shape (`Map<key, ComputedRef>`),
736
- bodies delegating to methods (the thin-closure rule), and MUST have an explicit release/
737
- eviction path — keyed overlays cannot GC on their own (the Map holds
738
- strong refs; attached watchers subscribe permanently).
739
- - Coarse tiers are the same pattern at lower resolution: one ref covering
740
- many keys (a block of rows, a whole-collection version counter) for
741
- subscribers that span many keys — one integer where naive design puts a
742
- million nodes.
743
- - No wrapper needed: `ref()`/`computed()` are first-class values from
744
- `@vue/reactivity`; Maps of them inside a `Reactive()` class compose with
745
- everything (methods stay bound and `$watch` works).
746
-
747
- | state shape | expression |
748
- | ---------------------------- | ----------------------------------------------------- |
749
- | named members | `get x() { return ref(v) }` |
750
- | wholesale-replaced structure | `get rows() { return shallowRef<Row[]>([]) }` |
751
- | keyed / sparse / unbounded | `Map<key, Ref>` + get-or-create track, peek-only bump |
752
-
753
- Same invariant at three granularities — nothing exists until observed: getters
754
- price MEMBERS, keyed collections price KEYS. (Proven at 20M cells / 4.7
755
- bytes each — see the flyweight grid.)
756
-
757
966
  ## Spacing is information
758
967
 
759
968
  Contiguity says "same kind of thing"; a blank line says "the kind changes,
@@ -841,5 +1050,6 @@ convention and check it in review.
841
1050
  - [ ] Identifiers are unfolded to domain words (`row`/`col`/`cell`/`cellValue`/`versionRef`…), loop indices and specs included — no single-letter names, no name meaning different things in different methods.
842
1051
  - [ ] Keyed/sparse state uses the Map-of-refs shape (get-or-create on read, peek-only bump on write, explicit release path) — never one getter per key, never a deep `reactive()` collection.
843
1052
  - [ ] Static members are anchored (`const $Class = Static($X)`); `$`-prefixed static getters are compute-once-per-receiver caches, non-`$` statics stay live knobs, and inheritance extends `$Class` — never the mutable `Class`.
1053
+ - [ ] Instance reads of own statics go through `this.self` (declared once per class needing it, cast to `typeof $X`, plain getter never `$self`); 2+ reads or loops hoist `const self = this.self`; no per-site `this.constructor` casts; `Namespace.Class` reads stay reserved for late-bound capability dispatch.
844
1054
  - [ ] Static members precede the constructor; the constructor precedes state, prop, and derived getters; methods come last.
845
1055
  - [ ] Spacing carries meaning: declaration-like getters contiguous within their group; blank lines only where a doc comment / multi-line body / category boundary begins; methods always separated.