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.
- package/README.md +170 -33
- package/bin/ivue.mjs +152 -0
- package/dist/Reactive.d.ts +131 -0
- package/dist/env.d.ts +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.es.js +90 -81
- package/dist/index.umd.js +1 -1
- package/lib/Reactive.ts +509 -0
- package/lib/__tests__/Reactive.vitest.spec.ts +1056 -0
- package/lib/__tests__/ReactiveAdversarial.vitest.spec.ts +182 -0
- package/lib/__tests__/coverage-completion.vitest.spec.ts +24 -0
- package/lib/__tests__/ivue.vitest.spec.ts +1460 -150
- package/lib/env.d.ts +1 -0
- package/lib/index.ts +1 -1
- package/lib/ivue.ts +661 -241
- package/lib/kernel.ts +18 -0
- package/package.json +51 -13
- package/skills/ivue/SKILL.md +726 -0
- package/dist/ivue.d.ts +0 -234
|
@@ -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.
|