ivue 1.5.8 → 2.0.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.
@@ -0,0 +1,726 @@
1
+ ---
2
+ name: ivue
3
+ description: Use when writing or editing ivue `Reactive()` classes, converting a Vue component or composable to ivue, or resolving any `.value`-in-template, `defineExpose`/`reactive()` instance-typing, `ReactiveInstance`/`Instance`, `$watch`/`$watchEffect`, or namespace-export question — the operating manual for Vue 3 class-based reactivity where state is ref-getters, derived values are plain getters, and Refs/Computeds are `.value` everywhere.
4
+ ---
5
+
6
+ # ivue `Reactive`
7
+
8
+ Author reactive Vue 3 logic as a plain `class $X`, then export `Class = Reactive($Class)` through `namespace X`.
9
+ The engine transforms the prototype once: ref-returning getters become cached
10
+ Refs/Computeds, plain getters de-optimize to native getters (reactive via leaf
11
+ tracking), methods become stable bound functions. Instances stay plain objects.
12
+ Follow the rules below exactly — every deviation is either a compile error or a
13
+ silent no-op at runtime.
14
+
15
+ ## Setup — ivue must be installed
16
+
17
+ `import { Reactive } from 'ivue'` resolves only when the package is a
18
+ dependency. Before writing ivue code, check `package.json` for `ivue`; if it
19
+ is missing, install it with the project's package manager:
20
+
21
+ ```sh
22
+ npm install ivue # or: yarn add ivue / pnpm add ivue / bun add ivue
23
+ ```
24
+
25
+ Some apps vendor the engine instead — a local module such as
26
+ `src/utils/ivue.ts` re-exporting `Reactive`. If one exists, import from that
27
+ path and skip the install; never add the dependency alongside a vendored copy.
28
+
29
+ ## The class template (copy this shape)
30
+
31
+ ```ts
32
+ import { Reactive } from 'ivue'; // in this app: 'src/utils/ivue'
33
+ import {
34
+ ref,
35
+ shallowRef,
36
+ computed,
37
+ watch,
38
+ onMounted,
39
+ toRef,
40
+ type Ref,
41
+ } from 'vue';
42
+ import { useProjectStore } from 'src/stores/project.store';
43
+
44
+ class $Box {
45
+ // Constructor runs SYNCHRONOUSLY where you `new` — in setup() that
46
+ // means the constructor body IS setup code, and the whole toolbox
47
+ // works here:
48
+ // - plain watch/watchEffect land in the COMPONENT's scope (reaped
49
+ // on unmount);
50
+ // - lifecycle hooks (onMounted, onUnmounted, …) register against
51
+ // the mounting component — full lifecycle access, zero wiring;
52
+ // - callbacks delegate to methods (the thin-closure rule).
53
+ // (this.$watch is ONLY for instances that OUTLIVE the component —
54
+ // see the singleton variant below. Lifecycle hooks NEVER belong in
55
+ // those.)
56
+ constructor(
57
+ public props: BoxProps,
58
+ public emit: BoxEmits,
59
+ ) {
60
+ watch(
61
+ () => this.height.value,
62
+ (height, oldHeight) => this.onResize(height, oldHeight),
63
+ );
64
+ onMounted(() => this.focusBox());
65
+ }
66
+
67
+ // MUTABLE STATE — getter returning ref()/shallowRef(). `this` is
68
+ // RAW: read AND write via .value. shallowRef for big structures you
69
+ // REPLACE wholesale.
70
+ get height() {
71
+ return ref(4);
72
+ }
73
+ get rows() {
74
+ return shallowRef<Row[]>([]);
75
+ } // deep mutations do NOT trigger
76
+
77
+ // TEMPLATE-REF TARGET — a ref(null); the SFC destructures it for
78
+ // ref="boxEl".
79
+ get boxEl() {
80
+ return ref<HTMLElement | null>(null);
81
+ }
82
+
83
+ // PROPS Pattern — plain getters, one per prop the class consumes.
84
+ // Reactively tracked through the props proxy (leaf tracking).
85
+ get width() {
86
+ return this.props.width;
87
+ }
88
+ get title() {
89
+ return this.props.title;
90
+ }
91
+ get isDisabled() {
92
+ return this.props.disabled;
93
+ }
94
+ get items() {
95
+ return toRef(() => this.props.items);
96
+ } // when you need a ref handle
97
+
98
+ // The pattern's extra capability: refine the SUPPLIED prop into
99
+ // the prop the template actually needs — mixing other props, state,
100
+ // and constants, all still leaf-tracked. The template reads the
101
+ // refinement, never the raw prop; the prop is an INPUT to the
102
+ // model, not wired to the view.
103
+ get displayTitle() {
104
+ return this.title || `Box ${this.width}×${this.height.value}`;
105
+ }
106
+
107
+ // DERIVED — PLAIN getter, NO computed().
108
+ // Reactive via leaf tracking; 0 bytes/instance.
109
+ get area() {
110
+ // prop × ref — both leaf-tracked
111
+ return this.width * this.height.value;
112
+ }
113
+ get widthPx() {
114
+ return this.width + 'px';
115
+ }
116
+
117
+ // computed() — SURGICAL opt-in only: expensive work,
118
+ // render-suppression by value-equality, or a stable ref handle for
119
+ // watch/props (~300 bytes/instance). THIN closures (see "computed()
120
+ // and watch callbacks delegate to methods"): the computed only
121
+ // dials a method — logic stays on the prototype, directly testable,
122
+ // minimum footprint.
123
+ get sortedRows() {
124
+ return computed(() => this.sortRows());
125
+ }
126
+ get celsius() {
127
+ return ref(20);
128
+ }
129
+ get fahrenheit() {
130
+ return computed({
131
+ get: () => this.celsiusToFahrenheit(),
132
+ set: (fahrenheit: number) => this.setFromFahrenheit(fahrenheit),
133
+ }); // writable computed — the only way to give a COMPUTED a setter.
134
+ // A native `get x() / set x(value)` accessor pair works too;
135
+ // pick the computed form when the member must be a ref handle
136
+ // (v-model target, watch source, destructured state binding).
137
+ }
138
+
139
+ // STORE / COMPOSABLE — `$`-getter caches WHOLE, forever, per
140
+ // instance. Resolves on first touch (after Pinia/app ready);
141
+ // circular-import safe.
142
+ private get $project() {
143
+ return useProjectStore();
144
+ }
145
+ get projectId() {
146
+ return this.$project.projectId;
147
+ }
148
+
149
+ // CONSTANTS / CONFIG — plain fields ONLY. A plain field written
150
+ // from a method triggers NOTHING (no Ref/Computed, no dependency
151
+ // edge). Never store mutable state here.
152
+ baseWidth = 400;
153
+
154
+ // METHODS — plain; engine-binds to raw (stable identity, safe as
155
+ // handlers). Reactive-closure bodies above delegate HERE (the
156
+ // thin-closure rule).
157
+ grow() {
158
+ this.height.value++;
159
+ }
160
+
161
+ focusBox() {
162
+ this.boxEl.value?.focus();
163
+ }
164
+
165
+ sortRows() {
166
+ return [...this.rows.value].sort(byScore);
167
+ }
168
+
169
+ celsiusToFahrenheit() {
170
+ return (this.celsius.value * 9) / 5 + 32;
171
+ }
172
+ setFromFahrenheit(fahrenheit: number) {
173
+ this.celsius.value = ((fahrenheit - 32) * 5) / 9;
174
+ }
175
+
176
+ onResize(height: number, oldHeight: number) {
177
+ /* ... */
178
+ }
179
+ }
180
+
181
+ export namespace Box {
182
+ export const $Class = $Box; // raw — children `extends` this
183
+ export let Class = Reactive($Class); // reactive — you `new` this
184
+ // the type of every unwrapping surface (defineExpose, reactive())
185
+ export type Instance = typeof Class.Instance;
186
+ }
187
+ ```
188
+
189
+ ### The optional `Model` line (domain entity graphs)
190
+
191
+ When classes hold and pass RAW instances of each other — entity
192
+ collections, method parameters, factory returns — the namespace grows a
193
+ fourth line:
194
+
195
+ ```ts
196
+ export namespace Task {
197
+ export const $Class = $Task;
198
+ export let Class = Reactive($Class);
199
+ // raw-instance type — collections, parameters, returns
200
+ export type Model = InstanceType<typeof Class>;
201
+ // the type of every unwrapping surface (defineExpose, reactive())
202
+ export type Instance = typeof Class.Instance;
203
+ }
204
+ ```
205
+
206
+ `Model` is the raw-instance type (Refs stay Refs; `.value` access) —
207
+ use it for `shallowRef<Task.Model[]>` collections and
208
+ `workloadPercent(member: Member.Model)` parameters. `Instance` remains
209
+ ONLY for unwrapping surfaces (defineExpose, reactive(), template refs);
210
+ never type a raw collection with it.
211
+
212
+ ## The SFC wiring template (copy this shape)
213
+
214
+ ```vue
215
+ <script lang="ts" setup>
216
+ import { Box } from './Box';
217
+
218
+ const props = withDefaults(defineProps<BoxProps>(), { width: 400 });
219
+ const emit = defineEmits<BoxEmits>();
220
+
221
+ // ONE raw instance — the same object drives template, emits
222
+ // payloads, and expose. No reactive() wrapper, no unwrap view. The
223
+ // constructor runs init in setup context.
224
+ const box = new Box.Class(props, emit);
225
+
226
+ // THE STATE DESTRUCTURE — one statement, grouped. Every Ref/Computed
227
+ // the template touches is listed here; each binding IS the cached
228
+ // cell (stable identity), and setup bindings unwrap uniformly in
229
+ // EVERY template position. NEVER destructure plain getters or
230
+ // methods (snapshots a dead value).
231
+ const {
232
+ // state refs
233
+ height,
234
+ celsius,
235
+ // computed refs
236
+ sortedRows,
237
+ fahrenheit,
238
+ // element refs
239
+ boxEl,
240
+ } = box;
241
+
242
+ // Type the expose surface through Instance — it strips readonly so
243
+ // ref-writes typecheck.
244
+ defineExpose(box as Box.Instance);
245
+ </script>
246
+
247
+ <template>
248
+ <!-- State bindings — reads AND writes compiler-unwrapped.
249
+ fahrenheit is the writable computed: v-model writes through
250
+ its setter. -->
251
+ <input
252
+ ref="boxEl"
253
+ v-model.number="fahrenheit"
254
+ :disabled="box.isDisabled"
255
+ />
256
+ <div v-if="height > 4">
257
+ {{ box.displayTitle }} — {{ celsius }}°C is {{ fahrenheit }}°F
258
+ </div>
259
+ <ul :style="{ width: box.widthPx }">
260
+ <li v-for="row in sortedRows" :key="row.id">{{ row.name }}</li>
261
+ </ul>
262
+ <!-- Plain getters and methods: DOTTED on the instance, no .value -->
263
+ <button @click="box.grow()">grow — area {{ box.area }}</button>
264
+ </template>
265
+ ```
266
+
267
+ ## One template, one logic owner
268
+
269
+ Every behavioral SFC has exactly one ivue class as its template logic owner.
270
+ `<script setup>` is the wiring boundary only:
271
+
272
+ - import dependencies;
273
+ - call compiler macros (`defineProps`, `defineEmits`, `defineExpose`);
274
+ - construct `new X.Class(...)` once;
275
+ - destructure the Ref/Computed bindings the template consumes.
276
+
277
+ Do not place component-local `ref`, `computed`, `watch`, lifecycle hooks, or
278
+ free functions beside that instance. State belongs in ref-getters, derivations
279
+ belong in plain getters, setup work belongs in the constructor, and event
280
+ handlers belong in methods — even when the handler only normalizes a DOM event
281
+ before delegating to a domain model.
282
+
283
+ When building on a class-backed component, **extend its class, not its
284
+ `<script setup>`**. Add behavior to the existing class when it belongs to the
285
+ same component contract. When it is a real specialization, subclass the raw
286
+ class and publish the normal namespace:
287
+
288
+ ```ts
289
+ class $SearchBox extends Box.$Class {
290
+ clearSearch() {
291
+ this.search.value = '';
292
+ }
293
+ }
294
+
295
+ export namespace SearchBox {
296
+ export const $Class = $SearchBox;
297
+ export let Class = Reactive($Class);
298
+ export type Instance = typeof Class.Instance;
299
+ }
300
+ ```
301
+
302
+ Never create a parallel behavior layer of setup functions around an existing
303
+ class. That splits ownership, hides behavior from inheritance, and makes the
304
+ template depend on two architectures.
305
+
306
+ A genuinely markup-only leaf may remain classless; do not manufacture an
307
+ empty class for static presentation. The moment the component owns state,
308
+ derivation, setup behavior, or an event handler, it has crossed the boundary
309
+ and needs one class.
310
+
311
+ The template's two access styles carry meaning: **a state binding = a destructured Ref/Computed**, **dotted `box.x` = a derivation or an
312
+ action** (plain getter / method) — the class's own anatomy, visible at the
313
+ call site. Rules that keep it clean:
314
+
315
+ - The destructure is TOTAL: every Ref/Computed the template touches is
316
+ destructured; a Ref is NEVER reached through the instance in the template
317
+ (interpolating `box.someRef` renders via display-unwrap, but
318
+ `v-if="box.someRef"` is always-truthy — the seam the total destructure abolishes).
319
+ - In the `<script setup>` BODY, destructured bindings are refs — use
320
+ `.value` there as everywhere else. Inside `<template>` only, the compiler
321
+ unwraps them.
322
+ - **The remaining `.value` boundary:** top-level component state is
323
+ destructured and auto-unwrapped. Collection items and slot props are nested
324
+ values, so Vue does not auto-unwrap their Ref fields; use
325
+ `item.title.value`. This is ivue's principal syntax tradeoff, preserving
326
+ direct, allocation-free reads where lists are hottest.
327
+ - Perf escape (measured): a METHOD called in a render-hot path (per row of
328
+ a large v-for) may be destructured — methods are identity-stable and the
329
+ hoisted call runs at closure speed (~1.4 vs ~4 ns dotted). Reserve it for
330
+ profiled hot paths; everywhere else methods stay dotted (the naming signal).
331
+ - **Instance-swapping components keep dotted access**: if the component
332
+ replaces its instance (`model.value = new X.Class()`), destructured
333
+ bindings would go stale — don't destructure what you swap.
334
+ - **Don't shadow props.** A destructured state binding with the same name as
335
+ a `defineProps` prop silently shadows it in the template (setup bindings
336
+ win). Rare by construction: the class consumes props through prop-getters,
337
+ so prop-derived values stay DOTTED (`box.width`, `box.widthPx`) and never
338
+ compete with state-binding names.
339
+ - **No logic in template expressions — name it as a derived getter.**
340
+ `v-if="items.length && !loading && mode === 'edit'"` is an anti-pattern:
341
+ the condition has no name, duplicates across call sites, and its pieces
342
+ can't be tested. Every combination, comparison or ternary lives on the
343
+ class as a PLAIN getter whose name says what the condition MEANS —
344
+ `v-if="box.canEditItems"`. When the condition takes an argument (per-item
345
+ in a `v-for`), the same rule wears its method form —
346
+ `v-if="media.fileExists(index)"` — still a name, still no inline logic.
347
+ In ordinary Vue this discipline costs a `computed()` per condition, so
348
+ nobody keeps it; here a named plain getter costs zero bytes, so there is
349
+ no excuse. Templates read as prose: bindings, names, and events — never
350
+ expressions.
351
+
352
+ ## The outliving instance (module singleton, entity)
353
+
354
+ For an instance that OUTLIVES any component — a module singleton, an entity
355
+ created in a callback — watchers go in the instance's OWN scope, and the
356
+ owner of its lifetime disposes it:
357
+
358
+ ```ts
359
+ class $Session {
360
+ get user() {
361
+ return ref<User | null>(null);
362
+ }
363
+
364
+ // Outliving instance: $watch/$watchEffect register in the
365
+ // instance's lazy effectScope — there is no component scope here
366
+ // to reap plain watch.
367
+ constructor() {
368
+ this.$watch(
369
+ () => this.user.value,
370
+ (user, previousUser) => this.onUserChanged(user, previousUser),
371
+ );
372
+ this.$watchEffect(() => this.persist());
373
+ // If constructed INSIDE some scope, auto-wire teardown instead:
374
+ // getCurrentScope() && onScopeDispose(() => this.$stopEffects());
375
+ }
376
+
377
+ // CLEANUP composes as an ORDINARY method — no hooks, no reserved
378
+ // names, ivue never auto-calls your code. Do the non-Vue work
379
+ // (sockets, listeners from composables), then reset the engine.
380
+ dispose() {
381
+ this.disconnect();
382
+ this.$stopEffects();
383
+ }
384
+
385
+ onUserChanged(user: User | null, previousUser: User | null) {
386
+ /* ... */
387
+ }
388
+ persist() {
389
+ /* ... */
390
+ }
391
+ disconnect() {
392
+ /* ... */
393
+ }
394
+ }
395
+
396
+ export namespace Session {
397
+ export const $Class = $Session; // raw — children `extends` this
398
+ export let Class = Reactive($Class); // reactive — you `new` this
399
+ // the type of every unwrapping surface (defineExpose, reactive())
400
+ export type Instance = typeof Class.Instance;
401
+ }
402
+
403
+ // The owner disposes — the class's own method, like any other:
404
+ session.dispose();
405
+ ```
406
+
407
+ ## DO / NEVER
408
+
409
+ | DO | NEVER |
410
+ | --- | --- |
411
+ | ✅ `class $X` + `export namespace X { $Class; Class = Reactive($Class); Instance }` | ❌ export a bare `Reactive(class {...})` for anything that grows a parent/dependent |
412
+ | ✅ mutable state = `get x() { return ref(v) }` | ❌ put mutable state in a plain field — writes trigger nothing |
413
+ | ✅ `.value` for every Ref/Computed inside the class and in the script body | ❌ write `this.x = v` for a Ref/Computed in the class — it clobbers the ref or no-ops |
414
+ | ✅ derive with a PLAIN getter | ❌ wrap every derivation in `computed()` — pays ~300 bytes/instance for nothing |
415
+ | ✅ `computed()` only for expensive / render-suppressing / stable-handle needs | ❌ reach for `computed()` by default |
416
+ | ✅ inject stores via `private get $store() { return useStore() }` | ❌ `store = useStore()` field initializer — runs at construction, breaks tests/SSR/cycles |
417
+ | ✅ `new X.Class(props, emit)` — raw instance everywhere | ❌ wrap in `reactive(instance)` or any shallow-unwrap view as the standard |
418
+ | ✅ destructure ALL template-touched Refs/Computeds + element refs, grouped | ❌ destructure plain getters or methods — snapshots a dead value / loses nothing but clarity |
419
+ | ✅ 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 |
420
+ | ✅ `defineExpose(box as X.Instance)` | ❌ `defineExpose(box)` raw — readonly-accessor writes will type-error for consumers |
421
+ | ✅ constructor runs init; register hooks/watchers there | ❌ add an `init()` method expecting auto-call — ivue never calls it |
422
+ | ✅ 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
+ | ✅ 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
+
425
+ ## The unwrapping-surface typing invariant
426
+
427
+ Vue's expose proxy and `reactive()` unwrap ref READS and redirect ref WRITES
428
+ into `.value` at runtime — but TypeScript keeps get-only accessors `readonly`
429
+ through its homomorphic unwrap types. So a surface typed from the raw class
430
+ FORBIDS writes the runtime allows. `Instance` (= `ReactiveInstance`, i.e.
431
+ `typeof Class.Instance`) strips readonly via its writable-getter remap. It is
432
+ the TYPE of every unwrapping surface.
433
+
434
+ - Producing an exposed instance: `defineExpose(box as X.Instance)`.
435
+ - Consuming a template ref to it: `ShallowUnwrapRef<X.Instance>`
436
+ (generic: `ShallowUnwrapRef<X.Instance<T>>`).
437
+ - Wrapping at an interop boundary: `reactive(instance as X.Instance)` (concession, not the standard).
438
+
439
+ Across expose, verified live: reads arrive unwrapped; ref-writes DO redirect
440
+ (there is a write path); methods arrive engine-bound to raw; and PLAIN GETTERS
441
+ STAY FULLY REACTIVE — `watch(() => ref.value.someDerived, cb)` fires on leaf
442
+ change. What does NOT survive: setup-time snapshots (`const v = ref.value.x`),
443
+ plain data fields (never reactive), pre-mount null (template refs are null
444
+ until mount — use `?.` in watch getters).
445
+
446
+ ### Common compile errors → fixes
447
+
448
+ | Error / symptom | Fix |
449
+ | --- | --- |
450
+ | ❌ `Cannot assign to 'x' because it is a read-only property` (on an exposed/`reactive()`/template-ref surface) | ✅ type that surface through `X.Instance` |
451
+ | ❌ `Type 'boolean' is not assignable to type 'Ref<boolean>'` | ✅ missing `.value` on a Ref/Computed write — `x.flag.value = true` |
452
+ | ❌ `'X' is possibly null` on a template ref in a watch getter | ✅ add `?.` — `watch(() => x.boxEl.value?.foo, cb)` |
453
+ | ❌ template write crashes / no-ops at runtime on the raw instance | ✅ you wrote `x.Ref/Computed = v`; write `x.Ref/Computed.value = v` |
454
+
455
+ ## Watch rules — and WHICH watch
456
+
457
+ | the instance is… | use |
458
+ | ------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
459
+ | component-scoped (created in `setup()`) | plain `watch` / `watchEffect` — the component scope stops them on unmount |
460
+ | component-outliving (module singleton, created in a callback) | `this.$watch` / `this.$watchEffect` — the instance's lazy scope; disposed by `$stopEffects()` |
461
+
462
+ - `watch(() => instance.plainGetter, cb)` works on a RAW instance — no `reactive()`
463
+ wrapper, no Ref/Computed needed. The getter body runs inside the watcher's effect, so
464
+ its leaf reads subscribe directly (non-intuitive but structural).
465
+ - The source MUST be the FUNCTION form. `watch(instance.plainGetter, cb)` passes a
466
+ dead snapshot and never fires.
467
+ - `$stopEffects()` stops the instance scope and clears cached Refs/Computeds;
468
+ instances that never `$watch` allocate no scope. There are NO hooks — richer
469
+ cleanup is an ordinary method that does its work and then calls
470
+ `$stopEffects()` itself. Every outliving instance needs an OWNER that calls
471
+ it — or, when constructed inside some scope, auto-wire:
472
+ `getCurrentScope() && onScopeDispose(() => this.$stopEffects());`
473
+ - Do NOT default to `this.$watch` in a component-scoped constructor: the
474
+ component scope cannot see the instance scope, so without `$stopEffects`
475
+ wiring that watcher outlives unmount.
476
+ - Lifecycle hooks (`onMounted`, `onUnmounted`, …) follow the same split: the
477
+ constructor runs synchronously where you `new`, so in a component-scoped
478
+ class they register against the mounting component — full setup toolbox.
479
+ Component-coupled classes ONLY; never in stores/entities that outlive
480
+ components. If the class is also constructed outside components, guard:
481
+ `getCurrentInstance() && onMounted(() => this.onMount());`
482
+ - Watch CALLBACKS delegate to methods (the thin-closure rule):
483
+ `watch(source, (newValue, oldValue) => this.onChanged(newValue, oldValue))`.
484
+
485
+ ## Circular references resolve by construction
486
+
487
+ The hoisted-namespace + getter convention makes late cross-module references
488
+ safe without ordering discipline or `forwardRef`-style workarounds:
489
+
490
+ - Cross-references (`new Other.Class()` in a method, a store read in a
491
+ `$`-getter) resolve at FIRST ACCESS, when every module in the cycle has
492
+ long finished loading — any load order works.
493
+ - Each file calls `Reactive()` on its own class safely: it is idempotent per
494
+ prototype level; a shared ancestor is transformed once, by
495
+ whichever file loads first.
496
+ - Eager top-level dereferences can still fail; the convention keeps
497
+ cross-references inside late method and getter bodies. Circular `extends`
498
+ stays impossible because it evaluates at load time and both parents cannot
499
+ exist first.
500
+
501
+ ## Generic classes (brief)
502
+
503
+ `ReactiveClass<C>` cannot carry `<T>` through (no higher-kinded types), but
504
+ `Reactive(X) === X` by identity — so cast `Class` back to the raw
505
+ constructor and apply `ReactiveInstance` explicitly for `Instance`:
506
+
507
+ ```ts
508
+ class $Scroller<T extends BaseItem> {
509
+ get items() {
510
+ return ref<T[]>([]);
511
+ }
512
+ }
513
+
514
+ export namespace Scroller {
515
+ export const $Class = $Scroller;
516
+ // the cast keeps <T> available at `new` sites
517
+ export let Class = Reactive($Class) as unknown as typeof $Class;
518
+ export type Instance<T extends BaseItem> =
519
+ ReactiveInstance<$Scroller<T>>;
520
+ }
521
+ // consumer of a template ref: ShallowUnwrapRef<Scroller.Instance<T>>
522
+ ```
523
+
524
+ ## computed() and watch callbacks delegate to methods
525
+
526
+ A reactive closure is cached per instance. Keep that closure as a small
527
+ pointer to behavior on the prototype: **closures connect; methods contain
528
+ logic.**
529
+
530
+ ```ts
531
+ // ✅ THIN — the closure only delegates; logic stays named and testable
532
+ get sortedItems() {
533
+ return computed(() => this.sortItems());
534
+ }
535
+ sortItems() {
536
+ return [...this.items.value].sort(byPrice);
537
+ }
538
+
539
+ // ✅ same rule for watch callbacks wired in constructors
540
+ watch(value, (newValue, oldValue) =>
541
+ this.onValueChanged(newValue, oldValue),
542
+ );
543
+
544
+ // ❌ FAT — logic is anonymous and duplicated inside the cached closure
545
+ get sortedItems() {
546
+ return computed(() => [...this.items.value].sort(byPrice));
547
+ }
548
+ ```
549
+
550
+ Also buys: guaranteed-minimum memory (the thin closure captures nothing but
551
+ the instance — a fat closure silently pins any getter-scope local for the
552
+ instance's lifetime) and direct testability (`instance.sortItems()`).
553
+ Reactivity is unaffected — reads inside the method are tracked through the
554
+ computed's evaluation exactly as if inlined.
555
+
556
+ Do NOT "optimize" the arrow away to `computed(this.sortItems)`: it works
557
+ (ivue methods are lazy-bound) but Vue 3.4+ passes the previous value as the
558
+ getter's first argument, so a method that later gains an optional parameter
559
+ silently receives stale data. Always the arrow.
560
+
561
+ `$`-prefixed singleton getters are frozen caches too — keep their bodies to
562
+ a single composable/service call (`return useThing()`), nothing more.
563
+
564
+ ## Naming: unfold to the domain
565
+
566
+ Readable code is the product. In ivue classes the class shape already reads
567
+ like prose — don't ruin it with letter soup:
568
+
569
+ - **No single-letter or abbreviated identifiers** — including loop indices
570
+ and callback parameters. `row`/`col`, not `r`/`c`; `cell`, `cellValue`,
571
+ `entry`, `versionRef`, `aggregate`, `newValue`/`oldValue`, not
572
+ `c`/`v`/`e`/`agg`/`nv`/`ov`.
573
+ - **The one-letter-many-meanings failure mode is the reason.** A file where
574
+ `c` means cell in one method, column in the next, and cellValue in a
575
+ third makes every reader re-derive the type system in their head. Named
576
+ after the domain, the ambiguity cannot exist.
577
+ - **Booleans are predicates** (`isFineTier`, `hasModel`); counts say what
578
+ they count (`observerRuns`, `releasedCount`); prior values are
579
+ `originalX`/`previousX`, not `old`/`prev` alone.
580
+ - Abbreviate only when the abbreviation IS the domain term (`px`, `id`,
581
+ `fx`, A1-notation like `startRow`/`endCol`).
582
+ - Tests are code — the same rules apply to specs.
583
+
584
+ ```ts
585
+ // ❌ const v = this.cellVersions.get(k);
586
+ // ✅ const versionRef = this.cellVersions.get(cellKey);
587
+
588
+ // ❌ for (let r = r1; r <= r2; r++)
589
+ // ✅ for (let row = startRow; row <= endRow; row++)
590
+
591
+ // ❌ watch(c, (nv, ov) => …)
592
+ // ✅ watch(value, (newValue, oldValue) => this.onChanged(…))
593
+ ```
594
+
595
+ ## Keyed reactivity — the third state shape
596
+
597
+ Ref-getters express NAMED members; `shallowRef` expresses wholesale-replaced
598
+ structures. When state is KEYED — sparse, unbounded, indexed by ids or
599
+ coordinates unknown until runtime (cells by (row,col), entities by id, rows
600
+ of a stream) — a getter per key is impossible. Hold **collections of
601
+ reactive primitives as plain values** and materialize per observation:
602
+
603
+ ```ts
604
+ class $Sheet {
605
+ // Plain readonly fields — the COLLECTIONS aren't reactive;
606
+ // their VALUES are.
607
+ private readonly cellVersions = new Map<number, Ref<number>>();
608
+
609
+ /**
610
+ * READ path: get-OR-CREATE, then subscribe — observation
611
+ * materializes.
612
+ */
613
+ private trackCell(cellKey: number): void {
614
+ let versionRef = this.cellVersions.get(cellKey);
615
+ if (!versionRef) {
616
+ versionRef = ref(0);
617
+ this.cellVersions.set(cellKey, versionRef);
618
+ }
619
+ // subscribes whatever effect is currently running
620
+ void versionRef.value;
621
+ }
622
+
623
+ /**
624
+ * WRITE path: PEEK-ONLY — unobserved keys allocate nothing,
625
+ * notify no one.
626
+ */
627
+ private bumpCell(cellKey: number): void {
628
+ const versionRef = this.cellVersions.get(cellKey);
629
+ if (versionRef) versionRef.value++;
630
+ }
631
+ }
632
+ ```
633
+
634
+ The read/write ASYMMETRY is the pattern: reads get-or-create (cost is priced
635
+ by observation), while writes to unobserved keys allocate no signal. Rules that keep it honest:
636
+
637
+ - Ground truth lives in plain storage (typed arrays, Maps); the refs are
638
+ VERSION SIGNALS, not value holders — bump to invalidate, readers re-derive.
639
+ - Per-key cached computeds follow the same shape (`Map<key, ComputedRef>`),
640
+ bodies delegating to methods (the thin-closure rule), and MUST have an explicit release/
641
+ eviction path — keyed overlays cannot GC on their own (the Map holds
642
+ strong refs; attached watchers subscribe permanently).
643
+ - Coarse tiers are the same pattern at lower resolution: one ref covering
644
+ many keys (a block of rows, a whole-collection version counter) for
645
+ subscribers that span many keys — one integer where naive design puts a
646
+ million nodes.
647
+ - No wrapper needed: `ref()`/`computed()` are first-class values from
648
+ `@vue/reactivity`; Maps of them inside a `Reactive()` class compose with
649
+ everything (methods stay bound and `$watch` works).
650
+
651
+ | state shape | expression |
652
+ | ---------------------------- | ----------------------------------------------------- |
653
+ | named members | `get x() { return ref(v) }` |
654
+ | wholesale-replaced structure | `get rows() { return shallowRef<Row[]>([]) }` |
655
+ | keyed / sparse / unbounded | `Map<key, Ref>` + get-or-create track, peek-only bump |
656
+
657
+ Same invariant at three granularities — nothing exists until observed: getters
658
+ price MEMBERS, keyed collections price KEYS. (Proven at 20M cells / 4.7
659
+ bytes each — see the flyweight grid.)
660
+
661
+ ## Spacing is information
662
+
663
+ Contiguity says "same kind of thing"; a blank line says "the kind changes,
664
+ or complexity rises." Spend the signal deliberately — a blanket
665
+ newline-between-everything rule makes air mean nothing.
666
+
667
+ ```ts
668
+ // state block — CONTIGUOUS: reads as the instance's STATE TABLE
669
+ get sheet() {
670
+ return shallowRef<Sheet | null>(null);
671
+ }
672
+ get scrollTop() {
673
+ return ref(0);
674
+ }
675
+ get editing() {
676
+ return ref<{ row: number; col: number } | null>(null);
677
+ }
678
+
679
+ // derived block — contiguous: the windowing math as ONE visual unit
680
+ get totalHeight() {
681
+ return Math.min(this.naturalHeight, MAX_SCROLL_HEIGHT);
682
+ }
683
+ get startRow() {
684
+ return Math.floor(this.virtualTop / ROW_HEIGHT);
685
+ }
686
+
687
+ /** A doc comment needs air — blank line before it. */
688
+ get offsetY() {
689
+ const windowTop = this.virtualTop - this.startRow * ROW_HEIGHT;
690
+ return this.scrollTop.value - windowTop;
691
+ }
692
+ ```
693
+
694
+ - **Declaration-like getters** (state refs, one-expression deriveds):
695
+ contiguous within their group — a `get x() { return ref(0) }` is morally
696
+ a field, and fields read as a struct-like table you absorb at a glance.
697
+ The GROUP is the unit, not the member.
698
+ - **Blank line the moment a member carries a doc comment or multi-line
699
+ logic** — comments and paragraphs of code need air.
700
+ - **Blank line + `// --- section ---` banner between categories**
701
+ (state → derived → methods) — the boundary that actually matters.
702
+ - **Methods: always separated** — they are paragraphs, not table rows.
703
+
704
+ Not machine-enforceable (linters can't tell a ref-getter from a method, and
705
+ Prettier expands getters past the single-line exemptions) — hold it as a
706
+ convention and check it in review.
707
+
708
+ ## Self-review checklist (run over your ivue diff)
709
+
710
+ - [ ] Every mutable state member is `get x() { return ref(...) }` — no mutable plain fields.
711
+ - [ ] Inside the class, every Ref/Computed read/write uses `.value`; plain fields are constants/config only.
712
+ - [ ] Derived values are PLAIN getters; `computed()` appears only for expensive / render-suppressing / stable-handle cases.
713
+ - [ ] Stores/composables are injected via `private get $store() { return useStore() }`, not field initializers.
714
+ - [ ] The class is exported through the namespace (`$Class` / `Class = Reactive($Class)` / `Instance`); generics cast `Class` and hand-apply `ReactiveInstance` to `Instance<T>`.
715
+ - [ ] The SFC does `new X.Class(...)` once — no `reactive()` wrapper, no unwrap view.
716
+ - [ ] `<script setup>` is wiring only: no component-local Ref/Computed, watcher, lifecycle hook, or free function beside the class instance; extend an existing class-backed component through its class, never through parallel setup behavior.
717
+ - [ ] The SFC destructures ALL template-touched Refs/Computeds + element refs (grouped: state refs / computed refs / element refs); templates use state bindings and dotted access ONLY for plain getters/methods — no Ref reached through the instance in a template, no state name shadowing a prop.
718
+ - [ ] Template expressions carry NO logic — every `&&`/`||`/comparison/ternary condition is a NAMED plain getter, or a NAMED method when it takes an argument (`v-if="box.canEditItems"`, `v-if="media.fileExists(index)"` — never `v-if="a && b"`).
719
+ - [ ] Nothing but Refs/Computeds/element-ref targets is destructured (never plain getters/methods); v-for item cells stay dotted with `.value`; instance-swapping components don't destructure at all.
720
+ - [ ] `defineExpose(x as X.Instance)`; consumers type the ref as `ShallowUnwrapRef<X.Instance>`.
721
+ - [ ] Watch sources are the FUNCTION form; component-scoped constructors use plain `watch`/`watchEffect`; `this.$watch`/`this.$watchEffect` only for component-outliving instances — each with a dispose path (`$stopEffects()` owner or `onScopeDispose` auto-wire).
722
+ - [ ] Lifecycle hooks / init logic live in the constructor (no `init()` expecting auto-call); template refs guarded with `?.` where read pre-mount.
723
+ - [ ] Every `computed()`/constructor-watch CALLBACK delegates to a method (`computed(() => this.recalculate())`) — no logic inlined in reactive closures; the arrow form, never `computed(this.method)`.
724
+ - [ ] 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.
725
+ - [ ] 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.
726
+ - [ ] 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.