ivue 2.2.2 → 2.3.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 +18 -18
- package/dist/LazyShared.d.ts +49 -0
- package/dist/extras.cjs +1 -1
- package/dist/extras.d.ts +1 -0
- package/dist/extras.es.js +40 -18
- package/lib/LazyShared.ts +71 -0
- package/lib/__tests__/LazyShared.vitest.spec.ts +90 -0
- package/lib/extras.ts +1 -0
- package/package.json +11 -3
- package/skills/ivue/SKILL.md +335 -147
package/README.md
CHANGED
|
@@ -79,6 +79,24 @@ const { count } = counter;
|
|
|
79
79
|
|
|
80
80
|
Full walkthrough: [Getting Started](https://ivue.dev/guide/getting-started).
|
|
81
81
|
|
|
82
|
+
## Built for humans and AI
|
|
83
|
+
|
|
84
|
+
ivue ships with a
|
|
85
|
+
[Standard Operating Manual](https://ivue.dev/guide/standard) — the complete
|
|
86
|
+
authoring standard as annotated templates, rules, and a review checklist. It
|
|
87
|
+
reads as documentation and works as a drop-in skill for AI coding agents, so
|
|
88
|
+
generated code follows the same standard your team writes:
|
|
89
|
+
|
|
90
|
+
```sh
|
|
91
|
+
npx ivue skill # installs .claude/skills/ivue/SKILL.md, version-locked
|
|
92
|
+
npx ivue skill --all # + Codex/Cursor/Copilot where already in use
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Agents holding the Standard have
|
|
96
|
+
[derived correct patterns its own author never wrote](https://ivue.dev/blog/patterns-the-author-never-wrote) —
|
|
97
|
+
the manual is a generator, not a catalog. The wider argument:
|
|
98
|
+
[Reactive framework for the AI era](https://ivue.dev/blog/reactive-framework-for-the-ai-era).
|
|
99
|
+
|
|
82
100
|
## Why classes, why now
|
|
83
101
|
|
|
84
102
|
- **Native class API** — `extends`, `super`, getters, setters, private
|
|
@@ -207,24 +225,6 @@ Taken all the way down: a fully reactive spreadsheet model holding
|
|
|
207
225
|
floor — because in ivue, everything costs proportional to what's *observed*,
|
|
208
226
|
nothing costs proportional to what *exists*.
|
|
209
227
|
|
|
210
|
-
## Built for humans and AI
|
|
211
|
-
|
|
212
|
-
ivue ships with a
|
|
213
|
-
[Standard Operating Manual](https://ivue.dev/guide/standard) — the complete
|
|
214
|
-
authoring standard as annotated templates, rules, and a review checklist. It
|
|
215
|
-
reads as documentation and works as a drop-in skill for AI coding agents, so
|
|
216
|
-
generated code follows the same standard your team writes:
|
|
217
|
-
|
|
218
|
-
```sh
|
|
219
|
-
npx ivue skill # installs .claude/skills/ivue/SKILL.md, version-locked
|
|
220
|
-
npx ivue skill --all # + Codex/Cursor/Copilot where already in use
|
|
221
|
-
```
|
|
222
|
-
|
|
223
|
-
Agents holding the Standard have
|
|
224
|
-
[derived correct patterns its own author never wrote](https://ivue.dev/blog/patterns-the-author-never-wrote) —
|
|
225
|
-
the manual is a generator, not a catalog. The wider argument:
|
|
226
|
-
[Reactive framework for the AI era](https://ivue.dev/blog/reactive-framework-for-the-ai-era).
|
|
227
|
-
|
|
228
228
|
## Go deeper
|
|
229
229
|
|
|
230
230
|
- [Fundamental Principles](https://ivue.dev/guide/principles) — the design, from first ideas
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `LazyShared<T>` — the safe shared-store cell for static classes.
|
|
3
|
+
*
|
|
4
|
+
* The static-class split (see `Static()`): `$`-prefixed static getters
|
|
5
|
+
* are compute-once-PER-RECEIVER caches — perfect for memos and per-class
|
|
6
|
+
* tuning, where a subclass forking its own copy is the feature. A SHARED
|
|
7
|
+
* store (a registry, a ledger) must never live there: per-receiver
|
|
8
|
+
* caching means a subclass reading `this.$store` silently forks the
|
|
9
|
+
* registry. The fix is a `static readonly` FIELD — one reference on the
|
|
10
|
+
* declaring class, inherited, never receiver-cached — but an eager field
|
|
11
|
+
* initializer runs at MODULE LOAD, so it may only hold dependency-free
|
|
12
|
+
* values; the moment it constructs another namespace's class it races
|
|
13
|
+
* import cycles.
|
|
14
|
+
*
|
|
15
|
+
* `LazyShared` closes the triangle. The field eagerly stores the CELL
|
|
16
|
+
* (load-safe — a thunk evaluates nothing), the thunk runs on first
|
|
17
|
+
* `.value` read (cycle-safe — every module in any import cycle has
|
|
18
|
+
* finished loading), and memoization mutates cell-internal state
|
|
19
|
+
* (fork-safe — no receiver, subclass included, can fork it):
|
|
20
|
+
*
|
|
21
|
+
* class $SearchRegistry {
|
|
22
|
+
* protected static readonly sharedBackend = new LazyShared(
|
|
23
|
+
* () => new SearchBackend.Class(),
|
|
24
|
+
* );
|
|
25
|
+
* protected static get $backend() {
|
|
26
|
+
* return this.sharedBackend.value; // the field IS the pin
|
|
27
|
+
* }
|
|
28
|
+
* }
|
|
29
|
+
*
|
|
30
|
+
* A thunk that reads its own cell (directly or through another cell)
|
|
31
|
+
* throws a NAMED cycle error instead of a bare stack overflow, and a
|
|
32
|
+
* failed construction leaves the cell retryable, never poisoned.
|
|
33
|
+
*
|
|
34
|
+
* Ships from `ivue/extras` (not the reactive core) so the primary `ivue`
|
|
35
|
+
* entry stays minimal.
|
|
36
|
+
*/
|
|
37
|
+
export declare class LazyShared<T> {
|
|
38
|
+
private readonly make;
|
|
39
|
+
constructor(make: () => T);
|
|
40
|
+
private constructed;
|
|
41
|
+
private constructing;
|
|
42
|
+
private stored;
|
|
43
|
+
get value(): T;
|
|
44
|
+
/**
|
|
45
|
+
* Drop the constructed value; the next read constructs again. For
|
|
46
|
+
* tests and process recomposition — production code never resets.
|
|
47
|
+
*/
|
|
48
|
+
reset(): void;
|
|
49
|
+
}
|
package/dist/extras.cjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
"use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const u=Object.hasOwn;exports.Static=function(
|
|
1
|
+
"use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const u=Object.hasOwn;exports.LazyShared=class{constructor(s){this.make=s,this.constructed=!1,this.constructing=!1,this.stored=null}get value(){if(!this.constructed){if(this.constructing)throw new Error("LazyShared thunk cycle: this cell is read inside its own construction. Break the dependency between the two thunks.");this.constructing=!0;try{this.stored=this.make(),this.constructed=!0}finally{this.constructing=!1}}return this.stored}reset(){this.constructed=!1,this.stored=null}},exports.Static=function(s){const i=class extends s{},c=new Set;for(let r=s;r!==Function.prototype;r=Object.getPrototypeOf(r))for(const t of Reflect.ownKeys(r)){if(c.has(t))continue;c.add(t);const e=Object.getOwnPropertyDescriptor(r,t);if(typeof e.value=="function"){const o=e.value,n=typeof t=="string"?Symbol.for(`ivue.staticBound.${t}`):Symbol("ivue.staticBound");Object.defineProperty(i,t,{configurable:!0,enumerable:e.enumerable,get(){return u(this,n)||Object.defineProperty(this,n,{configurable:!0,value:o.bind(this)}),this[n]}})}else if(e.get&&!e.set&&typeof t=="string"&&t.startsWith("$")){const o=e.get,n=Symbol.for(`ivue.staticCache.${t}`);Object.defineProperty(i,t,{configurable:!0,enumerable:e.enumerable,get(){return u(this,n)||Object.defineProperty(this,n,{configurable:!0,value:o.call(this)}),this[n]}})}}return i};
|
package/dist/extras.d.ts
CHANGED
package/dist/extras.es.js
CHANGED
|
@@ -1,27 +1,49 @@
|
|
|
1
|
-
const
|
|
2
|
-
function
|
|
3
|
-
const
|
|
1
|
+
const u = Object.hasOwn;
|
|
2
|
+
function a(i) {
|
|
3
|
+
const n = class extends i {
|
|
4
4
|
}, c = /* @__PURE__ */ new Set();
|
|
5
|
-
for (let
|
|
6
|
-
for (const
|
|
7
|
-
if (c.has(
|
|
5
|
+
for (let r = i; r !== Function.prototype; r = Object.getPrototypeOf(r))
|
|
6
|
+
for (const t of Reflect.ownKeys(r)) {
|
|
7
|
+
if (c.has(t))
|
|
8
8
|
continue;
|
|
9
|
-
c.add(
|
|
10
|
-
const
|
|
11
|
-
if (typeof
|
|
12
|
-
const
|
|
13
|
-
Object.defineProperty(
|
|
14
|
-
return
|
|
9
|
+
c.add(t);
|
|
10
|
+
const e = Object.getOwnPropertyDescriptor(r, t);
|
|
11
|
+
if (typeof e.value == "function") {
|
|
12
|
+
const o = e.value, s = typeof t == "string" ? Symbol.for(`ivue.staticBound.${t}`) : Symbol("ivue.staticBound");
|
|
13
|
+
Object.defineProperty(n, t, { configurable: !0, enumerable: e.enumerable, get() {
|
|
14
|
+
return u(this, s) || Object.defineProperty(this, s, { configurable: !0, value: o.bind(this) }), this[s];
|
|
15
15
|
} });
|
|
16
|
-
} else if (
|
|
17
|
-
const
|
|
18
|
-
Object.defineProperty(
|
|
19
|
-
return
|
|
16
|
+
} else if (e.get && !e.set && typeof t == "string" && t.startsWith("$")) {
|
|
17
|
+
const o = e.get, s = Symbol.for(`ivue.staticCache.${t}`);
|
|
18
|
+
Object.defineProperty(n, t, { configurable: !0, enumerable: e.enumerable, get() {
|
|
19
|
+
return u(this, s) || Object.defineProperty(this, s, { configurable: !0, value: o.call(this) }), this[s];
|
|
20
20
|
} });
|
|
21
21
|
}
|
|
22
22
|
}
|
|
23
|
-
return
|
|
23
|
+
return n;
|
|
24
|
+
}
|
|
25
|
+
class h {
|
|
26
|
+
constructor(n) {
|
|
27
|
+
this.make = n, this.constructed = !1, this.constructing = !1, this.stored = null;
|
|
28
|
+
}
|
|
29
|
+
get value() {
|
|
30
|
+
if (!this.constructed) {
|
|
31
|
+
if (this.constructing)
|
|
32
|
+
throw new Error("LazyShared thunk cycle: this cell is read inside its own construction. Break the dependency between the two thunks.");
|
|
33
|
+
this.constructing = !0;
|
|
34
|
+
try {
|
|
35
|
+
this.stored = this.make(), this.constructed = !0;
|
|
36
|
+
} finally {
|
|
37
|
+
this.constructing = !1;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return this.stored;
|
|
41
|
+
}
|
|
42
|
+
reset() {
|
|
43
|
+
this.constructed = !1, this.stored = null;
|
|
44
|
+
}
|
|
24
45
|
}
|
|
25
46
|
export {
|
|
26
|
-
|
|
47
|
+
h as LazyShared,
|
|
48
|
+
a as Static
|
|
27
49
|
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `LazyShared<T>` — the safe shared-store cell for static classes.
|
|
3
|
+
*
|
|
4
|
+
* The static-class split (see `Static()`): `$`-prefixed static getters
|
|
5
|
+
* are compute-once-PER-RECEIVER caches — perfect for memos and per-class
|
|
6
|
+
* tuning, where a subclass forking its own copy is the feature. A SHARED
|
|
7
|
+
* store (a registry, a ledger) must never live there: per-receiver
|
|
8
|
+
* caching means a subclass reading `this.$store` silently forks the
|
|
9
|
+
* registry. The fix is a `static readonly` FIELD — one reference on the
|
|
10
|
+
* declaring class, inherited, never receiver-cached — but an eager field
|
|
11
|
+
* initializer runs at MODULE LOAD, so it may only hold dependency-free
|
|
12
|
+
* values; the moment it constructs another namespace's class it races
|
|
13
|
+
* import cycles.
|
|
14
|
+
*
|
|
15
|
+
* `LazyShared` closes the triangle. The field eagerly stores the CELL
|
|
16
|
+
* (load-safe — a thunk evaluates nothing), the thunk runs on first
|
|
17
|
+
* `.value` read (cycle-safe — every module in any import cycle has
|
|
18
|
+
* finished loading), and memoization mutates cell-internal state
|
|
19
|
+
* (fork-safe — no receiver, subclass included, can fork it):
|
|
20
|
+
*
|
|
21
|
+
* class $SearchRegistry {
|
|
22
|
+
* protected static readonly sharedBackend = new LazyShared(
|
|
23
|
+
* () => new SearchBackend.Class(),
|
|
24
|
+
* );
|
|
25
|
+
* protected static get $backend() {
|
|
26
|
+
* return this.sharedBackend.value; // the field IS the pin
|
|
27
|
+
* }
|
|
28
|
+
* }
|
|
29
|
+
*
|
|
30
|
+
* A thunk that reads its own cell (directly or through another cell)
|
|
31
|
+
* throws a NAMED cycle error instead of a bare stack overflow, and a
|
|
32
|
+
* failed construction leaves the cell retryable, never poisoned.
|
|
33
|
+
*
|
|
34
|
+
* Ships from `ivue/extras` (not the reactive core) so the primary `ivue`
|
|
35
|
+
* entry stays minimal.
|
|
36
|
+
*/
|
|
37
|
+
export class LazyShared<T> {
|
|
38
|
+
constructor(private readonly make: () => T) {}
|
|
39
|
+
|
|
40
|
+
private constructed = false;
|
|
41
|
+
private constructing = false;
|
|
42
|
+
private stored: T | null = null;
|
|
43
|
+
|
|
44
|
+
get value(): T {
|
|
45
|
+
if (!this.constructed) {
|
|
46
|
+
if (this.constructing) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
'LazyShared thunk cycle: this cell is read inside its own ' +
|
|
49
|
+
'construction. Break the dependency between the two thunks.',
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
this.constructing = true;
|
|
53
|
+
try {
|
|
54
|
+
this.stored = this.make();
|
|
55
|
+
this.constructed = true;
|
|
56
|
+
} finally {
|
|
57
|
+
this.constructing = false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return this.stored as T;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Drop the constructed value; the next read constructs again. For
|
|
65
|
+
* tests and process recomposition — production code never resets.
|
|
66
|
+
*/
|
|
67
|
+
reset(): void {
|
|
68
|
+
this.constructed = false;
|
|
69
|
+
this.stored = null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { LazyShared } from '../LazyShared';
|
|
3
|
+
import { Static } from '../Static';
|
|
4
|
+
|
|
5
|
+
describe('LazyShared', () => {
|
|
6
|
+
it('evaluates nothing at definition, constructs once, shares the result', () => {
|
|
7
|
+
let constructionCount = 0;
|
|
8
|
+
const cell = new LazyShared(() => {
|
|
9
|
+
constructionCount += 1;
|
|
10
|
+
return { count: constructionCount };
|
|
11
|
+
});
|
|
12
|
+
expect(constructionCount).toBe(0); // load-safe: the thunk is inert
|
|
13
|
+
expect(cell.value).toBe(cell.value);
|
|
14
|
+
expect(constructionCount).toBe(1);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('every receiver converges on the ONE singleton — forking the access path is harmless', () => {
|
|
18
|
+
let constructionCount = 0;
|
|
19
|
+
class $Owner {
|
|
20
|
+
protected static readonly sharedStore = new LazyShared(() => {
|
|
21
|
+
constructionCount += 1;
|
|
22
|
+
return new Map<string, number>();
|
|
23
|
+
});
|
|
24
|
+
static store(): Map<string, number> {
|
|
25
|
+
return this.sharedStore.value;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
class $SubReceiver extends $Owner {}
|
|
29
|
+
$Owner.store().set('planted', 7);
|
|
30
|
+
expect($SubReceiver.store().get('planted')).toBe(7);
|
|
31
|
+
expect($SubReceiver.store()).toBe($Owner.store());
|
|
32
|
+
expect(constructionCount).toBe(1);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('even a per-receiver $-cache over the cell returns the same singleton', () => {
|
|
36
|
+
// The registry-fork trap: Static()'s $-getters cache per receiver,
|
|
37
|
+
// so parent and subclass each run the getter body once. With the
|
|
38
|
+
// body reading a LazyShared cell, both receivers cache the SAME
|
|
39
|
+
// constructed value — the fork exists only in the access path.
|
|
40
|
+
let constructionCount = 0;
|
|
41
|
+
class $Registry {
|
|
42
|
+
protected static readonly sharedEntries = new LazyShared(() => {
|
|
43
|
+
constructionCount += 1;
|
|
44
|
+
return new Map<string, number>();
|
|
45
|
+
});
|
|
46
|
+
static get $entries(): Map<string, number> {
|
|
47
|
+
return this.sharedEntries.value;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const Registry = Static($Registry);
|
|
51
|
+
class $SubRegistry extends Registry {}
|
|
52
|
+
const SubRegistry = Static($SubRegistry);
|
|
53
|
+
Registry.$entries.set('planted', 7);
|
|
54
|
+
expect(SubRegistry.$entries).toBe(Registry.$entries);
|
|
55
|
+
expect(SubRegistry.$entries.get('planted')).toBe(7);
|
|
56
|
+
expect(constructionCount).toBe(1);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('reset drops the value and the next read constructs again', () => {
|
|
60
|
+
let constructionCount = 0;
|
|
61
|
+
const cell = new LazyShared(() => (constructionCount += 1));
|
|
62
|
+
expect(cell.value).toBe(1);
|
|
63
|
+
cell.reset();
|
|
64
|
+
expect(cell.value).toBe(2);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('a thunk cycle throws a named error and leaves the cell retryable', () => {
|
|
68
|
+
const cellA = new LazyShared((): number => cellB.value + 1);
|
|
69
|
+
const cellB = new LazyShared((): number => cellA.value + 1);
|
|
70
|
+
expect(() => cellA.value).toThrow('LazyShared thunk cycle');
|
|
71
|
+
// not poisoned: break the cycle and the same cell constructs fine
|
|
72
|
+
let repaired = 0;
|
|
73
|
+
const cellC = new LazyShared((): number => (repaired += 1));
|
|
74
|
+
expect(cellC.value).toBe(1);
|
|
75
|
+
// the failed cell itself retries once its dependency resolves
|
|
76
|
+
expect(() => cellB.value).toThrow('LazyShared thunk cycle');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('a throwing thunk does not poison the cell — the next read retries', () => {
|
|
80
|
+
let attempts = 0;
|
|
81
|
+
const cell = new LazyShared(() => {
|
|
82
|
+
attempts += 1;
|
|
83
|
+
if (attempts === 1) throw new Error('backend not ready');
|
|
84
|
+
return 'ready';
|
|
85
|
+
});
|
|
86
|
+
expect(() => cell.value).toThrow('backend not ready');
|
|
87
|
+
expect(cell.value).toBe('ready');
|
|
88
|
+
expect(attempts).toBe(2);
|
|
89
|
+
});
|
|
90
|
+
});
|
package/lib/extras.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ivue",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.0",
|
|
4
4
|
"description": "Infinite Vue – Class Based Architecture for Vue 3",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -29,6 +29,8 @@
|
|
|
29
29
|
"measure:formula": "node demo/formula/measure.mjs",
|
|
30
30
|
"measure:grid": "node demo/grid/measure.mjs",
|
|
31
31
|
"dev:docs": "npm --prefix docs_v2 run dev -- --port 5174",
|
|
32
|
+
"admin": "vite newsletter/dashboard --host",
|
|
33
|
+
"build:admin": "vite build newsletter/dashboard",
|
|
32
34
|
"test": "vitest --coverage.enabled=true --reporter=verbose --ui",
|
|
33
35
|
"coverage": "vitest run --coverage",
|
|
34
36
|
"bench": "vitest bench --run",
|
|
@@ -37,11 +39,16 @@
|
|
|
37
39
|
"cypress:headed": "cypress run --component --browser chrome --headed --no-exit",
|
|
38
40
|
"build": "tsc --version;tsc --p ./tsconfig.json && vite build",
|
|
39
41
|
"build:demo": "tsc --p ./tsconfig.json && vite build demo",
|
|
40
|
-
"build:docs": "npm run sync:examples && npm --prefix docs_v2 run build && npm run check:links",
|
|
42
|
+
"build:docs": "npm run sync:examples && npm run sync:releases && npm run sync:blog-index && npm --prefix docs_v2 run build && npm run check:links && node docs_v2/scripts/check-related-posts.mjs",
|
|
43
|
+
"sync:releases": "node docs_v2/scripts/releases-page-generator.mjs",
|
|
44
|
+
"sync:blog-index": "node docs_v2/scripts/blog-index-generator.mjs",
|
|
41
45
|
"check:links": "node docs_v2/scripts/check-links.mjs",
|
|
42
46
|
"render:og": "node docs_v2/scripts/brand-image-generator.mjs og",
|
|
43
47
|
"render:form-header": "node docs_v2/scripts/brand-image-generator.mjs form-header",
|
|
44
48
|
"render:banner": "node docs_v2/scripts/brand-image-generator.mjs blog",
|
|
49
|
+
"render:diagram": "node docs_v2/scripts/brand-image-generator.mjs diagram",
|
|
50
|
+
"render:embeds": "node docs_v2/scripts/blog-embed-shots.mjs",
|
|
51
|
+
"render:code-shots": "node docs_v2/scripts/blog-code-shots.mjs",
|
|
45
52
|
"sync:blog-dates": "node docs_v2/scripts/blog-dates-generator.mjs",
|
|
46
53
|
"preview:demo": "npm run build:demo && vite preview demo --host",
|
|
47
54
|
"preview:docs": "npm --prefix docs_v2 run preview",
|
|
@@ -51,7 +58,8 @@
|
|
|
51
58
|
"sync:examples": "node -e \"require('fs').copyFileSync('lib/Reactive.ts','examples/playground/src/ivue.ts')\"",
|
|
52
59
|
"dev:playground": "vite examples/playground --host",
|
|
53
60
|
"build:playground": "vite build examples/playground",
|
|
54
|
-
"preview:playground": "vite preview examples/playground --host"
|
|
61
|
+
"preview:playground": "vite preview examples/playground --host",
|
|
62
|
+
"rename:blog-slug": "node docs_v2/scripts/rename-blog-slug.mjs"
|
|
55
63
|
},
|
|
56
64
|
"peerDependencies": {
|
|
57
65
|
"vue": "^3.2.0",
|
package/skills/ivue/SKILL.md
CHANGED
|
@@ -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(
|
|
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 =
|
|
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
|
|
|
@@ -417,11 +440,13 @@ session.dispose();
|
|
|
417
440
|
| ✅ `new X.Class(props, emit)` — raw instance everywhere | ❌ wrap in `reactive(instance)` or any shallow-unwrap view as the standard |
|
|
418
441
|
| ✅ destructure ALL template-touched Refs/Computeds + element refs, grouped | ❌ destructure plain getters or methods — snapshots a dead value / loses nothing but clarity |
|
|
419
442
|
| ✅ 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 |
|
|
443
|
+
| ✅ labels, disabled states, and class conditions as named getters/methods (`model.sendButtonLabel`, `model.sendDisabled`) | ❌ ternaries, `\|\|`/`&&` chains, comparisons, or string-building inside template expressions |
|
|
420
444
|
| ✅ `defineExpose(box as X.Instance)` | ❌ `defineExpose(box)` raw — readonly-accessor writes will type-error for consumers |
|
|
421
445
|
| ✅ constructor runs init; register hooks/watchers there | ❌ add an `init()` method expecting auto-call — ivue never calls it |
|
|
422
446
|
| ✅ 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
447
|
| ✅ 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
448
|
| ✅ 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` |
|
|
449
|
+
| ✅ 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
450
|
|
|
426
451
|
## The unwrapping-surface typing invariant
|
|
427
452
|
|
|
@@ -483,6 +508,220 @@ until mount — use `?.` in watch getters).
|
|
|
483
508
|
- Watch CALLBACKS delegate to methods (the thin-closure rule):
|
|
484
509
|
`watch(source, (newValue, oldValue) => this.onChanged(newValue, oldValue))`.
|
|
485
510
|
|
|
511
|
+
## computed() and watch callbacks delegate to methods
|
|
512
|
+
|
|
513
|
+
A reactive closure is cached per instance. Keep that closure as a small
|
|
514
|
+
pointer to behavior on the prototype: **closures connect; methods contain
|
|
515
|
+
logic.**
|
|
516
|
+
|
|
517
|
+
```ts
|
|
518
|
+
// ✅ THIN — the closure only delegates; logic stays named and testable
|
|
519
|
+
get sortedItems() {
|
|
520
|
+
return computed(() => this.sortItems());
|
|
521
|
+
}
|
|
522
|
+
sortItems() {
|
|
523
|
+
return [...this.items.value].sort(byPrice);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// ✅ same rule for watch callbacks wired in constructors
|
|
527
|
+
watch(value, (newValue, oldValue) =>
|
|
528
|
+
this.onValueChanged(newValue, oldValue),
|
|
529
|
+
);
|
|
530
|
+
|
|
531
|
+
// ❌ FAT — logic is anonymous and duplicated inside the cached closure
|
|
532
|
+
get sortedItems() {
|
|
533
|
+
return computed(() => [...this.items.value].sort(byPrice));
|
|
534
|
+
}
|
|
535
|
+
```
|
|
536
|
+
|
|
537
|
+
Also buys: guaranteed-minimum memory (the thin closure captures nothing but
|
|
538
|
+
the instance — a fat closure silently pins any getter-scope local for the
|
|
539
|
+
instance's lifetime) and direct testability (`instance.sortItems()`).
|
|
540
|
+
Reactivity is unaffected — reads inside the method are tracked through the
|
|
541
|
+
computed's evaluation exactly as if inlined.
|
|
542
|
+
|
|
543
|
+
Do NOT "optimize" the arrow away to `computed(this.sortItems)`: it works
|
|
544
|
+
(ivue methods are lazy-bound) but Vue 3.4+ passes the previous value as the
|
|
545
|
+
getter's first argument, so a method that later gains an optional parameter
|
|
546
|
+
silently receives stale data. Always the arrow.
|
|
547
|
+
|
|
548
|
+
`$`-prefixed singleton getters are frozen caches too — keep their bodies to
|
|
549
|
+
a single composable/service call (`return useThing()`), nothing more.
|
|
550
|
+
|
|
551
|
+
## The store pattern: a singleton behind `use()`, injected by `$`-getter
|
|
552
|
+
|
|
553
|
+
Shared application state (session, navigation, toasts, the current user)
|
|
554
|
+
is a STORE — one ivue class published as a module singleton — never a
|
|
555
|
+
model passed down as a prop. Prop-drilling a shared model
|
|
556
|
+
(`<ChildView :app="app" />`, `constructor(public app: AppModel.Instance)`)
|
|
557
|
+
threads one object through every component and constructor signature it
|
|
558
|
+
crosses; the store pattern deletes the thread.
|
|
559
|
+
|
|
560
|
+
```ts
|
|
561
|
+
// app/AppStore.ts — the store IS an ivue class; `use()` owns the singleton
|
|
562
|
+
class $AppStore {
|
|
563
|
+
get authenticated() {
|
|
564
|
+
return ref(false);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
notify(message: string) {
|
|
568
|
+
/* ... */
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
export namespace AppStore {
|
|
573
|
+
export const $Class = $AppStore;
|
|
574
|
+
export let Class = Reactive($Class);
|
|
575
|
+
export type Instance = typeof Class.Instance;
|
|
576
|
+
|
|
577
|
+
let singleton: Instance | null = null;
|
|
578
|
+
export function use(): Instance {
|
|
579
|
+
return (singleton ??= new Class());
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
```
|
|
583
|
+
|
|
584
|
+
Consumers never receive it — they REACH for it:
|
|
585
|
+
|
|
586
|
+
```ts
|
|
587
|
+
// any model — the `$`-getter caches the store per instance, forever
|
|
588
|
+
class $SubscribersModel {
|
|
589
|
+
protected get $app() {
|
|
590
|
+
return AppStore.use();
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
async refresh() {
|
|
594
|
+
try {
|
|
595
|
+
/* ... */
|
|
596
|
+
} catch (error) {
|
|
597
|
+
this.$app.reportFailure(error);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
```
|
|
602
|
+
|
|
603
|
+
```vue
|
|
604
|
+
<script setup lang="ts">
|
|
605
|
+
// any component — call use() directly; no prop, no provide/inject
|
|
606
|
+
import { AppStore } from '../app/AppStore';
|
|
607
|
+
|
|
608
|
+
const app = AppStore.use();
|
|
609
|
+
const { authenticated } = app;
|
|
610
|
+
</script>
|
|
611
|
+
|
|
612
|
+
<template>
|
|
613
|
+
<button v-if="authenticated" @click="app.logout()">Lock</button>
|
|
614
|
+
</template>
|
|
615
|
+
```
|
|
616
|
+
|
|
617
|
+
Why this shape and not alternatives:
|
|
618
|
+
|
|
619
|
+
- **`use()` is lazy** — the singleton constructs on first touch, after the
|
|
620
|
+
app exists, so module-load order and circular imports stay non-events
|
|
621
|
+
(the same late-read property as every cross-module reference).
|
|
622
|
+
- **The `$`-getter is the injection point** — cached whole, per instance,
|
|
623
|
+
on first read. A model names its dependency once; every method reads
|
|
624
|
+
`this.$app` with zero lookup cost and zero constructor plumbing.
|
|
625
|
+
- **Tests swap the slot, not the callers** — `AppStore.Class = $TestStore`
|
|
626
|
+
before the first `use()` (or reset the singleton) and every consumer
|
|
627
|
+
gets the double through the same seam.
|
|
628
|
+
- A store is component-OUTLIVING by definition: watchers inside it use
|
|
629
|
+
`this.$watch`/`$watchEffect`, never plain `watch`, and lifecycle hooks
|
|
630
|
+
never belong in it.
|
|
631
|
+
- Pass PROPS for what is genuinely per-instance input (a row, a slug, a
|
|
632
|
+
config knob). Reach for the STORE for what is genuinely shared. A prop
|
|
633
|
+
named `app`, `store`, or `session` is the tell that a store is being
|
|
634
|
+
drilled.
|
|
635
|
+
|
|
636
|
+
## Keyed reactivity — the third state shape
|
|
637
|
+
|
|
638
|
+
Ref-getters express NAMED members; `shallowRef` expresses wholesale-replaced
|
|
639
|
+
structures. When state is KEYED — sparse, unbounded, indexed by ids or
|
|
640
|
+
coordinates unknown until runtime (cells by (row,col), entities by id, rows
|
|
641
|
+
of a stream) — a getter per key is impossible. Hold **collections of
|
|
642
|
+
reactive primitives as plain values** and materialize per observation:
|
|
643
|
+
|
|
644
|
+
```ts
|
|
645
|
+
class $Sheet {
|
|
646
|
+
// Plain readonly fields — the COLLECTIONS aren't reactive;
|
|
647
|
+
// their VALUES are.
|
|
648
|
+
private readonly cellVersions = new Map<number, Ref<number>>();
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* READ path: get-OR-CREATE, then subscribe — observation
|
|
652
|
+
* materializes.
|
|
653
|
+
*/
|
|
654
|
+
private trackCell(cellKey: number): void {
|
|
655
|
+
let versionRef = this.cellVersions.get(cellKey);
|
|
656
|
+
if (!versionRef) {
|
|
657
|
+
versionRef = ref(0);
|
|
658
|
+
this.cellVersions.set(cellKey, versionRef);
|
|
659
|
+
}
|
|
660
|
+
// subscribes whatever effect is currently running
|
|
661
|
+
void versionRef.value;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* WRITE path: PEEK-ONLY — unobserved keys allocate nothing,
|
|
666
|
+
* notify no one.
|
|
667
|
+
*/
|
|
668
|
+
private bumpCell(cellKey: number): void {
|
|
669
|
+
const versionRef = this.cellVersions.get(cellKey);
|
|
670
|
+
if (versionRef) versionRef.value++;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
```
|
|
674
|
+
|
|
675
|
+
The read/write ASYMMETRY is the pattern: reads get-or-create (cost is priced
|
|
676
|
+
by observation), while writes to unobserved keys allocate no signal. Rules that keep it honest:
|
|
677
|
+
|
|
678
|
+
- Ground truth lives in plain storage (typed arrays, Maps); the refs are
|
|
679
|
+
VERSION SIGNALS, not value holders — bump to invalidate, readers re-derive.
|
|
680
|
+
- Per-key cached computeds follow the same shape (`Map<key, ComputedRef>`),
|
|
681
|
+
bodies delegating to methods (the thin-closure rule), and MUST have an explicit release/
|
|
682
|
+
eviction path — keyed overlays cannot GC on their own (the Map holds
|
|
683
|
+
strong refs; attached watchers subscribe permanently).
|
|
684
|
+
- Coarse tiers are the same pattern at lower resolution: one ref covering
|
|
685
|
+
many keys (a block of rows, a whole-collection version counter) for
|
|
686
|
+
subscribers that span many keys — one integer where naive design puts a
|
|
687
|
+
million nodes.
|
|
688
|
+
- No wrapper needed: `ref()`/`computed()` are first-class values from
|
|
689
|
+
`@vue/reactivity`; Maps of them inside a `Reactive()` class compose with
|
|
690
|
+
everything (methods stay bound and `$watch` works).
|
|
691
|
+
|
|
692
|
+
| state shape | expression |
|
|
693
|
+
| ---------------------------- | ----------------------------------------------------- |
|
|
694
|
+
| named members | `get x() { return ref(v) }` |
|
|
695
|
+
| wholesale-replaced structure | `get rows() { return shallowRef<Row[]>([]) }` |
|
|
696
|
+
| keyed / sparse / unbounded | `Map<key, Ref>` + get-or-create track, peek-only bump |
|
|
697
|
+
|
|
698
|
+
Same invariant at three granularities — nothing exists until observed: getters
|
|
699
|
+
price MEMBERS, keyed collections price KEYS. (Proven at 20M cells / 4.7
|
|
700
|
+
bytes each — see the flyweight grid.)
|
|
701
|
+
|
|
702
|
+
## Generic classes (brief)
|
|
703
|
+
|
|
704
|
+
`ReactiveClass<C>` cannot carry `<T>` through (no higher-kinded types), but
|
|
705
|
+
`Reactive(X) === X` by identity — so cast `Class` back to the raw
|
|
706
|
+
constructor and apply `ReactiveInstance` explicitly for `Instance`:
|
|
707
|
+
|
|
708
|
+
```ts
|
|
709
|
+
class $Scroller<T extends BaseItem> {
|
|
710
|
+
get items() {
|
|
711
|
+
return ref<T[]>([]);
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
export namespace Scroller {
|
|
716
|
+
export const $Class = $Scroller;
|
|
717
|
+
// the cast keeps <T> available at `new` sites
|
|
718
|
+
export let Class = Reactive($Class) as unknown as typeof $Class;
|
|
719
|
+
export type Instance<T extends BaseItem> =
|
|
720
|
+
ReactiveInstance<$Scroller<T>>;
|
|
721
|
+
}
|
|
722
|
+
// consumer of a template ref: ShallowUnwrapRef<Scroller.Instance<T>>
|
|
723
|
+
```
|
|
724
|
+
|
|
486
725
|
## Circular references resolve by construction
|
|
487
726
|
|
|
488
727
|
The hoisted-namespace + getter convention makes late cross-module references
|
|
@@ -509,8 +748,51 @@ files, git, parsers, clocks: never constructed, only called and swapped — use
|
|
|
509
748
|
router/queue/listener callback, bound to the RECEIVING class.
|
|
510
749
|
- **Get-only statics named `$…` compute once PER RECEIVER.** The `$` prefix
|
|
511
750
|
promises stable identity, NOT immutability — a mutable memo table is a
|
|
512
|
-
legitimate `$`-cache. Non-`$` static getters stay LIVE:
|
|
513
|
-
test
|
|
751
|
+
legitimate `$`-cache. Non-`$` static getters stay LIVE: the settings a
|
|
752
|
+
subclass or test double overrides.
|
|
753
|
+
- **A SHARED STORE never lives in receiver-space.** Per-receiver caching
|
|
754
|
+
means a subclass reading `this.$store` silently forks a fresh copy — the
|
|
755
|
+
registry-fork trap. The store is a `static readonly` FIELD on the
|
|
756
|
+
declaring class — one reference, inherited through the prototype chain,
|
|
757
|
+
never receiver-cached — so every receiver read (`this.$store`,
|
|
758
|
+
`this.constructor.$store`) resolves to the one store with no special
|
|
759
|
+
case anywhere; the `$`-getter pins by returning the field:
|
|
760
|
+
```ts
|
|
761
|
+
class $Registry {
|
|
762
|
+
protected static readonly sharedRegistrations = new Map<object, Registration>();
|
|
763
|
+
protected static get $registrations() {
|
|
764
|
+
return this.sharedRegistrations; // the field IS the pin
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
```
|
|
768
|
+
Two questions place every static value:
|
|
769
|
+
|
|
770
|
+
1. **Should a subclass get its own copy?** Yes → per-receiver
|
|
771
|
+
`$`-cache. That is what memos and per-class tuning want: forking on
|
|
772
|
+
subclass is the feature. No → it is a SHARED store (a registry, a
|
|
773
|
+
ledger — forking is the bug), and it lives in a `static readonly`
|
|
774
|
+
field as above.
|
|
775
|
+
2. **Shared store: can its initializer run at module load?** A field
|
|
776
|
+
initializer runs while modules are still loading, so it may only
|
|
777
|
+
hold a dependency-free value — a bare `new Map()`, a literal. The
|
|
778
|
+
moment construction needs ANOTHER module's class, the field holds a
|
|
779
|
+
`LazyShared` cell instead (`import { LazyShared } from
|
|
780
|
+
'ivue/extras'`), and the `$`-getter reads through it:
|
|
781
|
+
```ts
|
|
782
|
+
protected static readonly sharedBackend = new LazyShared(
|
|
783
|
+
() => new SearchBackend.Class(),
|
|
784
|
+
);
|
|
785
|
+
protected static get $backend() {
|
|
786
|
+
return this.sharedBackend.value;
|
|
787
|
+
}
|
|
788
|
+
```
|
|
789
|
+
Each step is safe on its own terms. Storing the cell eagerly is
|
|
790
|
+
safe because a thunk evaluates nothing at load. Running the thunk
|
|
791
|
+
on first read is safe because by then every import cycle has
|
|
792
|
+
resolved. And sharing is safe because the memoized value lives
|
|
793
|
+
INSIDE the cell — every access path, subclass receivers and
|
|
794
|
+
per-receiver `$`-caches over the cell included, converges on the
|
|
795
|
+
one constructed singleton.
|
|
514
796
|
|
|
515
797
|
THE ANCHOR RULE — a class that declares static members wraps them ONCE, at
|
|
516
798
|
`$Class`, so subclasses and test doubles inherit working semantics by
|
|
@@ -577,85 +859,56 @@ Take the first rung that applies:
|
|
|
577
859
|
```ts
|
|
578
860
|
protected get tooltipDwellSeconds() { return 0.4; }
|
|
579
861
|
```
|
|
580
|
-
2. **Something outside reads it** (
|
|
581
|
-
→ keep the static and read it
|
|
862
|
+
2. **Something outside reads it** (a test overriding the knob, another class)
|
|
863
|
+
→ keep the static and read it through **`self`** — the one cast per
|
|
864
|
+
class, declared beside the statics it types — DIRECTLY at each call
|
|
865
|
+
site:
|
|
582
866
|
```ts
|
|
583
|
-
protected get
|
|
584
|
-
return
|
|
867
|
+
protected get self() {
|
|
868
|
+
return this.constructor as typeof $Tooltip;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
show() {
|
|
872
|
+
this.dwellTimer.start(this.self.TOOLTIP_DWELL_SECONDS);
|
|
585
873
|
}
|
|
586
874
|
```
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
875
|
+
An instance getter over a static earns its place when it genuinely
|
|
876
|
+
derives — mixing in instance state or transforming the value; a
|
|
877
|
+
plain read stays a direct `this.self.X` at the call site, so the
|
|
878
|
+
knob keeps one name and one override surface (the static).
|
|
879
|
+
`this.constructor` is the actual class — the subclass when subclassed,
|
|
880
|
+
and an engine class that INHERITS `$Class` for a plain reactive
|
|
881
|
+
instance — so statics resolve late-bound in both cases.
|
|
882
|
+
TypeScript types `constructor` as bare
|
|
883
|
+
`Function`, so ONE cast is unavoidable; `self` is where it lives.
|
|
884
|
+
Never scatter per-site `(this.constructor as typeof $X)` casts: each
|
|
885
|
+
is an unchecked assertion that the class name is right, and the
|
|
886
|
+
copy-paste error it invites typechecks silently against the wrong
|
|
887
|
+
statics. Rules that keep `self` honest:
|
|
888
|
+
- **Plain getter, never `$self`** — a `$`-cache would spend a
|
|
889
|
+
per-instance slot on what `this.constructor` hands back for free.
|
|
890
|
+
- **One read → `this.self.X` inline. Two or more reads, or any
|
|
891
|
+
loop → hoist:** `const self = this.self;` as the first line, then
|
|
892
|
+
`self.X` throughout. Measured (Node 26): the de-opted `self` getter
|
|
893
|
+
costs ~2 ns/read over an inline cast — noise for a single read —
|
|
894
|
+
while the hoisted form runs at ~0.4 ns/iter in loops, CHEAPER than
|
|
895
|
+
the inline cast, because the engine hoists the class as a loop
|
|
896
|
+
constant.
|
|
897
|
+
- **A subclass that adds statics redeclares `self`** with its own
|
|
898
|
+
`typeof $Sub` (a covariant override); a subclass that only tunes
|
|
899
|
+
inherited statics needs nothing — `self` is already late-bound.
|
|
900
|
+
- **`self` is NOT the namespace slot.** `this.self` is the class you
|
|
901
|
+
were constructed from; `Namespace.Class` is the live mutable slot a
|
|
902
|
+
kernel may have re-pointed since. Receiver statics (constants,
|
|
903
|
+
per-class tuning, `$`-caches) read through `self`; late-bound
|
|
904
|
+
capability dispatch reads through `Namespace.Class`. Blurring them
|
|
905
|
+
trades typo bugs for staleness bugs.
|
|
591
906
|
3. **Overriding must NOT happen** → name the class directly,
|
|
592
907
|
`$Tooltip.TOOLTIP_DWELL_SECONDS`, and let the code say so.
|
|
593
908
|
|
|
594
909
|
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.
|
|
910
|
+
cast wearing a getter costume: it looks live and is not — `self` is its
|
|
911
|
+
honest replacement.
|
|
659
912
|
|
|
660
913
|
## Naming: unfold to the domain
|
|
661
914
|
|
|
@@ -688,72 +941,6 @@ like prose — don't ruin it with letter soup:
|
|
|
688
941
|
// ✅ watch(value, (newValue, oldValue) => this.onChanged(…))
|
|
689
942
|
```
|
|
690
943
|
|
|
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
944
|
## Spacing is information
|
|
758
945
|
|
|
759
946
|
Contiguity says "same kind of thing"; a blank line says "the kind changes,
|
|
@@ -841,5 +1028,6 @@ convention and check it in review.
|
|
|
841
1028
|
- [ ] 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
1029
|
- [ ] 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
1030
|
- [ ] 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`.
|
|
1031
|
+
- [ ] 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
1032
|
- [ ] Static members precede the constructor; the constructor precedes state, prop, and derived getters; methods come last.
|
|
845
1033
|
- [ ] 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.
|