j-templates 7.0.95 → 7.0.96
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/SYNTAX_PRIMER.md +1586 -0
- package/package.json +1 -1
package/SYNTAX_PRIMER.md
ADDED
|
@@ -0,0 +1,1586 @@
|
|
|
1
|
+
# j-templates Syntax Primer — v3
|
|
2
|
+
|
|
3
|
+
Complete reference for the **j-templates** framework syntax. This documents **j-templates v7.0.94** (see `package.json`). For pattern-oriented guides, see `docs/patterns/`; for step-by-step tutorials, see `docs/tutorials/`.
|
|
4
|
+
|
|
5
|
+
> **Core concepts:** Components define UI via `Template()`. State decorators (`@Value`, `@State`, `@Computed`) enable reactivity. DOM functions (`div()`, `button()`) create virtual nodes. No compile step, minimal dependencies.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## How to Use This Document (for LLMs)
|
|
10
|
+
|
|
11
|
+
- **Read the Mental Model and Cheat Sheet first.** They encode the single most important idea (no vNode diffing) and a compact summary you can get right from.
|
|
12
|
+
- **The Traps section is mandatory reading.** It consolidates the subtle behaviors that cause the most bugs.
|
|
13
|
+
- **The Anti-Patterns and Debugging tables are the highest-value reference.** Consult them before writing any component.
|
|
14
|
+
- **The complete worked example (Smart Tasks)** exercises the whole stack together — read it once to anchor every concept.
|
|
15
|
+
- Internal implementation types are intentionally omitted; you build with DOM functions and decorators, never with raw `vNode` objects.
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Mental Model
|
|
20
|
+
|
|
21
|
+
> **The framework does not diff vNode trees.** When a reactive scope emits, the children function that read it re-runs and produces a brand-new vNode tree. The DOM is then patched from the old tree to the new tree. There is no vNode-to-vNode reconciliation (unlike React's diffing).
|
|
22
|
+
|
|
23
|
+
This one fact drives every design decision in this framework:
|
|
24
|
+
|
|
25
|
+
- **Optimization = minimize how often scopes emit**, not how cheap the re-run is.
|
|
26
|
+
- A scope read at the top of `Template()` rebuilds the **entire** component tree.
|
|
27
|
+
- A scope read inside a children function rebuilds **only that subtree**.
|
|
28
|
+
- A scope read inside a `data:` binding rebuilds **only that iteration's vNode**.
|
|
29
|
+
- Per-item scopes are **reused by object identity** (not by key) when the same data reference reappears.
|
|
30
|
+
|
|
31
|
+
**The core loop:** `@Value` → `Template()` → `Component.ToFunction` → `Component.Attach`. Everything else is a refinement.
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## Cheat Sheet
|
|
36
|
+
|
|
37
|
+
**Imports**
|
|
38
|
+
```typescript
|
|
39
|
+
import { Component, scope, gate, peek, mapped } from "j-templates";
|
|
40
|
+
import { div, button, input, span, h1, text, fragment, _var } from "j-templates/DOM";
|
|
41
|
+
import { Value, State, Computed, ComputedAsync, Scope, Watch, Inject, Destroy, Bound, Animation, AnimationType, IDestroyable } from "j-templates/Utils";
|
|
42
|
+
import { StoreSync, StoreAsync, ObservableScope, ObservableNode } from "j-templates/Store";
|
|
43
|
+
import { CreateRootPropertyAssignment, CreateEventAssignment } from "j-templates/DOM";
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
**The core loop**
|
|
47
|
+
```typescript
|
|
48
|
+
const MyComp = Component.ToFunction("my-comp", MyComponent); // class → function
|
|
49
|
+
Component.Attach(document.body, MyComp({})); // mount
|
|
50
|
+
Component.Register("my-comp", MyComponent); // Web Component (open shadow DOM)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
**Decorators — one line each**
|
|
54
|
+
| Decorator | Use for | Backend | Identity |
|
|
55
|
+
|-----------|---------|---------|----------|
|
|
56
|
+
| `@Value()` | primitives (`number`/`string`/`boolean`/`null`/`undefined`) | basic scope | — |
|
|
57
|
+
| `@State()` | objects & arrays (deep proxy) | ObservableNode proxy | — |
|
|
58
|
+
| `@Scope()` | cheap getters, filter/sort of existing refs | single scope | **new ref** |
|
|
59
|
+
| `@Computed()` | new composite objects | StoreSync | **same ref** (ApplyDiff) |
|
|
60
|
+
| `@ComputedAsync(default)` | sync getter + StoreAsync backend | StoreAsync | **same ref** |
|
|
61
|
+
| `@Watch(fn)` | run on property change (fires immediately on `Bound()`) | greedy scope | — |
|
|
62
|
+
| `@Inject(Type)` | DI from component injector | — | — |
|
|
63
|
+
| `@Destroy()` | auto `.Destroy()` on teardown (needs `IDestroyable`) | — | — |
|
|
64
|
+
|
|
65
|
+
**The 3 conditional patterns**
|
|
66
|
+
```typescript
|
|
67
|
+
// 1. Nested children function — isolated scope, use when an "else" branch is needed
|
|
68
|
+
div({}, () => this.isLoading ? div({}, () => "Loading") : text(() => ""));
|
|
69
|
+
|
|
70
|
+
// 2. data: boolean — falsy renders nothing, truthy renders child (no "else")
|
|
71
|
+
div({ data: () => this.isLoading }, () => div({}, () => "Loading"));
|
|
72
|
+
|
|
73
|
+
// 3. gate() — only re-evaluates when boolean flips; shares scope with siblings
|
|
74
|
+
gate(() => this.isLoading) ? div({}, () => "Loading") : div({}, () => "Content");
|
|
75
|
+
|
|
76
|
+
// 4. fragment() — conditional rendering with NO wrapper DOM node
|
|
77
|
+
fragment({ data: () => this.isLoading }, () => div({}, () => "Loading"));
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
> **⚠️ `data:` boolean controls *children*, not element existence.** When the value is falsy, the element itself is still created — it just has no children. A styled container (padding, background, border) will still occupy space as an empty box. To remove an element entirely, use pattern 1 (nested children function) or pattern 3 (`gate()`).
|
|
81
|
+
|
|
82
|
+
**The inline scope functions**
|
|
83
|
+
| Function | Registers dependency | Gates on `===` | Use when |
|
|
84
|
+
|----------|---------------------|----------------|----------|
|
|
85
|
+
| `scope(fn)` | Yes | No | Full reactivity |
|
|
86
|
+
| `gate(fn)` | Yes | Yes | Prevent unnecessary downstream updates |
|
|
87
|
+
| `peek(fn)` | No | N/A | One-time reads, display-only values |
|
|
88
|
+
| `mapped(data, fn)` | Yes (per item) | No | Per-item scopes (advanced; used internally by `data:`) |
|
|
89
|
+
|
|
90
|
+
**The 3 golden rules**
|
|
91
|
+
1. Pass arrays as `data:` — the framework iterates. Don't call `.map()` inside children.
|
|
92
|
+
2. Wrap children in functions for separate reactive scopes.
|
|
93
|
+
3. Read scopes at the point of use (inside children functions / `data:` bindings), never at the top of `Template()`.
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
## Quick Start — Minimal Component
|
|
98
|
+
|
|
99
|
+
```typescript
|
|
100
|
+
import { Component } from "j-templates";
|
|
101
|
+
import { div, button, text } from "j-templates/DOM";
|
|
102
|
+
import { Value } from "j-templates/Utils";
|
|
103
|
+
|
|
104
|
+
class Counter extends Component {
|
|
105
|
+
@Value() count = 0;
|
|
106
|
+
|
|
107
|
+
Template() {
|
|
108
|
+
return div({}, () => [
|
|
109
|
+
text(() => `Count: ${this.count}`),
|
|
110
|
+
button({ on: { click: () => this.count++ } }, () => "Increment"),
|
|
111
|
+
]);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
Bound() { super.Bound(); }
|
|
115
|
+
Destroy() { super.Destroy(); }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const CounterFn = Component.ToFunction("my-counter", Counter);
|
|
119
|
+
Component.Attach(document.body, CounterFn({}));
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
---
|
|
123
|
+
|
|
124
|
+
## Complete Worked Example — Smart Tasks
|
|
125
|
+
|
|
126
|
+
> This is a trimmed version of the real example at `examples/smart-tasks/src/`. It exercises the full stack: `@State`, `@Value`, `@Scope`, `@Computed`, plain reactive getters, parent→child data, child→parent events, `data:` iteration, conditional rendering, and two-way binding. Read it once to anchor every concept below.
|
|
127
|
+
|
|
128
|
+
**`types.ts`**
|
|
129
|
+
```typescript
|
|
130
|
+
export interface Task { id: string; text: string; completed: boolean; }
|
|
131
|
+
export type FilterType = "all" | "active" | "completed";
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
**`app.ts`** — the root component
|
|
135
|
+
```typescript
|
|
136
|
+
import { Component } from "j-templates";
|
|
137
|
+
import { Value, State } from "j-templates/Utils";
|
|
138
|
+
import { div, h1, span, text } from "j-templates/DOM";
|
|
139
|
+
import { taskInput } from "./task-input";
|
|
140
|
+
import { taskItem } from "./task-item";
|
|
141
|
+
import { filterBar } from "./filter-bar";
|
|
142
|
+
import { statsBar } from "./stats-bar";
|
|
143
|
+
import { Task, FilterType } from "./types";
|
|
144
|
+
|
|
145
|
+
let nextId = 1;
|
|
146
|
+
|
|
147
|
+
class App extends Component {
|
|
148
|
+
@State() tasks: Task[] = []; // complex state → deep proxy
|
|
149
|
+
@Value() filter: FilterType = "all"; // primitive state → lightweight
|
|
150
|
+
|
|
151
|
+
// Plain getter — reactive because it reads @State/@Value values.
|
|
152
|
+
// No decorator needed for simple derived reads.
|
|
153
|
+
get filteredTasks(): Task[] {
|
|
154
|
+
switch (this.filter) {
|
|
155
|
+
case "active": return this.tasks.filter((t) => !t.completed);
|
|
156
|
+
case "completed": return this.tasks.filter((t) => t.completed);
|
|
157
|
+
default: return this.tasks;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private handleAdd(payload: { text: string }): void {
|
|
162
|
+
this.tasks.push({ id: String(nextId++), text: payload.text, completed: false });
|
|
163
|
+
}
|
|
164
|
+
private handleToggle(id: string): void {
|
|
165
|
+
const task = this.tasks.find((t) => t.id === id);
|
|
166
|
+
if (task) task.completed = !task.completed; // @State proxy → direct mutation works
|
|
167
|
+
}
|
|
168
|
+
private handleDelete(id: string): void {
|
|
169
|
+
const idx = this.tasks.findIndex((t) => t.id === id);
|
|
170
|
+
if (idx !== -1) this.tasks.splice(idx, 1);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
Template() {
|
|
174
|
+
return div({ props: { className: "app" } }, () => [
|
|
175
|
+
h1({}, () => "Smart Tasks"),
|
|
176
|
+
|
|
177
|
+
// Parent → child data (read-only in child)
|
|
178
|
+
statsBar({ data: () => ({ tasks: this.tasks }) }),
|
|
179
|
+
|
|
180
|
+
// Child → parent event
|
|
181
|
+
taskInput({ on: { add: (p) => this.handleAdd(p) } }),
|
|
182
|
+
|
|
183
|
+
filterBar({
|
|
184
|
+
data: () => ({ activeFilter: this.filter }),
|
|
185
|
+
on: { filterChange: (p) => { this.filter = p.filter; } },
|
|
186
|
+
}),
|
|
187
|
+
|
|
188
|
+
// Conditional rendering — isolated scope. Only this div re-evaluates
|
|
189
|
+
// when tasks/filteredTasks/filter change. Siblings are unaffected.
|
|
190
|
+
div({}, () => {
|
|
191
|
+
if (this.tasks.length === 0) return div({}, () => "No tasks yet");
|
|
192
|
+
if (this.filteredTasks.length === 0) return div({}, () => "No tasks match this filter.");
|
|
193
|
+
return text(() => "");
|
|
194
|
+
}),
|
|
195
|
+
|
|
196
|
+
// data: binding — framework iterates. Each item gets its own scope,
|
|
197
|
+
// so toggling one task does not re-render the others.
|
|
198
|
+
div({ props: { className: "task-list" }, data: () => this.filteredTasks },
|
|
199
|
+
(task: Task) =>
|
|
200
|
+
taskItem({
|
|
201
|
+
data: () => task,
|
|
202
|
+
on: {
|
|
203
|
+
toggle: () => this.handleToggle(task.id),
|
|
204
|
+
delete: () => this.handleDelete(task.id),
|
|
205
|
+
},
|
|
206
|
+
}),
|
|
207
|
+
),
|
|
208
|
+
]);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const app = Component.ToFunction("app", App);
|
|
213
|
+
Component.Attach(document.getElementById("app")!, app({}));
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
**`task-item.ts`** — child component with events
|
|
217
|
+
```typescript
|
|
218
|
+
import { Component } from "j-templates";
|
|
219
|
+
import { div, span } from "j-templates/DOM";
|
|
220
|
+
import { Task } from "./types";
|
|
221
|
+
|
|
222
|
+
export interface TaskItemEvents { toggle: { id: string }; delete: { id: string }; }
|
|
223
|
+
|
|
224
|
+
class TaskItem extends Component<Task, void, TaskItemEvents> {
|
|
225
|
+
Template() {
|
|
226
|
+
return div({
|
|
227
|
+
props: () => ({ className: this.Data.completed ? "task-item completed" : "task-item" }),
|
|
228
|
+
}, () => [
|
|
229
|
+
div({
|
|
230
|
+
props: () => ({ className: this.Data.completed ? "task-check checked" : "task-check" }),
|
|
231
|
+
on: { click: () => this.Fire("toggle", { id: this.Data.id }) },
|
|
232
|
+
}),
|
|
233
|
+
span({ props: { className: "task-text" } }, () => this.Data.text),
|
|
234
|
+
div({
|
|
235
|
+
props: { className: "task-delete" },
|
|
236
|
+
on: { click: () => this.Fire("delete", { id: this.Data.id }) },
|
|
237
|
+
}, () => "\u00d7"),
|
|
238
|
+
]);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
const taskItem = Component.ToFunction("task-item", TaskItem);
|
|
242
|
+
export { taskItem };
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
**`task-input.ts`** — two-way binding via reactive props
|
|
246
|
+
```typescript
|
|
247
|
+
import { Component } from "j-templates";
|
|
248
|
+
import { Value } from "j-templates/Utils";
|
|
249
|
+
import { div, input, button } from "j-templates/DOM";
|
|
250
|
+
|
|
251
|
+
export interface TaskInputEvents { add: { text: string }; }
|
|
252
|
+
|
|
253
|
+
class TaskInput extends Component<void, void, TaskInputEvents> {
|
|
254
|
+
@Value() text: string = "";
|
|
255
|
+
|
|
256
|
+
private handleAdd(): void {
|
|
257
|
+
const trimmed = this.text.trim();
|
|
258
|
+
if (!trimmed) return;
|
|
259
|
+
this.Fire("add", { text: trimmed });
|
|
260
|
+
this.text = "";
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
Template() {
|
|
264
|
+
return div({ props: { className: "task-input" } }, () => [
|
|
265
|
+
input({
|
|
266
|
+
props: () => ({ value: this.text, placeholder: "What needs doing?", type: "text" as const }),
|
|
267
|
+
on: {
|
|
268
|
+
input: (e: Event) => { this.text = (e.target as HTMLInputElement).value; },
|
|
269
|
+
keydown: (e: KeyboardEvent) => { if (e.key === "Enter") this.handleAdd(); },
|
|
270
|
+
},
|
|
271
|
+
}),
|
|
272
|
+
button({ props: () => ({ disabled: this.text.trim() === "" }), on: { click: () => this.handleAdd() } }, () => "Add"),
|
|
273
|
+
]);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
const taskInput = Component.ToFunction("task-input", TaskInput);
|
|
277
|
+
export { taskInput };
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
**`stats-bar.ts`** — `@Scope` vs `@Computed`
|
|
281
|
+
```typescript
|
|
282
|
+
import { Component } from "j-templates";
|
|
283
|
+
import { Scope, Computed } from "j-templates/Utils";
|
|
284
|
+
import { div, span } from "j-templates/DOM";
|
|
285
|
+
import { Task } from "./types";
|
|
286
|
+
|
|
287
|
+
interface StatsBarData { tasks: Task[]; }
|
|
288
|
+
|
|
289
|
+
class StatsBar extends Component<StatsBarData> {
|
|
290
|
+
// @Scope — cheap derived values, new reference each update.
|
|
291
|
+
@Scope() get total(): number { return this.Data.tasks.length; }
|
|
292
|
+
@Scope() get activeCount(): number { return this.Data.tasks.filter((t) => !t.completed).length; }
|
|
293
|
+
@Scope() get completedCount(): number { return this.Data.tasks.filter((t) => t.completed).length; }
|
|
294
|
+
|
|
295
|
+
// @Computed — composite object, SAME reference preserved via ApplyDiff.
|
|
296
|
+
@Computed()
|
|
297
|
+
get summary(): { active: number; completed: number; total: number; pct: string } {
|
|
298
|
+
const t = this.total;
|
|
299
|
+
const c = this.completedCount;
|
|
300
|
+
return { total: t, active: this.activeCount, completed: c, pct: t === 0 ? "0%" : `${Math.round((c / t) * 100)}%` };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
Template() {
|
|
304
|
+
return div({ props: { className: "stats-bar" } }, () => [
|
|
305
|
+
span({}, () => `${this.activeCount} active`),
|
|
306
|
+
span({}, () => `${this.completedCount} completed`),
|
|
307
|
+
span({}, () => `${this.summary.pct} done`),
|
|
308
|
+
]);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
const statsBar = Component.ToFunction("stats-bar", StatsBar);
|
|
312
|
+
export { statsBar };
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
**What to notice:**
|
|
316
|
+
- `@State` arrays support **direct mutation** (`push`, `splice`, `task.completed = ...`) because they're proxies. Plain arrays would need reassignment.
|
|
317
|
+
- The plain getter `filteredTasks` needs **no decorator** — it's reactive because it reads `@State`/`@Value` values.
|
|
318
|
+
- `@Scope` for cheap per-region derived values; `@Computed` for a composite object consumed by multiple spans.
|
|
319
|
+
- `data:` is passed **raw** to components (`this.Data`), but **iterated** for DOM elements.
|
|
320
|
+
- Events flow child→parent via `Fire()` + `on:`; data flows parent→child via `data:`.
|
|
321
|
+
|
|
322
|
+
---
|
|
323
|
+
|
|
324
|
+
## Imports & Setup
|
|
325
|
+
|
|
326
|
+
```bash
|
|
327
|
+
npm install j-templates
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
**tsconfig.json** requires `"experimentalDecorators": true, "useDefineForClassFields": false`.
|
|
331
|
+
|
|
332
|
+
**File naming:** Components: kebab-case (`todo-list.ts`). Services: kebab-case + `-service` suffix. Exports: lowercase matching filename.
|
|
333
|
+
|
|
334
|
+
**Public entry points** (verified against `src/index.ts`, `src/DOM/index.ts`, `src/Utils/index.ts`, `src/Store/index.ts`):
|
|
335
|
+
|
|
336
|
+
| Import path | Exports |
|
|
337
|
+
|-------------|---------|
|
|
338
|
+
| `j-templates` | `Component`, `scope`, `gate`, `peek`, `mapped` |
|
|
339
|
+
| `j-templates/DOM` | all DOM functions (`div`, `span`, `text`, `_var`, …), `CreateRootPropertyAssignment`, `CreateEventAssignment` |
|
|
340
|
+
| `j-templates/Utils` | `Value`, `State`, `Computed`, `ComputedAsync`, `Scope`, `Watch`, `Inject`, `Destroy`, `Bound`, `Animation`, `AnimationType`, `IDestroyable` |
|
|
341
|
+
| `j-templates/Store` | `StoreSync`, `StoreAsync`, `ObservableScope`, `ObservableNode` |
|
|
342
|
+
|
|
343
|
+
> ⚠️ **`Injector` is NOT exported from any public entry point.** It is defined in `src/Utils/injector.ts` but never re-exported. Use `@Inject` and `this.Injector` on components instead of importing `Injector` directly.
|
|
344
|
+
|
|
345
|
+
### DOM Functions
|
|
346
|
+
|
|
347
|
+
Layout: `div`, `span`, `section`, `article`, `aside`, `nav`, `main`, `header`, `footer`, `hr`, `blockquote`, `address`
|
|
348
|
+
|
|
349
|
+
Headings: `h1`–`h6`
|
|
350
|
+
|
|
351
|
+
Text: `p`, `a`, `b`, `strong`, `i`, `em`, `u`, `s`, `strike`, `del`, `ins`, `sub`, `sup`, `mark`, `small`, `label`, `pre`, `code`, `kbd`, `samp`, `_var`, `cite`, `q`, `abbr`, `time`, `dfn`, `rt`, `rp`
|
|
352
|
+
|
|
353
|
+
Lists: `ul`, `ol`, `li`, `dl`, `dt`, `dd`
|
|
354
|
+
|
|
355
|
+
Tables: `table`, `thead`, `tbody`, `tfoot`, `tr`, `th`, `td`, `col`, `colgroup`
|
|
356
|
+
|
|
357
|
+
Forms: `form`, `input`, `textarea`, `button`, `select`, `option`, `optgroup`, `fieldset`, `legend`, `datalist`, `output`, `progress`, `meter`
|
|
358
|
+
|
|
359
|
+
Media: `img`, `figure`, `figcaption`, `picture`, `source`, `audio`, `video`, `track`, `embed`, `object`, `param`, `iframe`
|
|
360
|
+
|
|
361
|
+
Interactive: `details`, `summary`, `dialog`, `menu`
|
|
362
|
+
|
|
363
|
+
Scripting: `canvas`, `svg`, `map`, `area`
|
|
364
|
+
|
|
365
|
+
Meta: `template`, `slot`
|
|
366
|
+
|
|
367
|
+
Text node: `text`
|
|
368
|
+
|
|
369
|
+
Fragment: `fragment` (no DOM node — children reconcile into the real ancestor)
|
|
370
|
+
|
|
371
|
+
No SVG-specific elements are exported (the `svgElements` module is commented out in `src/DOM/index.ts`). Use `Component.ToFunction` with a namespace for custom SVG components. Note that the `svg` element function itself creates an **HTML-namespace** `<svg>` element (no SVG namespace), so it is not suitable for inline SVG rendering — use a namespaced `Component.ToFunction` instead.
|
|
372
|
+
|
|
373
|
+
---
|
|
374
|
+
|
|
375
|
+
## Component
|
|
376
|
+
|
|
377
|
+
### Class, Generics & Lifecycle
|
|
378
|
+
|
|
379
|
+
```typescript
|
|
380
|
+
class MyComponent extends Component<D, T, E> {
|
|
381
|
+
// D = data type from parent (default: void)
|
|
382
|
+
// T = template functions from parent (default: void)
|
|
383
|
+
// E = event map type (default: {})
|
|
384
|
+
|
|
385
|
+
Template(): vNodeType | vNodeType[] { return div({}, () => "Hello"); }
|
|
386
|
+
Bound() { super.Bound(); } // Required: initializes @Watch decorators
|
|
387
|
+
Destroy() { super.Destroy(); } // Required: cleans up scopes and @Destroy properties
|
|
388
|
+
}
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
**Never override the constructor.** Use field initializers and `Bound()` for setup.
|
|
392
|
+
|
|
393
|
+
### Properties & Methods
|
|
394
|
+
|
|
395
|
+
| Property | Access | Description |
|
|
396
|
+
|----------|--------|-------------|
|
|
397
|
+
| `Data` | `protected get` | Data from parent via `data: () => ({...})` |
|
|
398
|
+
| `Templates` | `protected get` | Parent-provided template functions |
|
|
399
|
+
| `Injector` | `public get` | Component's scoped DI injector |
|
|
400
|
+
| `VNode` | `protected get` | Custom element host (not template root) |
|
|
401
|
+
| `Scope` | `protected get` | Internal scoped observable |
|
|
402
|
+
| `Destroyed` | `public get` | Whether component is destroyed |
|
|
403
|
+
|
|
404
|
+
| Method | Description |
|
|
405
|
+
|--------|-------------|
|
|
406
|
+
| `Template()` | Override to define UI. Returns empty array by default. |
|
|
407
|
+
| `Bound()` | Lifecycle hook after DOM attachment. Calls `Bound.All(this)`. |
|
|
408
|
+
| `Destroy()` | Destroys scope + calls `Destroy.All(this)`. |
|
|
409
|
+
| `Fire<P extends keyof E>(event: P, data?: E[P])` | Fire component event. `data` is optional. |
|
|
410
|
+
|
|
411
|
+
### ToFunction, Attach, Register
|
|
412
|
+
|
|
413
|
+
```typescript
|
|
414
|
+
// Convert class to reusable function (required for template use)
|
|
415
|
+
export const myComponent = Component.ToFunction("my-component", MyComponent);
|
|
416
|
+
|
|
417
|
+
// With namespace (SVG)
|
|
418
|
+
export const svgCircle = Component.ToFunction("circle", SvgCircle, "http://www.w3.org/2000/svg");
|
|
419
|
+
|
|
420
|
+
// Attach to DOM
|
|
421
|
+
Component.Attach(document.body, myComponent({}));
|
|
422
|
+
|
|
423
|
+
// Register as Web Component (creates open shadow DOM)
|
|
424
|
+
Component.Register("my-component", MyComponent);
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
**ToFunction config type:**
|
|
428
|
+
```typescript
|
|
429
|
+
type vComponentConfig<D, E, P = HTMLElement> = {
|
|
430
|
+
data?: () => D | undefined;
|
|
431
|
+
props?: FunctionOr<RecursivePartial<P>> | undefined;
|
|
432
|
+
on?: ComponentEvents<E> | undefined;
|
|
433
|
+
};
|
|
434
|
+
type ComponentEvents<E> = { [P in keyof E]?: { (data: E[P]): void } };
|
|
435
|
+
type FunctionOr<T> = { (): T | Promise<T> } | T;
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
### Custom Element Host
|
|
439
|
+
|
|
440
|
+
`this.VNode.node` is the custom element host, not the template root. Query template children via `this.VNode.node.querySelector('.className')`.
|
|
441
|
+
|
|
442
|
+
> **Components don't own the host.** There is no framework support for interacting with the host element. To attach native DOM listeners (focus, scroll, resize, etc.), attach them to a root element you define in `Template()` — not to `this.VNode.node`.
|
|
443
|
+
|
|
444
|
+
```html
|
|
445
|
+
<my-component> <!-- Host (styled via element selector) -->
|
|
446
|
+
<div class="container"> <!-- Template root (styled via class selector) -->
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
### Lifecycle
|
|
450
|
+
|
|
451
|
+
```
|
|
452
|
+
1. Component.Attach() called ─ DOM: Not attached
|
|
453
|
+
2. vNode.Init() called ─ DOM: Not attached
|
|
454
|
+
3. Component constructor runs ─ DOM: Not attached
|
|
455
|
+
4. Bound() called ─ DOM: Attached ✓ | Children: May not be ready ⚠
|
|
456
|
+
5. Template rendered, attached ─ DOM: Fully rendered ✓
|
|
457
|
+
6. ... reactivity updates ...
|
|
458
|
+
7. Component.Destroy() called ─ DOM: About to be removed
|
|
459
|
+
8. Destroy() called ─ Cleanup: Scopes, @Destroy properties
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
- **Bound()** — DOM attached, `@Watch` initialized (fires immediately with initial value). Query children with `requestAnimationFrame()` if needed.
|
|
463
|
+
- **Destroy()** — Always call `super.Destroy()` and `super.Bound()` when overriding.
|
|
464
|
+
|
|
465
|
+
### Async Initialization Pattern
|
|
466
|
+
|
|
467
|
+
```typescript
|
|
468
|
+
@State() data: Data[] = [];
|
|
469
|
+
@Value() isLoading = false;
|
|
470
|
+
|
|
471
|
+
Bound() {
|
|
472
|
+
super.Bound();
|
|
473
|
+
this.LoadData();
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
async LoadData() {
|
|
477
|
+
this.isLoading = true;
|
|
478
|
+
try {
|
|
479
|
+
const result = await fetchData();
|
|
480
|
+
await this.store.Write(result, "data");
|
|
481
|
+
} finally {
|
|
482
|
+
this.isLoading = false;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
```
|
|
486
|
+
|
|
487
|
+
---
|
|
488
|
+
|
|
489
|
+
## Template System
|
|
490
|
+
|
|
491
|
+
### DOM Function Signature
|
|
492
|
+
|
|
493
|
+
```typescript
|
|
494
|
+
function element<P, E, T>(
|
|
495
|
+
config?: {
|
|
496
|
+
props?: FunctionOr<RecursivePartial<P>>; // DOM properties (static or reactive)
|
|
497
|
+
attrs?: FunctionOr<{ [name: string]: string }>; // HTML attributes
|
|
498
|
+
on?: FunctionOr<vNodeEvents<E>>; // Event handlers
|
|
499
|
+
data?: () => T | Array<T> | Promise<Array<T>> | Promise<T>; // Reactive data
|
|
500
|
+
},
|
|
501
|
+
children?: vNodeType[] | ((data: T) => vNodeType[] | vNodeType | string)
|
|
502
|
+
): vNodeType
|
|
503
|
+
```
|
|
504
|
+
|
|
505
|
+
**Functions are reactive:** When referenced scope values change, the vNode re-renders.
|
|
506
|
+
|
|
507
|
+
### Template Patterns
|
|
508
|
+
|
|
509
|
+
```typescript
|
|
510
|
+
Template() {
|
|
511
|
+
return div({ props: { className: "container" } }, () => [
|
|
512
|
+
h1({}, () => "Title"),
|
|
513
|
+
|
|
514
|
+
// Reactive data binding - framework iterates array
|
|
515
|
+
div({ data: () => this.Data.items }, (item) =>
|
|
516
|
+
div({}, () => item.name)
|
|
517
|
+
),
|
|
518
|
+
|
|
519
|
+
// Reactive props
|
|
520
|
+
div({ props: () => ({ className: this.isActive ? "active" : "" }) }, () => "Content"),
|
|
521
|
+
|
|
522
|
+
// Event handlers
|
|
523
|
+
button({ on: { click: (e: MouseEvent) => this.handleClick(e) } }, () => "Click"),
|
|
524
|
+
|
|
525
|
+
// Conditional rendering — three patterns depending on isolation needs:
|
|
526
|
+
|
|
527
|
+
// 1. Nested children function — isolated scope, use when an "else" branch is needed
|
|
528
|
+
div({}, () =>
|
|
529
|
+
this.isLoading ? div({}, () => "Loading") : text(() => "")
|
|
530
|
+
),
|
|
531
|
+
|
|
532
|
+
// 2. data: boolean — falsy renders nothing, truthy renders child, no "else" needed
|
|
533
|
+
div({ data: () => this.isLoading }, () => div({}, () => "Loading")),
|
|
534
|
+
|
|
535
|
+
// 3. gate() — only re-evaluates when boolean flips; use when the condition
|
|
536
|
+
// shares a children function with other reactive siblings
|
|
537
|
+
gate(() => this.isLoading) ? div({}, () => "Loading") : div({}, () => "Content"),
|
|
538
|
+
|
|
539
|
+
// Child component
|
|
540
|
+
childComponent({ data: () => ({ id: 1 }) }),
|
|
541
|
+
|
|
542
|
+
// Reactive text node
|
|
543
|
+
text(() => `Count: ${this.count}`),
|
|
544
|
+
|
|
545
|
+
// Bare string as the sole children-function return value — valid per
|
|
546
|
+
// vNodeChildrenFunction<T>, and equivalent to using text() as the only child.
|
|
547
|
+
div({}, () => `Count: ${this.count}`),
|
|
548
|
+
|
|
549
|
+
// Raw HTML (use innerHTML prop, NOT { __html: })
|
|
550
|
+
div({ props: { innerHTML: "<strong>Bold</strong>" } }),
|
|
551
|
+
|
|
552
|
+
// Mixed text + elements (use text(), not plain strings in arrays)
|
|
553
|
+
text(() => "Click "), button({}, () => "here"), text(() => " to continue")
|
|
554
|
+
]);
|
|
555
|
+
}
|
|
556
|
+
```
|
|
557
|
+
|
|
558
|
+
### data: Binding Behavior
|
|
559
|
+
|
|
560
|
+
> **🔑 Key Insight:** `data:` binding behavior differs fundamentally between DOM elements and components. **DOM elements:** framework iterates/wraps/short-circuits. **Components:** raw passthrough as `this.Data`.
|
|
561
|
+
|
|
562
|
+
#### DOM Elements
|
|
563
|
+
|
|
564
|
+
| Return Value | Behavior |
|
|
565
|
+
|--------------|----------|
|
|
566
|
+
| `[]` | No children rendered |
|
|
567
|
+
| `[a, b, c]` | Iterates, renders child for each item |
|
|
568
|
+
| `{ id: 1 }` | Wraps as `[{ id: 1 }]`, renders once |
|
|
569
|
+
| `"text"` / `123` | Wraps as `[value]`, renders once |
|
|
570
|
+
| `true` | Wraps as `[true]`, renders child once |
|
|
571
|
+
| `false` / `null` / `undefined` | Returns `[]`, no children rendered |
|
|
572
|
+
|
|
573
|
+
> ⚠️ **Falsy edge case:** the wrapping logic is `if (!result) return [];` (see `ToArray` in `src/Node/vNode.ts`). So **`0`, `""`, and `NaN` also collapse to `[]`** — not just `false`/`null`/`undefined`. Only *truthy* non-array values are wrapped as `[value]`.
|
|
574
|
+
|
|
575
|
+
**Also accepts** `Promise<T>` and `Promise<T[]>` for async data. While a Promise is pending, the scope evaluates to `null` (falsy), so the element renders nothing until resolved — there is no built-in placeholder; implement one yourself if needed.
|
|
576
|
+
|
|
577
|
+
The `false`/`true` behavior makes `data:` a clean conditional rendering mechanism. **The child function is invoked with the truthy value as its data argument** — e.g. `data: () => this.isLoading` calls the child with `true` when loading. It renders its child once when true and nothing when false, with its own isolated reactive scope.
|
|
578
|
+
|
|
579
|
+
#### Components
|
|
580
|
+
|
|
581
|
+
Components receive the raw return value as `this.Data` — no iteration, no wrapping, no `false`/`null` short-circuit.
|
|
582
|
+
|
|
583
|
+
| Return Value | `this.Data` in component |
|
|
584
|
+
|--------------|--------------------------|
|
|
585
|
+
| `[a, b, c]` | `[a, b, c]` — component must iterate itself |
|
|
586
|
+
| `{ id: 1 }` | `{ id: 1 }` — passed as-is |
|
|
587
|
+
| `"text"` | `"text"` — passed as-is |
|
|
588
|
+
| `false` / `null` / `undefined` | The actual value — component decides how to handle |
|
|
589
|
+
| `Promise<T>` | Resolved by the scope — `this.Data` is the resolved value, or `null` while pending |
|
|
590
|
+
|
|
591
|
+
```typescript
|
|
592
|
+
// DOM element: framework iterates and renders a child per item
|
|
593
|
+
div({ data: () => this.tasks }, (task) => div({}, () => task.name));
|
|
594
|
+
|
|
595
|
+
// Component: data passes through as this.Data — no framework iteration
|
|
596
|
+
taskList({ data: () => ({ tasks: this.tasks }) });
|
|
597
|
+
// Inside TaskList: this.Data.tasks — component manages its own iteration
|
|
598
|
+
```
|
|
599
|
+
|
|
600
|
+
**Why the difference:** DOM elements are leaf nodes — the framework owns their rendering. Components have their own `Template()` method and full control over how data is consumed, so the framework treats `data:` as a reactive property passthrough, not an iteration instruction.
|
|
601
|
+
|
|
602
|
+
### Fragment Elements
|
|
603
|
+
|
|
604
|
+
`fragment()` creates a **container with no DOM node**. Its children are reconciled directly into the nearest real ancestor element. Use it when you need a reactive scope or a `data:` iteration but don't want an extra wrapper element in the DOM.
|
|
605
|
+
|
|
606
|
+
```typescript
|
|
607
|
+
import { fragment } from "j-templates/DOM";
|
|
608
|
+
|
|
609
|
+
// Conditional rendering with no wrapper node — the ternary is its own scope
|
|
610
|
+
fragment({ data: () => this.show }, (show) =>
|
|
611
|
+
show === "admin" ? div({}, () => "ADMIN") : div({}, () => "LOGIN"),
|
|
612
|
+
);
|
|
613
|
+
|
|
614
|
+
// Iteration with no wrapper node
|
|
615
|
+
fragment({ data: () => this.items }, (item) => div({}, () => item.name));
|
|
616
|
+
|
|
617
|
+
// Nested fragments flatten into the real ancestor
|
|
618
|
+
fragment({}, () => [
|
|
619
|
+
div({}, () => "OUTER"),
|
|
620
|
+
fragment({}, () => (this.showExtra ? div({}, () => "EXTRA") : div({}, () => "BASE"))),
|
|
621
|
+
]);
|
|
622
|
+
```
|
|
623
|
+
|
|
624
|
+
Key behaviors:
|
|
625
|
+
- **No DOM node.** `fragment()` produces no element; its children are inserted directly into the parent. A falsy `data:` value renders *nothing* — there is no empty wrapper box left behind (unlike a `div` with a `data:` boolean, which keeps the element in the DOM).
|
|
626
|
+
- **`data:` behaves like any DOM element** — iterates arrays, wraps truthy scalars, collapses falsy values to nothing.
|
|
627
|
+
- **Nesting is fine** — fragments inside fragments flatten into the real ancestor.
|
|
628
|
+
- **Cannot be attached directly.** A fragment has no node to attach; wrap it in a real element (e.g. `div`) before attaching to the DOM.
|
|
629
|
+
|
|
630
|
+
### Key Template Rules
|
|
631
|
+
|
|
632
|
+
1. **Pass arrays as `data:`** — framework iterates automatically. Don't call `.map()` inside children.
|
|
633
|
+
2. **Wrap children in functions** for separate reactive scopes. Without function wrapper, all children share parent scope.
|
|
634
|
+
3. **Two-way binding requires reactive props**: `props: () => ({ value: this.text })` (not static `props: { value: this.text }` which causes focus loss).
|
|
635
|
+
4. **Conditional rendering** — choose a pattern based on isolation needs. The most common mistake is reading a condition and a sibling `data:` list in the same children function — when either changes, both re-render.
|
|
636
|
+
|
|
637
|
+
```typescript
|
|
638
|
+
// Anti-pattern: condition and list share a children function scope.
|
|
639
|
+
div({}, () => [
|
|
640
|
+
this.isEmpty ? div({}, () => "No items") : text(() => ""),
|
|
641
|
+
div({ data: () => this.visibleTodos }, (item) => ...)
|
|
642
|
+
])
|
|
643
|
+
|
|
644
|
+
// Correct: each has its own isolated scope.
|
|
645
|
+
div({}, () => [
|
|
646
|
+
div({}, () => this.isEmpty ? div({}, () => "No items") : text(() => "")),
|
|
647
|
+
div({ data: () => this.visibleTodos }, (item) => ...)
|
|
648
|
+
])
|
|
649
|
+
|
|
650
|
+
// Also correct: data: boolean — no else branch needed, isolated scope.
|
|
651
|
+
div({}, () => [
|
|
652
|
+
div({ data: () => this.isEmpty }, () => div({}, () => "No items")),
|
|
653
|
+
div({ data: () => this.visibleTodos }, (item) => ...)
|
|
654
|
+
])
|
|
655
|
+
|
|
656
|
+
// Also correct: gate() — condition shares scope with siblings but only
|
|
657
|
+
// re-evaluates when the boolean flips, not on every upstream emission.
|
|
658
|
+
div({}, () => [
|
|
659
|
+
gate(() => this.visibleTodos.length === 0)
|
|
660
|
+
? div({}, () => "No items")
|
|
661
|
+
: text(() => ""),
|
|
662
|
+
div({ data: () => this.visibleTodos }, (item) => ...)
|
|
663
|
+
])
|
|
664
|
+
```
|
|
665
|
+
|
|
666
|
+
5. **`text()` for reactive text nodes when mixing with other vNodes** — a children function may return a bare string directly when it is the *sole* child (e.g. `div({}, () => \`Count: ${this.count}\`)`), which is equivalent to using `text()` as the only child. The constraint is specifically about *arrays*: never mix a plain string into an array alongside other vNodes — use `text()` for each string segment in that case.
|
|
667
|
+
6. **Keep `data:` bindings inline in `Template()`** — helper functions called from `Template()` create new vNodes each render, destroying and recreating children scopes. Keep element definitions with `data:` bindings inline so the vNode and its scope persist across renders.
|
|
668
|
+
|
|
669
|
+
```typescript
|
|
670
|
+
// Anti-pattern — helper function creates new vNodes each Template() call
|
|
671
|
+
private renderItem = (item: Item) =>
|
|
672
|
+
div({ data: () => item }, (data) => span({}, () => data.name));
|
|
673
|
+
Template() {
|
|
674
|
+
return div({}, () => [
|
|
675
|
+
this.renderItem(a), // New vNode, destroyed and recreated each render
|
|
676
|
+
this.renderItem(b),
|
|
677
|
+
]);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// Correct — inline with @Scope data source
|
|
681
|
+
@Scope() get groupA() { return items.filter(...); }
|
|
682
|
+
Template() {
|
|
683
|
+
return div({}, () => [
|
|
684
|
+
div({ data: () => this.groupA }, (item) => renderItem(item)),
|
|
685
|
+
div({ data: () => this.groupB }, (item) => renderItem(item)),
|
|
686
|
+
]);
|
|
687
|
+
}
|
|
688
|
+
```
|
|
689
|
+
|
|
690
|
+
7. **Read `@Scope` getters at the point of use** — reading a `@Scope` at the top of `Template()` registers it as a dependency of the entire Template. Reading it inside a children function or `data:` binding keeps the subscription scoped to that subtree.
|
|
691
|
+
8. **Same applies to `this.Data` in components** — reading `this.Data` at the top of `Template()` subscribes the entire component to the parent's data scope. Any parent data change rebuilds the entire Template. Read `this.Data` inside children functions, `props:` functions, or `data:` bindings to scope reactivity to specific DOM subtrees.
|
|
692
|
+
|
|
693
|
+
```typescript
|
|
694
|
+
// Anti-pattern — this.Data read at top of Template()
|
|
695
|
+
Template() {
|
|
696
|
+
const task = this.Data; // Subscribes entire Template
|
|
697
|
+
return div({}, () => [
|
|
698
|
+
span({}, () => task.name), // Any parent data change rebuilds ALL
|
|
699
|
+
span({}, () => task.status),
|
|
700
|
+
]);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// Correct — this.Data read inside children function
|
|
704
|
+
Template() {
|
|
705
|
+
return div({}, () => [
|
|
706
|
+
span({}, () => this.Data.name), // Only this subtree re-renders
|
|
707
|
+
span({}, () => this.Data.status), // Only this subtree re-renders
|
|
708
|
+
]);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// Also correct — in props: reactive function
|
|
712
|
+
Template() {
|
|
713
|
+
return div({ props: () => ({ className: this.Data.active ? "active" : "" }) }, () =>
|
|
714
|
+
"Content"
|
|
715
|
+
);
|
|
716
|
+
}
|
|
717
|
+
```
|
|
718
|
+
|
|
719
|
+
```typescript
|
|
720
|
+
// Anti-pattern — scope read at top of Template
|
|
721
|
+
Template() {
|
|
722
|
+
const derived = this.computedValue; // Subscribes entire Template
|
|
723
|
+
return div({}, () => [
|
|
724
|
+
div({}, () => `${derived}`), // Any change re-runs ALL
|
|
725
|
+
div({}, () => "other static content"),
|
|
726
|
+
]);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// Correct — scope read inside children function
|
|
730
|
+
Template() {
|
|
731
|
+
return div({}, () => [
|
|
732
|
+
div({}, () => { // Subscription scoped to this subtree
|
|
733
|
+
const derived = this.computedValue;
|
|
734
|
+
return `${derived}`;
|
|
735
|
+
}),
|
|
736
|
+
div({}, () => "other static content"), // Unaffected by derived changes
|
|
737
|
+
]);
|
|
738
|
+
}
|
|
739
|
+
```
|
|
740
|
+
|
|
741
|
+
### Granular Reactive Scopes
|
|
742
|
+
|
|
743
|
+
```typescript
|
|
744
|
+
// No function wrappers
|
|
745
|
+
div({}, [span({ data: this.value1 }, (v) => v), span({ data: this.value2 }, (v) => v)]);
|
|
746
|
+
// Changing value1 re-renders all elements
|
|
747
|
+
|
|
748
|
+
// With children function wrapper
|
|
749
|
+
div({}, () => [span({ data: this.value1 }, (v) => v), span({ data: this.value2 }, (v) => v)]);
|
|
750
|
+
// Changing value1 re-renders both spans, div is unaffected
|
|
751
|
+
|
|
752
|
+
// With data function wrappers
|
|
753
|
+
div({}, () => [span({ data: () => this.value1 }, (v) => v), span({ data: () => this.value2 }, (v) => v)]);
|
|
754
|
+
// Changing value1 only updates the first span, other elements are unaffected
|
|
755
|
+
```
|
|
756
|
+
|
|
757
|
+
---
|
|
758
|
+
|
|
759
|
+
## How Updates Propagate
|
|
760
|
+
|
|
761
|
+
> **🔑 Key Insight:** The framework does **not** diff vNode trees. When a scope emits, the children function re-runs and produces new vNodes. DOM is patched from old to new. Optimization comes from minimizing *how often* scopes emit, not making the re-run cheap.
|
|
762
|
+
|
|
763
|
+
When a reactive scope emits, the framework:
|
|
764
|
+
|
|
765
|
+
1. Re-runs the children function that read the scope, producing a new vNode tree.
|
|
766
|
+
2. Patches the DOM from the old vNode tree to the new vNode tree.
|
|
767
|
+
|
|
768
|
+
The framework does **not** diff two vNode trees against each other. There is no vNode-to-vNode reconciliation — no keyed diffing, no positional matching of old vNodes to new vNodes. The "surgical" aspect comes from scoping — only the children functions that subscribed to the changed scope re-run. Everything else is untouched.
|
|
769
|
+
|
|
770
|
+
**What actually happens at the DOM level:** when a children function re-runs, it produces a fresh array of vNodes. Each vNode maps to a DOM node, and `reconcileChildren` (in `src/DOM/domNodeConfig.ts`) reconciles the element's real DOM children against that list. Reuse is purely by **reference identity**:
|
|
771
|
+
- A vNode that is the *same object* as before (which is what per-item `MappedScope` reuse produces) keeps its existing DOM node — no rebuild.
|
|
772
|
+
- A *new* vNode object creates a *new* DOM node (`createNode`); the old node is removed.
|
|
773
|
+
- Text nodes are special-cased: if the incoming child is a string and the current child is a text node, the text node is **reused and its value updated** (`setText`) rather than replaced.
|
|
774
|
+
|
|
775
|
+
So "no vNode diffing" is precise: there is no keyed reconciliation and no vNode-to-vNode matching. But there *is* a cheap DOM-node reconciliation that reuses nodes by `===` reference. This is exactly why `@Computed` (same reference across updates) avoids DOM churn while `@Scope` (new reference) forces node recreation, and why per-item identity reuse is the framework's only mechanism for stable DOM.
|
|
776
|
+
|
|
777
|
+
This means:
|
|
778
|
+
- A scope read at the top of `Template()` rebuilds the entire component vNode tree.
|
|
779
|
+
- A scope read inside a children function rebuilds only that subtree.
|
|
780
|
+
- A scope read inside a `data:` binding rebuilds only that iteration's vNode.
|
|
781
|
+
- Per-item scopes are reused when the same data object reference reappears (identity-based, not key-based).
|
|
782
|
+
|
|
783
|
+
The optimization goal is minimizing **how often** children functions re-run through fine-grained scopes, not making the re-run itself cheap.
|
|
784
|
+
|
|
785
|
+
---
|
|
786
|
+
|
|
787
|
+
## State Decorators
|
|
788
|
+
|
|
789
|
+
All decorators are for **Component classes only**. Services must use `ObservableScope`/`Store` APIs directly.
|
|
790
|
+
|
|
791
|
+
### @Value — Primitive State
|
|
792
|
+
|
|
793
|
+
```typescript
|
|
794
|
+
@Value() count: number = 0;
|
|
795
|
+
@Value() isLoading: boolean = false;
|
|
796
|
+
```
|
|
797
|
+
|
|
798
|
+
Lightweight, no proxy. For `number`, `string`, `boolean`, `null`, `undefined`. Backed by a `basic` scope (`ObservableScope.Basic`).
|
|
799
|
+
|
|
800
|
+
**When NOT to use:** for objects or arrays — use `@State()` instead (deep reactivity).
|
|
801
|
+
|
|
802
|
+
### @State — Complex State
|
|
803
|
+
|
|
804
|
+
```typescript
|
|
805
|
+
@State() user: { name: string } = { name: "" };
|
|
806
|
+
@State() items: Item[] = [];
|
|
807
|
+
```
|
|
808
|
+
|
|
809
|
+
Deep reactivity via proxy (`ObservableNode.Create`). For objects with nested properties and arrays. **Array mutations (`push`, `splice`, item property writes) are tracked** — see the Smart Tasks example.
|
|
810
|
+
|
|
811
|
+
**When NOT to use:** for primitives — `@State()` creates a proxy, leaf scopes, and caches for no benefit; use `@Value()`.
|
|
812
|
+
|
|
813
|
+
### @Scope — Cached Getter (New Reference)
|
|
814
|
+
|
|
815
|
+
```typescript
|
|
816
|
+
@Scope()
|
|
817
|
+
get fullName() { return `${this.firstName} ${this.lastName}`; }
|
|
818
|
+
```
|
|
819
|
+
|
|
820
|
+
Cached, re-executes getter on dependency change. For cheap computations, primitives, simple array operations that maintain object identity (filter, sort). Backed by a non-greedy single scope (`ObservableScope.Create`).
|
|
821
|
+
|
|
822
|
+
**Emission behavior:** `@Scope` creates a non-greedy scope. When a dependency changes, the scope **emits** (notifies consumers) but does **not** re-evaluate the getter immediately — the getter re-runs lazily on the **next read** of the scope. There is no `===` gating: downstream consumers re-execute regardless of whether the getter returns the same value. The getter returns whatever it computes — `return this.items` preserves reference, `return this.items.filter(...)` produces a new reference. Downstream `data:` bindings use identity-based scope reuse: when the same data object reference appears in the array, its per-item scope is reused and only re-renders if that scope's dependencies changed.
|
|
823
|
+
|
|
824
|
+
> **Emit ≠ recompute.** An `@Scope` emit only notifies consumers; the value is recomputed on the next read. This differs from `@Computed`, whose `StoreSync` backend recomputes eagerly on emit (pulling from the source) so it can `ApplyDiff` in place. Both are created lazily on first read.
|
|
825
|
+
|
|
826
|
+
**Granularity matters:** Each `@Scope` getter creates a single cached value. When its dependencies change, ALL downstream consumers re-execute. If the getter creates a new object (filter, composite, etc.), all downstream `data:` bindings see a new reference and update. Use separate `@Scope` getters per independent UI region:
|
|
827
|
+
|
|
828
|
+
```typescript
|
|
829
|
+
// Anti-pattern — single scope for multiple regions
|
|
830
|
+
@Scope()
|
|
831
|
+
get grouped() {
|
|
832
|
+
return {
|
|
833
|
+
categoryA: items.filter(i => i.category === "A"),
|
|
834
|
+
categoryB: items.filter(i => i.category === "B"),
|
|
835
|
+
categoryC: items.filter(i => i.category === "C"),
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
// Changing one group's data creates a new { categoryA, categoryB, categoryC } object,
|
|
839
|
+
// causing all three groups to re-render.
|
|
840
|
+
|
|
841
|
+
// Correct — one scope per independent region
|
|
842
|
+
@Scope() get groupA() { return items.filter(i => i.category === "A"); }
|
|
843
|
+
@Scope() get groupB() { return items.filter(i => i.category === "B"); }
|
|
844
|
+
@Scope() get groupC() { return items.filter(i => i.category === "C"); }
|
|
845
|
+
// Each region reads its own scope. Only affected regions re-render.
|
|
846
|
+
```
|
|
847
|
+
|
|
848
|
+
**When NOT to use:** for composite objects consumed by multiple regions (new ref invalidates all consumers) — use `@Computed()`; and never wrap a `@Scope` in `gate()` (always-new-ref defeats `===`).
|
|
849
|
+
|
|
850
|
+
### @Computed — Cached Getter (Same Reference)
|
|
851
|
+
|
|
852
|
+
```typescript
|
|
853
|
+
@Computed()
|
|
854
|
+
get summary() {
|
|
855
|
+
return { total: this.items.reduce((s, i) => s + i.value, 0), count: this.items.length };
|
|
856
|
+
}
|
|
857
|
+
```
|
|
858
|
+
|
|
859
|
+
Cached, preserves object identity via `ApplyDiff`. No default value parameter. For creating new objects in getter (filtering/sorting/aggregating data into new structures). Backed by `StoreSync`.
|
|
860
|
+
|
|
861
|
+
`@Computed` uses `ApplyDiff` to merge changes into the existing object reference. Downstream consumers using `===` comparison (like `gate()` or `data:` bindings) only see a change when actual structure differs, not when the getter re-runs. Use `@Computed` when returning composite objects consumed by multiple UI regions. Prefer per-region `@Scope` when you need granular updates.
|
|
862
|
+
|
|
863
|
+
> **`@Computed` returns a copy, not the source reference.** The getter's result is copied into a new reactive object whose identity is preserved across updates via `ApplyDiff`. It is a distinct object from whatever the getter read. Consequently, changes to the *source* object do not fire on the copy — the copy only re-evaluates when its own source dependencies change. If you need downstream consumers to observe mutations to an existing reactive object directly, use `@Scope` (which passes the existing reference through) instead.
|
|
864
|
+
|
|
865
|
+
> **Recompute timing.** `@Computed`'s `StoreSync` is created lazily on first read; after that it recomputes eagerly on emit (pulling from the source) so it can `ApplyDiff`. This contrasts with `@Scope`, which re-evaluates lazily on the next read.
|
|
866
|
+
|
|
867
|
+
**Dependency tracking note:** `@Computed` (via `StoreSync`) registers dependencies based on what properties the getter accesses through the proxy. If the getter returns `this.tasks` without iterating or reading individual item properties, per-item mutations (e.g., `task.completed = true`) won't trigger re-evaluation. The getter must touch every reactive property it intends to track — `.filter()`, `.map()`, `.reduce()`, and manual property reads all register deps. Returning the array reference alone only tracks array-level mutations (push, splice, reassignment).
|
|
868
|
+
|
|
869
|
+
**When NOT to use:** for cheap ops or array filter/sort of existing refs — use `@Scope()` or a plain getter (identity already preserved, less overhead).
|
|
870
|
+
|
|
871
|
+
### @ComputedAsync — Sync Getter with StoreAsync Backend
|
|
872
|
+
|
|
873
|
+
```typescript
|
|
874
|
+
@ComputedAsync(null)
|
|
875
|
+
get userData(): User | null {
|
|
876
|
+
return getUserSync(this.Data.userId); // Must be synchronous — "Async" refers to the StoreAsync backend
|
|
877
|
+
}
|
|
878
|
+
```
|
|
879
|
+
|
|
880
|
+
Requires default value parameter. Same reference preservation as `@Computed`. The getter **must be synchronous** — the "Async" in the name refers to the internal `StoreAsync` diffing mechanism, not the getter signature. For async data fetching, use `@Scope() + scope(async)` or `ObservableScope.Create(async)`.
|
|
881
|
+
|
|
882
|
+
### @Watch — Property Change Handler
|
|
883
|
+
|
|
884
|
+
```typescript
|
|
885
|
+
@Watch((self) => self.count)
|
|
886
|
+
handleCountChanged(newValue: number) { console.log("Count:", newValue); }
|
|
887
|
+
|
|
888
|
+
@Watch((self) => self.Data.value)
|
|
889
|
+
onDataChange(newValue: DataType) { /* ... */ }
|
|
890
|
+
|
|
891
|
+
// Syncing child @Value state with parent Data
|
|
892
|
+
@Watch((self) => self.Data.filter)
|
|
893
|
+
syncFromParent(newFilter: FilterType): void {
|
|
894
|
+
this.localFilter = newFilter;
|
|
895
|
+
}
|
|
896
|
+
```
|
|
897
|
+
|
|
898
|
+
Fires immediately with initial value when `Bound()` runs, then on each change. Subscription auto-cleaned on `Destroy()`. Uses greedy (batched) scope (`ObservableScope.Greedy`).
|
|
899
|
+
|
|
900
|
+
**When NOT to use:** for values you only read in `Template()` — reading a scope there is already reactive; `@Watch` is for side effects (syncing state, logging, triggering external calls).
|
|
901
|
+
|
|
902
|
+
### @Inject — Dependency Injection
|
|
903
|
+
|
|
904
|
+
```typescript
|
|
905
|
+
@Inject(DataService) dataService!: DataService;
|
|
906
|
+
```
|
|
907
|
+
|
|
908
|
+
Creates getter/setter using component's injector. Getter: `this.Injector.Get(type)`. Setter: `this.Injector.Set(type, value)`.
|
|
909
|
+
|
|
910
|
+
### @Destroy — Auto-Cleanup
|
|
911
|
+
|
|
912
|
+
```typescript
|
|
913
|
+
@Destroy() timer: Timer = new Timer(); // Timer must implement IDestroyable
|
|
914
|
+
@Destroy() @Inject(DataService) dataService = new DataService();
|
|
915
|
+
```
|
|
916
|
+
|
|
917
|
+
Calls `.Destroy()` on marked properties during component teardown. Requires `IDestroyable` interface.
|
|
918
|
+
|
|
919
|
+
### Decorator Selection
|
|
920
|
+
|
|
921
|
+
| Value Type | Decorator | Why |
|
|
922
|
+
|------------|-----------|-----|
|
|
923
|
+
| `number`, `string`, `boolean` | `@Value` | Lightweight, no proxy |
|
|
924
|
+
| `null`, `undefined` | `@Value` | Simple scope |
|
|
925
|
+
| `{ nested: objects }` | `@State` | Deep reactivity via proxy |
|
|
926
|
+
| `arrays (Item[])` | `@State` | Array mutations tracked |
|
|
927
|
+
| Cheap getter / primitives | `@Scope` | Cached, new ref, minimal overhead |
|
|
928
|
+
| Array map/filter/sort (existing refs) | `@Scope` | Object identity preserved |
|
|
929
|
+
| Creating new objects | `@Computed()` | Cached + same ref via ApplyDiff |
|
|
930
|
+
| Sync getter + StoreAsync backend | `@ComputedAsync(default)` | Same ref, StoreAsync diffing |
|
|
931
|
+
| Watch changes | `@Watch` | Callback on change (greedy/batched) |
|
|
932
|
+
| DI from injector | `@Inject` | Lazy resolution from injector |
|
|
933
|
+
| Cleanup on destroy | `@Destroy` | Auto `.Destroy()` (requires `IDestroyable`) |
|
|
934
|
+
| Simple property access | None (plain getter) | Reading `this.Data` is reactive |
|
|
935
|
+
|
|
936
|
+
### @Computed vs @Scope vs @ComputedAsync
|
|
937
|
+
|
|
938
|
+
| Aspect | `@Computed()` | `@Scope()` | `@ComputedAsync(default)` |
|
|
939
|
+
|--------|------------|------------|--------------------------|
|
|
940
|
+
| Object identity | Same ref (ApplyDiff) | New ref | Same ref (ApplyDiff) |
|
|
941
|
+
| Backend | StoreSync | Single scope | StoreAsync |
|
|
942
|
+
| Default param | No | N/A | Required |
|
|
943
|
+
| Best for | New objects, expensive ops | Primitives, cheap ops, existing-ref arrays | Sync getter + StoreAsync diffing |
|
|
944
|
+
| Composite object for multiple consumers | Yes — same ref, sub-property mutations tracked | No — new ref invalidates all consumers | Yes |
|
|
945
|
+
|
|
946
|
+
**Key:** `@Computed` preserves object identity across updates. Critical when DOM reuse depends on reference stability (e.g., iterating arrays with `data:`).
|
|
947
|
+
|
|
948
|
+
### Async Patterns
|
|
949
|
+
|
|
950
|
+
```typescript
|
|
951
|
+
// 1. Direct async in services (new reference on each update)
|
|
952
|
+
private dataScope = ObservableScope.Create(async () => fetch('/api/data'));
|
|
953
|
+
get data(): Data | null { return ObservableScope.Value(this.dataScope); }
|
|
954
|
+
|
|
955
|
+
// 2. Component async with scope() (new reference on each update)
|
|
956
|
+
@Scope()
|
|
957
|
+
get CurrentUser() { return scope(async () => fetchUser(`/api/user/${this.userId}`)); }
|
|
958
|
+
|
|
959
|
+
// 3. @ComputedAsync — sync getter only, StoreAsync backend (same reference via ApplyDiff)
|
|
960
|
+
@ComputedAsync(null)
|
|
961
|
+
get userData(): User | null { return getUserSync(this.Data.userId); }
|
|
962
|
+
```
|
|
963
|
+
|
|
964
|
+
**Async patterns 1 & 2:** Async functions auto-detected. Automatically sets `greedy: true` (batched updates). Initial value: `null` or Promise. New reference on each update.
|
|
965
|
+
|
|
966
|
+
**Async limitation:** Dependencies are only captured synchronously. Read all reactive values before the first `await`. Reactive reads after `await` are not tracked.
|
|
967
|
+
|
|
968
|
+
**Pattern 3 (@ComputedAsync):** Getter must be synchronous. Returns default value initially, then computed value with same reference via ApplyDiff.
|
|
969
|
+
|
|
970
|
+
### State Location
|
|
971
|
+
|
|
972
|
+
| State Type | Location | API |
|
|
973
|
+
|------------|----------|-----|
|
|
974
|
+
| Raw data store | Service | `StoreAsync`/`StoreSync` |
|
|
975
|
+
| Derived (shared) | Service | `ObservableScope.Create()` |
|
|
976
|
+
| Derived (local, cheap) | Component | `@Scope()` |
|
|
977
|
+
| Derived (local, new objects) | Component | `@Computed()` |
|
|
978
|
+
| Primitives (local) | Component | `@Value()` |
|
|
979
|
+
| Complex (local) | Component | `@State()` |
|
|
980
|
+
| Async (component) | Component | `@Scope() + scope(async)` |
|
|
981
|
+
| Async (service) | Service | `ObservableScope.Create(async)` |
|
|
982
|
+
| External resources | Service | `IDestroyable` |
|
|
983
|
+
|
|
984
|
+
---
|
|
985
|
+
|
|
986
|
+
## Scope Selection Decision Tree
|
|
987
|
+
|
|
988
|
+
Need derived data?
|
|
989
|
+
-> Is it a simple read from `this.Data` without computation?
|
|
990
|
+
- Yes -> No decorator needed — plain getter. Reading `this.Data` is already reactive.
|
|
991
|
+
- Read it inside children function, `props:` function, or `data:` binding for scoped subscriptions
|
|
992
|
+
-> Consumed by one UI region?
|
|
993
|
+
- Yes -> `@Scope()` per region, read inside children function
|
|
994
|
+
- No, multiple regions need different slices?
|
|
995
|
+
- Cheap, same reference (filter/sort of existing array) -> `@Scope()` per region
|
|
996
|
+
- Creating new composite object?
|
|
997
|
+
- Need reference stability across updates -> `@Computed()`
|
|
998
|
+
- Each region independent -> `@Scope()` per region
|
|
999
|
+
|
|
1000
|
+
Where to read a scope in Template()?
|
|
1001
|
+
- Top of `Template()` -> subscribes entire Template (avoid unless unavoidable)
|
|
1002
|
+
- Inside children function -> subscribes only that subtree (preferred)
|
|
1003
|
+
- Inside `data:` binding -> subscribes only that iteration (preferred)
|
|
1004
|
+
|
|
1005
|
+
Need conditional rendering?
|
|
1006
|
+
- No "else" branch needed -> `data:` boolean (`data: () => this.condition`)
|
|
1007
|
+
- "Else" branch needed, condition isolated from siblings -> nested children function with ternary
|
|
1008
|
+
- "Else" branch needed, condition shares scope with frequently-changing siblings -> `gate()` ternary
|
|
1009
|
+
|
|
1010
|
+
---
|
|
1011
|
+
|
|
1012
|
+
## Inline Computed Scopes: scope(), gate(), peek(), mapped()
|
|
1013
|
+
|
|
1014
|
+
Four functions for creating memoized computed scopes inline within a watch context (template functions, `@Scope` getters, etc.). All accept `() => T | Promise<T>` — async callbacks are resolved and the resolved value is emitted. They live in `src/Store/Tree/observableScope.ts` and are re-exported from the root (`src/index.ts`).
|
|
1015
|
+
|
|
1016
|
+
**Only works within a watch context** — throws if called outside (e.g. `scope() must be called within a watch context`).
|
|
1017
|
+
|
|
1018
|
+
### scope() — Full Reactivity
|
|
1019
|
+
|
|
1020
|
+
Creates an inline computed scope registered as a dependency of the parent. Emits on every recomputation — no `===` gating.
|
|
1021
|
+
|
|
1022
|
+
```typescript
|
|
1023
|
+
import { scope } from "j-templates";
|
|
1024
|
+
|
|
1025
|
+
// Inline computed value
|
|
1026
|
+
div({ data: () => scope(() => this.Data.items) }, (item) => div({}, () => item.name));
|
|
1027
|
+
|
|
1028
|
+
// Async data fetching in a getter
|
|
1029
|
+
@Scope()
|
|
1030
|
+
get userData(): User {
|
|
1031
|
+
return scope(async () => fetchUser(`/api/user/${this.userId}`));
|
|
1032
|
+
}
|
|
1033
|
+
```
|
|
1034
|
+
|
|
1035
|
+
### gate() — Emission Gatekeeper
|
|
1036
|
+
|
|
1037
|
+
Like `scope()`, but only emits when the value actually changes (`===` comparison). Prevents unnecessary downstream re-evaluations.
|
|
1038
|
+
|
|
1039
|
+
```typescript
|
|
1040
|
+
import { gate } from "j-templates";
|
|
1041
|
+
|
|
1042
|
+
// Primitive gating — prevents emission when result unchanged
|
|
1043
|
+
gate(() => this.Data.count > 10);
|
|
1044
|
+
|
|
1045
|
+
// Array reference gating
|
|
1046
|
+
div({ data: () => gate(() => this.Data.items) }, (item) => div({}, () => item.name));
|
|
1047
|
+
|
|
1048
|
+
// Conditional rendering — only re-evaluates when boolean flips, not on every upstream emission.
|
|
1049
|
+
gate(() => this.visibleTodos.length === 0)
|
|
1050
|
+
? div({}, () => "No items")
|
|
1051
|
+
: div({ data: () => this.visibleTodos }, (item) => ...),
|
|
1052
|
+
|
|
1053
|
+
// Multiple gate scopes with custom IDs
|
|
1054
|
+
gate(() => computeA(), "id-a");
|
|
1055
|
+
gate(() => computeB(), "id-b");
|
|
1056
|
+
|
|
1057
|
+
> **⚠️ ID collision is per-scope.** The custom ID only disambiguates multiple `scope()`/`gate()`/`peek()` calls **within the same ObservableScope definition** (one watch context). If you call the same helper twice in one watch context without IDs, the second call silently resolves to the first scope. IDs are not global — two calls in different components/scopes never collide.
|
|
1058
|
+
```
|
|
1059
|
+
|
|
1060
|
+
#### When to Use gate()
|
|
1061
|
+
|
|
1062
|
+
| Scenario | Use gate()? | Why |
|
|
1063
|
+
|----------|-------------|-----|
|
|
1064
|
+
| Condition shares scope with reactive siblings | Yes | Prevents sibling re-render when boolean doesn't flip |
|
|
1065
|
+
| Conditional rendering (boolean flip) | Yes | Re-evaluates ternary only when value changes true↔false |
|
|
1066
|
+
| Direct `@State` array access | Optional | Value gating when parent changes |
|
|
1067
|
+
| Static constant array | No | Never changes, adds overhead |
|
|
1068
|
+
| Primitive derived state | Yes | `===` prevents unnecessary emissions |
|
|
1069
|
+
| Parent aggregates multiple values | Yes | Child only cares about specific parts |
|
|
1070
|
+
| Array transformations (filter/map) | No | Always new references, never helps |
|
|
1071
|
+
| Multiple uses in same template | Yes | Scope reuse avoids duplicate work |
|
|
1072
|
+
| Wrapping `@Scope` getter | No | `@Scope` always returns new ref; `===` always differs |
|
|
1073
|
+
|
|
1074
|
+
#### Empty state vs. list — the canonical `gate()` pattern
|
|
1075
|
+
|
|
1076
|
+
The most common real-world use of `gate()` is choosing between an empty-state
|
|
1077
|
+
message and a `data:` list, where the two must be mutually exclusive (never both
|
|
1078
|
+
in the DOM):
|
|
1079
|
+
|
|
1080
|
+
```typescript
|
|
1081
|
+
div({}, () =>
|
|
1082
|
+
gate(() => this.visibleTodos.length === 0)
|
|
1083
|
+
? div({}, () => "No items")
|
|
1084
|
+
: div({ data: () => this.visibleTodos }, (item) => div({}, () => item.name)),
|
|
1085
|
+
)
|
|
1086
|
+
```
|
|
1087
|
+
|
|
1088
|
+
**Why `gate()` and not a nested children function?** The wrapper children
|
|
1089
|
+
function reads `this.visibleTodos.length`. Without `gate()`, *every* change to
|
|
1090
|
+
`visibleTodos` (e.g. toggling one item while the list stays non-empty) re-runs
|
|
1091
|
+
the wrapper and rebuilds the whole subtree. `gate()` only emits when the boolean
|
|
1092
|
+
flips, so the ternary is not re-evaluated on those upstream emissions. The list
|
|
1093
|
+
still updates correctly because its `data: () => this.visibleTodos` binding has
|
|
1094
|
+
its own reactive scope that subscribes to `visibleTodos` directly — independent
|
|
1095
|
+
of the wrapper.
|
|
1096
|
+
|
|
1097
|
+
**Why not `data:` boolean?** `data:` boolean only controls the element's
|
|
1098
|
+
*children*; the element itself stays in the DOM. A styled empty-state container
|
|
1099
|
+
would remain visible as an empty box. `gate()` (or a nested children function)
|
|
1100
|
+
actually removes the element.
|
|
1101
|
+
|
|
1102
|
+
**What gate() does NOT do:** Make arrays reactive (`@State` already does that). Prevent emissions for array transformations (always new refs). Provide object reuse (that's `@Computed`).
|
|
1103
|
+
|
|
1104
|
+
`gate()` and `@Scope` are incompatible: `@Scope` returns a new reference on every update, so `gate()`'s `===` comparison always sees a change. If you need both caching and reference stability, use `@Computed()` instead.
|
|
1105
|
+
|
|
1106
|
+
### peek() — Read Without Subscribing
|
|
1107
|
+
|
|
1108
|
+
Creates a memoized computed scope that does **not** register as a dependency. Use this to read reactive data without the parent scope subscribing to changes.
|
|
1109
|
+
|
|
1110
|
+
```typescript
|
|
1111
|
+
import { peek } from "j-templates";
|
|
1112
|
+
|
|
1113
|
+
// Read reactive data without subscribing
|
|
1114
|
+
const timestamp = peek(() => Date.now());
|
|
1115
|
+
|
|
1116
|
+
// Read with custom ID for multiple uses
|
|
1117
|
+
const id = peek(() => this.Data.id, "id");
|
|
1118
|
+
const name = peek(() => this.Data.name, "name");
|
|
1119
|
+
```
|
|
1120
|
+
|
|
1121
|
+
`peek()` differs from `gate()` in that the created scope does not register as a dependency. Changes to data accessed within the callback will not trigger recomputation of the parent scope. The scope is still memoized by ID to avoid redundant computation within the same evaluation.
|
|
1122
|
+
|
|
1123
|
+
### mapped() — Per-Item Scopes (Advanced)
|
|
1124
|
+
|
|
1125
|
+
`mapped(data, callback, onUpdated?, onDestroyed?)` creates a per-item scope for a single data value. This is the mechanism `data:` uses internally. Only needed for advanced manual per-item scoping.
|
|
1126
|
+
|
|
1127
|
+
```typescript
|
|
1128
|
+
import { mapped } from "j-templates";
|
|
1129
|
+
|
|
1130
|
+
mapped(data, (d) => /* ... */, (lastValue, scope) => /* onUpdated */, (lastValue) => /* onDestroyed */);
|
|
1131
|
+
```
|
|
1132
|
+
|
|
1133
|
+
Signature (see `MappedScope` in `src/Store/Tree/observableScope.ts`):
|
|
1134
|
+
|
|
1135
|
+
```typescript
|
|
1136
|
+
function mapped<D, T>(
|
|
1137
|
+
data: D,
|
|
1138
|
+
callback: (data: D) => T | Promise<T>,
|
|
1139
|
+
onUpdated?: (lastValue: T, scope: IObservableScope<T>) => void,
|
|
1140
|
+
onDestroyed?: (lastValue: T) => void
|
|
1141
|
+
): T
|
|
1142
|
+
```
|
|
1143
|
+
|
|
1144
|
+
- `data` is a **single** value — there is no array-iterating form. Iterate arrays with `data:` on a DOM element instead.
|
|
1145
|
+
- `onUpdated` fires when the per-item scope's value changes.
|
|
1146
|
+
- `onDestroyed` fires when the per-item scope is torn down.
|
|
1147
|
+
- Like the other inline scopes, `mapped()` must be called within a watch context.
|
|
1148
|
+
|
|
1149
|
+
#### Comparison
|
|
1150
|
+
|
|
1151
|
+
| Function | Registers dependency | Gates on `===` | Use when |
|
|
1152
|
+
|----------|---------------------|----------------|----------|
|
|
1153
|
+
| `scope()` | Yes | No | Full reactivity needed |
|
|
1154
|
+
| `gate()` | Yes | Yes | Prevent unnecessary downstream updates |
|
|
1155
|
+
| `peek()` | No | N/A | One-time reads, display-only values |
|
|
1156
|
+
| `mapped()` | Yes (per item) | No | Per-item scopes (advanced) |
|
|
1157
|
+
|
|
1158
|
+
---
|
|
1159
|
+
|
|
1160
|
+
## Component Composition
|
|
1161
|
+
|
|
1162
|
+
### Parent → Child (Data)
|
|
1163
|
+
|
|
1164
|
+
```typescript
|
|
1165
|
+
// Parent passes data (read-only in child)
|
|
1166
|
+
childComponent({ data: () => ({ userId: this.userId }) });
|
|
1167
|
+
|
|
1168
|
+
// Child receives via this.Data
|
|
1169
|
+
class Child extends Component<{ userId: string }> {
|
|
1170
|
+
Template() { return div({}, () => this.Data.userId); }
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
// @Value() is for component-internal mutable state, not parent data
|
|
1174
|
+
```
|
|
1175
|
+
|
|
1176
|
+
### Child → Parent (Events)
|
|
1177
|
+
|
|
1178
|
+
```typescript
|
|
1179
|
+
interface ChildEvents { save: { data: string }; }
|
|
1180
|
+
|
|
1181
|
+
class Child extends Component<{}, {}, ChildEvents> {
|
|
1182
|
+
Template() { return button({ on: { click: () => this.Fire("save", { data: "value" }) } }); }
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
childComponent({ on: { save: (payload) => console.log(payload.data) } });
|
|
1186
|
+
```
|
|
1187
|
+
|
|
1188
|
+
Component events don't bubble through DOM — only via j-templates `on:` system.
|
|
1189
|
+
|
|
1190
|
+
### Template Callbacks
|
|
1191
|
+
|
|
1192
|
+
```typescript
|
|
1193
|
+
interface ItemTemplate<D> { render: (data: D) => vNode; }
|
|
1194
|
+
|
|
1195
|
+
class Container<D> extends Component<{ items: D[] }, ItemTemplate<D>> {
|
|
1196
|
+
Template() {
|
|
1197
|
+
return div({ data: () => this.Data.items }, (item: D) => this.Templates.render(item));
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
container({ data: () => ({ items: users }) }, { render: (user) => div({}, () => user.name) });
|
|
1202
|
+
```
|
|
1203
|
+
|
|
1204
|
+
Template callbacks are suitable for simple rendering logic with no internal state. When the rendered item needs its own state, lifecycle, or events, use a dedicated component instead:
|
|
1205
|
+
|
|
1206
|
+
```typescript
|
|
1207
|
+
// Callback — no state, no events, no lifecycle
|
|
1208
|
+
container({ data: () => items }, (item) => div({}, () => item.name));
|
|
1209
|
+
|
|
1210
|
+
// Component — full reactivity, events, lifecycle
|
|
1211
|
+
container({ data: () => items }, (item) =>
|
|
1212
|
+
itemCard({ data: () => ({ item }), on: { deleted: (p) => handleDelete(p) } })
|
|
1213
|
+
);
|
|
1214
|
+
```
|
|
1215
|
+
|
|
1216
|
+
Prefer dedicated components when the item needs internal state (`@Value`, `@State`), fires events, requires `Bound()`/`Destroy()` lifecycle, or injects services.
|
|
1217
|
+
|
|
1218
|
+
### Sibling Communication (Shared Service)
|
|
1219
|
+
|
|
1220
|
+
Inject same service, use `ObservableScope` for reactive messaging. See `docs/patterns/04-dependency-injection.md`.
|
|
1221
|
+
|
|
1222
|
+
### Accessing DOM Elements
|
|
1223
|
+
|
|
1224
|
+
```typescript
|
|
1225
|
+
Bound() {
|
|
1226
|
+
super.Bound();
|
|
1227
|
+
const host = this.VNode.node as HTMLElement;
|
|
1228
|
+
const scrollEl = host.querySelector('.scroll-container');
|
|
1229
|
+
scrollEl?.scrollTo(0, scrollEl.scrollHeight);
|
|
1230
|
+
// Use requestAnimationFrame() if child elements aren't ready yet
|
|
1231
|
+
}
|
|
1232
|
+
```
|
|
1233
|
+
|
|
1234
|
+
---
|
|
1235
|
+
|
|
1236
|
+
## Dependency Injection
|
|
1237
|
+
|
|
1238
|
+
### Injector API
|
|
1239
|
+
|
|
1240
|
+
> ⚠️ The `Injector` class is **not exported** from the package's public entry points. The API below documents its behavior for understanding `@Inject` and `this.Injector`; do not import `Injector` directly.
|
|
1241
|
+
|
|
1242
|
+
```typescript
|
|
1243
|
+
class Injector {
|
|
1244
|
+
constructor(); // Sets parent to Injector.Current()
|
|
1245
|
+
Get<T>(type: any): T; // Searches parent chain. Returns undefined as T if not found.
|
|
1246
|
+
Set<T>(type: any, instance: T): T; // Sets at this scope, returns instance.
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
namespace Injector {
|
|
1250
|
+
Current(): Injector | null;
|
|
1251
|
+
Scope<R, P>(injector: Injector, action: (...args: P) => R, ...args: P): R;
|
|
1252
|
+
}
|
|
1253
|
+
```
|
|
1254
|
+
|
|
1255
|
+
### Abstract Service Pattern
|
|
1256
|
+
|
|
1257
|
+
```typescript
|
|
1258
|
+
abstract class IDataService implements IDestroyable {
|
|
1259
|
+
abstract getData(): Data[];
|
|
1260
|
+
abstract Destroy(): void;
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
class DataService implements IDataService {
|
|
1264
|
+
private store = new StoreSync();
|
|
1265
|
+
getData(): Data[] { return this.store.Get<Data[]>("data", []); }
|
|
1266
|
+
Destroy(): void { /* cleanup */ }
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
// Parent provides, child consumes
|
|
1270
|
+
class App extends Component {
|
|
1271
|
+
@Destroy() @Inject(IDataService) dataService = new DataService();
|
|
1272
|
+
}
|
|
1273
|
+
class Child extends Component {
|
|
1274
|
+
@Inject(IDataService) dataService!: IDataService;
|
|
1275
|
+
}
|
|
1276
|
+
```
|
|
1277
|
+
|
|
1278
|
+
**Key:** Injector has parent chain — `Get` traverses up. `@Inject` provides getter + setter. Combine `@Inject` + `@Destroy` for services needing cleanup. `@Destroy` requires `IDestroyable`.
|
|
1279
|
+
|
|
1280
|
+
---
|
|
1281
|
+
|
|
1282
|
+
## Store (StoreSync & StoreAsync)
|
|
1283
|
+
|
|
1284
|
+
### Choosing Between StoreSync and StoreAsync
|
|
1285
|
+
|
|
1286
|
+
Both stores share the same API and flattening model, but differ fundamentally in where and how diffing occurs.
|
|
1287
|
+
|
|
1288
|
+
**StoreSync** computes diffs synchronously on the main thread. Writes are immediate and consistent — a value written is readable in the same tick. It has no worker overhead, no serialisation constraints, and no `Destroy()` requirement. Use StoreSync for the vast majority of application state: user data, UI state, app config, form data, and any dataset where diff computation is not a bottleneck. `@Computed` uses `StoreSync` internally.
|
|
1289
|
+
|
|
1290
|
+
**StoreAsync** offloads all diff computation to a dedicated Web Worker via a serialised message queue. The worker maintains its own shadow copy of the store state and computes minimal diffs off the main thread, returning only the changed paths. This prevents large dataset operations from blocking rendering or input. Use StoreAsync when diffing genuinely large or deeply nested datasets — message feeds, large tables, real-time data — where synchronous diffing would cause frame drops. `@ComputedAsync` uses `StoreAsync` internally.
|
|
1291
|
+
|
|
1292
|
+
**If you are unsure which to use, start with StoreSync.** StoreAsync introduces meaningful constraints (see below) that are only worth accepting when the dataset size justifies off-thread diffing.
|
|
1293
|
+
|
|
1294
|
+
| Aspect | StoreSync | StoreAsync |
|
|
1295
|
+
|--------|-----------|------------|
|
|
1296
|
+
| Diff execution | Main thread, synchronous | Web Worker, asynchronous |
|
|
1297
|
+
| Write consistency | Immediate — readable same tick | Eventual — must `await` before reading |
|
|
1298
|
+
| `keyFunc` constraint | None — can close over outer scope | **Must be self-contained** — serialised via `.toString()` and `eval`'d in worker |
|
|
1299
|
+
| Data constraint | Any JS value | **JSON-serialisable only** — no class instances, methods, `Date`, `Map`, `Set`, circular refs |
|
|
1300
|
+
| `Destroy()` required | No | Yes — terminates worker and queue |
|
|
1301
|
+
| Best for | Most app state | Large / real-time datasets |
|
|
1302
|
+
| Decorator backend | `@Computed` | `@ComputedAsync` |
|
|
1303
|
+
|
|
1304
|
+
### StoreAsync Constraints
|
|
1305
|
+
|
|
1306
|
+
StoreAsync's worker is bootstrapped by serialising `keyFunc` and the diff engine as strings and executing them inside a Blob URL. This has two hard constraints:
|
|
1307
|
+
|
|
1308
|
+
**1. `keyFunc` must be self-contained — no closed-over variables.**
|
|
1309
|
+
|
|
1310
|
+
The function is serialised via `.toString()` and `eval`'d in the worker context. Any variable from the outer scope will be undefined inside the worker.
|
|
1311
|
+
|
|
1312
|
+
```typescript
|
|
1313
|
+
// ❌ Breaks at runtime — prefix is not accessible in the worker
|
|
1314
|
+
const prefix = "user";
|
|
1315
|
+
const store = new StoreAsync((val) => val?.id ? `${prefix}_${val.id}` : undefined);
|
|
1316
|
+
|
|
1317
|
+
// ✅ Self-contained — no outer scope references
|
|
1318
|
+
const store = new StoreAsync((val) => val?.id ? `user_${val.id}` : undefined);
|
|
1319
|
+
```
|
|
1320
|
+
|
|
1321
|
+
**2. All data must be JSON-serialisable.**
|
|
1322
|
+
|
|
1323
|
+
The worker communicates via `postMessage`, which uses the structured clone algorithm. Class instances with methods, `Date` objects, `Map`, `Set`, `undefined` values, and circular references will be lost or throw.
|
|
1324
|
+
|
|
1325
|
+
```typescript
|
|
1326
|
+
// ❌ Methods and class instances are stripped by structured clone
|
|
1327
|
+
class Todo { constructor(public id: string, public text: string) {} getText() { return this.text; } }
|
|
1328
|
+
await store.Write(new Todo("1", "Buy milk"), "todo"); // getText() lost in transit
|
|
1329
|
+
|
|
1330
|
+
// ✅ Plain objects only
|
|
1331
|
+
await store.Write({ id: "1", text: "Buy milk" }, "todo");
|
|
1332
|
+
```
|
|
1333
|
+
|
|
1334
|
+
### Base Store API
|
|
1335
|
+
|
|
1336
|
+
```typescript
|
|
1337
|
+
class Store {
|
|
1338
|
+
constructor(keyFunc?: (value: any) => string | undefined);
|
|
1339
|
+
Get<O>(id: string): O | undefined; // Returns undefined if not found
|
|
1340
|
+
Get<O>(id: string, defaultValue: O): O; // Creates and returns default if not found
|
|
1341
|
+
}
|
|
1342
|
+
```
|
|
1343
|
+
|
|
1344
|
+
### StoreSync API
|
|
1345
|
+
|
|
1346
|
+
```typescript
|
|
1347
|
+
class StoreSync extends Store {
|
|
1348
|
+
Write(data: unknown, key?: string): void;
|
|
1349
|
+
Patch(key: string, patch: unknown): void; // Deep merge; throws if key not found
|
|
1350
|
+
Push(key: string, ...data: unknown[]): void;
|
|
1351
|
+
Splice(key: string, start: number, deleteCount?: number, ...items: unknown[]): unknown[];
|
|
1352
|
+
// No Destroy() method
|
|
1353
|
+
}
|
|
1354
|
+
```
|
|
1355
|
+
|
|
1356
|
+
### StoreAsync API
|
|
1357
|
+
|
|
1358
|
+
```typescript
|
|
1359
|
+
class StoreAsync extends Store {
|
|
1360
|
+
async Write(data: unknown, key?: string): Promise<void>;
|
|
1361
|
+
async Patch(key: string, patch: unknown): Promise<void>; // Deep merge; throws if key not found
|
|
1362
|
+
async Push(key: string, ...data: unknown[]): Promise<void>;
|
|
1363
|
+
async Splice(key: string, start: number, deleteCount?: number, ...items: unknown[]): Promise<unknown[]>;
|
|
1364
|
+
Destroy(): void; // Always call when service is destroyed — terminates worker
|
|
1365
|
+
}
|
|
1366
|
+
```
|
|
1367
|
+
|
|
1368
|
+
### keyFunc and Automatic Flattening
|
|
1369
|
+
|
|
1370
|
+
`keyFunc` teaches the store how to extract an ID from any object. On `Write` and `Push`, the store eagerly recurses the entire object tree, calling `keyFunc` on each node. Any node that returns a valid ID is registered as an independently addressable entry in the flat internal map. The original shape is preserved because stored objects are `ObservableNode` proxies — nested objects are virtual getters into the flat map rather than copies, which is what allows `Get` to return the original structure while `Patch` operates on individual entries.
|
|
1371
|
+
|
|
1372
|
+
This means:
|
|
1373
|
+
- `Get("todos")` returns the full original shape as an `ObservableNode` proxy tree
|
|
1374
|
+
- `Patch(id, patch)` can target any registered entry by ID regardless of nesting depth — no need to know where in the hierarchy it lives
|
|
1375
|
+
- A `Patch` on a nested child is immediately reflected in the parent structure on next `Get`, because the parent `ObservableNode` proxy reads from the same flat map entry
|
|
1376
|
+
- If the same ID appears multiple times in a tree, the last encountered object wins
|
|
1377
|
+
- `Patch` performs a deep merge into the located entry
|
|
1378
|
+
|
|
1379
|
+
```typescript
|
|
1380
|
+
// keyFunc extracts the ID from any stored object
|
|
1381
|
+
const store = new StoreSync((value: any) => value?.id);
|
|
1382
|
+
|
|
1383
|
+
// Write a list — each TodoItem is also registered individually by its id
|
|
1384
|
+
store.Write(todos, "todos");
|
|
1385
|
+
|
|
1386
|
+
// Patch a nested TodoItem directly by ID — no need to rewrite the whole list
|
|
1387
|
+
store.Patch(todo.id, { completed: true });
|
|
1388
|
+
|
|
1389
|
+
// Get returns an ObservableNode proxy tree — reflects the patched item immediately
|
|
1390
|
+
const updated = store.Get<TodoItem[]>("todos", []);
|
|
1391
|
+
```
|
|
1392
|
+
|
|
1393
|
+
If an object has no `id` property (or `keyFunc` returns `undefined`), it is stored only under its explicit key and cannot be targeted by `Patch` without that key.
|
|
1394
|
+
|
|
1395
|
+
**Additional key points:**
|
|
1396
|
+
- Explicit key passed to `Write`/`Push` overrides `keyFunc` for the root object
|
|
1397
|
+
- Use separate keys for separate data states (`"messages"` vs `"pending-messages"`)
|
|
1398
|
+
- Always `await` StoreAsync operations before reading back
|
|
1399
|
+
- Cast `Get()` return value with generics: `Get<Type[]>("key", [])`
|
|
1400
|
+
|
|
1401
|
+
### Store Operations Summary
|
|
1402
|
+
|
|
1403
|
+
| Operation | StoreSync | StoreAsync |
|
|
1404
|
+
|-----------|-----------|------------|
|
|
1405
|
+
| Write | sync | async (Promise) |
|
|
1406
|
+
| Push | sync | async (Promise) |
|
|
1407
|
+
| Patch | sync | async (Promise) |
|
|
1408
|
+
| Splice | sync | async (Promise) |
|
|
1409
|
+
| Get | sync | sync |
|
|
1410
|
+
| Destroy | N/A | `Destroy(): void` — required |
|
|
1411
|
+
| keyFunc constraint | None | Self-contained, no closed-over vars |
|
|
1412
|
+
| Data constraint | Any JS value | JSON-serialisable only |
|
|
1413
|
+
|
|
1414
|
+
---
|
|
1415
|
+
|
|
1416
|
+
## ObservableScope API
|
|
1417
|
+
|
|
1418
|
+
```typescript
|
|
1419
|
+
namespace ObservableScope {
|
|
1420
|
+
Create<T>(valueFunction: { (): T | Promise<T> }, greedy?: boolean, force?: boolean): IObservableScope<T>;
|
|
1421
|
+
Value<T>(scope: IObservableScope<T>): T; // Get value + register dependency
|
|
1422
|
+
Peek<T>(scope: IObservableScope<T>): T; // Get value without registering dependency
|
|
1423
|
+
Touch<T>(scope: IObservableScope<T>): void; // Register as dependency without reading value
|
|
1424
|
+
Watch<T>(scope: IObservableScope<T>, callback: EmitterCallback<[IObservableScope<T>]>): void;
|
|
1425
|
+
Unwatch<T>(scope: IObservableScope<T>, callback: EmitterCallback<[IObservableScope<T>]>): void;
|
|
1426
|
+
OnDestroyed(scope: IObservableScope<unknown>, callback: EmitterCallback): void;
|
|
1427
|
+
Update(scope: IObservableScope<any>): void; // Mark dirty, triggers recomputation
|
|
1428
|
+
Register(emitter: Emitter): void;
|
|
1429
|
+
Destroy<T>(scope: IObservableScope<T>): void;
|
|
1430
|
+
DestroyAll(scopes: IObservableScope<unknown>[]): void;
|
|
1431
|
+
}
|
|
1432
|
+
```
|
|
1433
|
+
|
|
1434
|
+
**Async limitation:** Dependencies are only captured synchronously. Read all reactive values before the first `await`. Reactive reads after `await` are not tracked.
|
|
1435
|
+
|
|
1436
|
+
### Service Patterns
|
|
1437
|
+
|
|
1438
|
+
```typescript
|
|
1439
|
+
// Derived state in services
|
|
1440
|
+
class DataService implements IDestroyable {
|
|
1441
|
+
private store = new StoreAsync((value) => value.id);
|
|
1442
|
+
private derived = ObservableScope.Create(() => {
|
|
1443
|
+
const items = this.store.Get<Item[]>("items", []);
|
|
1444
|
+
return ObservableNode.Unwrap(items).filter(i => i.active);
|
|
1445
|
+
});
|
|
1446
|
+
get DerivedData() { return ObservableScope.Value(this.derived); }
|
|
1447
|
+
Destroy(): void { this.store.Destroy(); ObservableScope.Destroy(this.derived); }
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
// Reactive counter via shared service
|
|
1451
|
+
class CounterService implements IDestroyable {
|
|
1452
|
+
private _count = 0;
|
|
1453
|
+
private countScope = ObservableScope.Create(() => this._count, false, true);
|
|
1454
|
+
get count() { return ObservableScope.Value(this.countScope); }
|
|
1455
|
+
increment() { this._count++; ObservableScope.Update(this.countScope); }
|
|
1456
|
+
Destroy(): void { ObservableScope.Destroy(this.countScope); }
|
|
1457
|
+
}
|
|
1458
|
+
```
|
|
1459
|
+
|
|
1460
|
+
---
|
|
1461
|
+
|
|
1462
|
+
## ObservableNode API
|
|
1463
|
+
|
|
1464
|
+
```typescript
|
|
1465
|
+
namespace ObservableNode {
|
|
1466
|
+
Create<T>(value: T): T; // Wrap in reactive proxy
|
|
1467
|
+
Unwrap<T>(value: T): T; // Get raw value from proxy
|
|
1468
|
+
Touch(value: unknown, prop?: string | number): void; // Manually trigger change
|
|
1469
|
+
ApplyDiff(rootNode: any, diffResult: JsonDiffResult): void; // Apply diff in-place (@Computed uses this)
|
|
1470
|
+
CreateFactory(alias?: (value: any) => any | undefined): <T>(value: T) => T; // Factory with aliasing
|
|
1471
|
+
}
|
|
1472
|
+
```
|
|
1473
|
+
|
|
1474
|
+
**Array operations on ObservableNode proxies:** `push`, `pop`, `shift`, `unshift`, `splice`, `sort`, `reverse` — all trigger reactive updates.
|
|
1475
|
+
|
|
1476
|
+
---
|
|
1477
|
+
|
|
1478
|
+
## Traps & Gotchas
|
|
1479
|
+
|
|
1480
|
+
These are the subtle behaviors that cause the most bugs. Read this before writing components.
|
|
1481
|
+
|
|
1482
|
+
1. **No vNode diffing.** The framework never reconciles vNode trees. A scope emission re-runs the children function and rebuilds its subtree. Optimize by minimizing emission frequency, not re-run cost.
|
|
1483
|
+
2. **Async dependencies are captured synchronously only.** Read all reactive values before the first `await`. Reads after `await` are not tracked.
|
|
1484
|
+
3. **`data:` collapses ALL falsy non-array values to `[]`** — `false`, `null`, `undefined`, **and `0`, `""`, `NaN`**. Only truthy non-array values wrap as `[value]`.
|
|
1485
|
+
4. **`data:` boolean renders the element, not nothing.** A falsy `data:` value removes the element's *children*, but the element itself stays in the DOM. A styled container (padding/background/border) will still show as an empty box. To remove an element entirely, use a nested children function or `gate()`.
|
|
1486
|
+
5. **`@ComputedAsync` is a *sync* getter.** The "Async" refers to the `StoreAsync` backend, not the getter signature. For real async, use `@Scope() + scope(async)` or `ObservableScope.Create(async)`.
|
|
1487
|
+
6. **`gate()` is incompatible with `@Scope`.** `@Scope` returns a new reference every update, so `gate()`'s `===` always sees a change. Use `@Computed()` for reference stability.
|
|
1488
|
+
7. **Per-item scope reuse is identity-based, not key-based.** The same data object reference reuses its scope; a new reference creates a new scope.
|
|
1489
|
+
8. **`@Watch` fires immediately on `Bound()`** with the initial value — not just on changes. Missing `super.Bound()` means `@Watch` never fires.
|
|
1490
|
+
9. **`@State` arrays support direct mutation** (`push`, `splice`, item property writes) because they're proxies. Plain arrays require reassignment.
|
|
1491
|
+
10. **`@Computed` only tracks what the getter touches.** Returning `this.tasks` without reading item properties won't re-trigger on per-item mutations. Touch every property you track.
|
|
1492
|
+
11. **`scope()`/`gate()`/`peek()`/`mapped()` throw outside a watch context.** They must be called inside a template function, `@Scope` getter, or other watch context.
|
|
1493
|
+
12. **`Injector` is not publicly exported.** Use `@Inject` and `this.Injector` on components.
|
|
1494
|
+
13. **StoreAsync data must be JSON-serialisable** and `keyFunc` must be self-contained (no closed-over variables). Always `await` StoreAsync writes before reading.
|
|
1495
|
+
14. **Two-way binding needs reactive props** (`props: () => ({ value })`). A static `props: { value }` object causes input focus loss.
|
|
1496
|
+
15. **Reading a scope at the top of `Template()` subscribes the whole component.** Read scopes inside children functions or `data:` bindings for fine-grained updates.
|
|
1497
|
+
16. **`scope()`/`gate()`/`peek()` ID collisions are per-scope.** Multiple calls to the same helper in one watch context without IDs silently resolve to the first scope. Provide distinct IDs when calling the same helper more than once in a single ObservableScope definition.
|
|
1498
|
+
17. **`IsAsync` only detects the `async` keyword.** A function that *returns* a Promise but is not declared `async` (e.g. `() => fetch(...)`) is treated as synchronous — the scope stores the Promise as its value instead of resolving it. Always write `async () => ...` for async scopes.
|
|
1499
|
+
18. **`fragment()` has no DOM node.** It cannot be attached directly (wrap it in a real element) and a falsy `data:` value renders *nothing* — no empty wrapper box. Its children reconcile into the nearest real ancestor.
|
|
1500
|
+
19. **`@State`/`ObservableNode` only deep-tracks plain objects and arrays.** `JsonType` classifies values by prototype; class instances, `Date`, `Map`, `Set`, and other non-plain objects are treated as opaque primitives — nested mutations won't be tracked. Use plain objects/arrays for reactive state.
|
|
1501
|
+
|
|
1502
|
+
---
|
|
1503
|
+
|
|
1504
|
+
## Anti-Patterns
|
|
1505
|
+
|
|
1506
|
+
| Mistake | Fix |
|
|
1507
|
+
|---------|-----|
|
|
1508
|
+
| `@State()` for primitives | Use `@Value()` |
|
|
1509
|
+
| `@Computed()` for cheap ops | Use `@Scope()` or plain getter |
|
|
1510
|
+
| `@Computed()` for array filter/sort of existing refs | Use `@Scope()` — identity already preserved |
|
|
1511
|
+
| `@Computed()` getter returning `this.tasks` without reading item props | Use plain getter or iterate items in the getter — per-item mutations won't re-trigger else |
|
|
1512
|
+
| Single `@Scope` for multiple independent UI regions | One `@Scope` per region |
|
|
1513
|
+
| `gate()` wrapping `@Scope` getter | Use `@Computed` or remove `gate()` |
|
|
1514
|
+
| `data:` binding inside helper function called from `Template()` | Inline in `Template()` with `@Scope` data source |
|
|
1515
|
+
| `@Scope` read at top of `Template()` | Read inside children function or `data:` binding |
|
|
1516
|
+
| `this.Data` read at top of `Template()` | Read inside children function, `props:` function, or `data:` binding |
|
|
1517
|
+
| Render callback for items needing state/events | Use dedicated component |
|
|
1518
|
+
| Child `@Value` not synced with parent `Data` | Use `@Watch((self) => self.Data.prop)` |
|
|
1519
|
+
| `.filter(Boolean)` for conditional rendering | Use ternary with `text(() => "")` fallback |
|
|
1520
|
+
| Condition and sibling `data:` list in same children function | Wrap condition in nested children function, use `data:` boolean, or use `gate()` |
|
|
1521
|
+
| `data:` boolean for a styled container that should disappear | Use nested children function or `gate()` — `data:` boolean keeps the element in the DOM (empty) |
|
|
1522
|
+
| Assuming framework diffs vNode trees | It doesn't — optimize by minimizing scope emission frequency |
|
|
1523
|
+
| `@State()` on class instances / `Date` / `Map` / `Set` | Use plain objects/arrays — non-plain objects are treated as primitives (no deep reactivity) |
|
|
1524
|
+
| Promise-returning arrow without `async` keyword in an async scope | Use `async () => ...` — `IsAsync` only detects `async` functions |
|
|
1525
|
+
|
|
1526
|
+
---
|
|
1527
|
+
|
|
1528
|
+
## Debugging Reactivity
|
|
1529
|
+
|
|
1530
|
+
| Symptom | Likely Cause | Fix |
|
|
1531
|
+
|---------|--------------|-----|
|
|
1532
|
+
| Initial render works, updates don't | Direct array mutation | Replace array, don't mutate (or use `@State` proxy) |
|
|
1533
|
+
| Template never re-renders | `data:` not a function | Use `data: () => this.state` |
|
|
1534
|
+
| Getter value stale | Not using `this.Data` | Read from `this.Data` in getter |
|
|
1535
|
+
| `@Watch` never fires | Missing `super.Bound()` | Call in `Bound()` method |
|
|
1536
|
+
| Memory leak | Missing cleanup | `@Destroy()` + `super.Destroy()` |
|
|
1537
|
+
| Input loses focus | Static `props` object | Use `props: () => ({ value })` |
|
|
1538
|
+
| Entire Template re-runs on small change | `@Scope` read at top of `Template()` | Read scope inside children function or `data:` binding |
|
|
1539
|
+
| Entire section re-renders on small change | Single `@Scope` feeds multiple regions | Split into per-region `@Scope` getters |
|
|
1540
|
+
| `gate()` doesn't prevent re-renders | Wrapping `@Scope` getter (always new ref) | Read `@Scope` directly or use `@Computed` |
|
|
1541
|
+
| `data:` binding re-renders every time | Element created in helper, not `Template()` | Inline element in `Template()` |
|
|
1542
|
+
| Child form controls don't reflect parent changes | No sync from `this.Data` to `@Value` | Add `@Watch((self) => self.Data.prop)` |
|
|
1543
|
+
| Conditional re-renders when sibling list updates | Condition and list share same children function scope | Isolate condition into nested children function, `data:` boolean, or `gate()` |
|
|
1544
|
+
| Expensive Template re-runs on every change | Large vNode subtree subscribed to frequently-changing scope | Split into smaller scopes to reduce rebuild surface |
|
|
1545
|
+
| Async scope resolves to a Promise instead of a value | Callback not declared `async` | Use `async () => ...` so `IsAsync` detects it |
|
|
1546
|
+
|
|
1547
|
+
### Internal Mechanics
|
|
1548
|
+
|
|
1549
|
+
- **No vNode Diffing:** The framework does not diff vNode trees. When a scope emits, the children function re-runs, producing new vNodes. The DOM is patched from old to new. Per-item scopes are reused when the same data object reference reappears (identity-based, not key-based).
|
|
1550
|
+
- **DOM-node reconciliation by reference:** `reconcileChildren` reuses a DOM node only when the vNode object reference is identical (per-item reuse); a new vNode creates a new DOM node. Text nodes are reused and updated in place (`setText`) rather than replaced. There is no keyed or positional vNode matching.
|
|
1551
|
+
- **Fragments reconcile into the ancestor:** `fragment()` has no DOM node; its children are patched into the nearest real ancestor via `reconcileRange` (a range-based sibling reconciliation). Nested fragments flatten into that same ancestor.
|
|
1552
|
+
- **Centralized scheduling:** all async callbacks (`requestAnimationFrame`, `queueMicrotask`, `setTimeout`, `requestIdleCallback`) route through `src/Utils/scheduling.ts`. Setting `SYNC_SCHEDULING=true` makes every callback run synchronously — the default vitest project uses this for deterministic tests, while the `*-test-async.ts` project runs without it.
|
|
1553
|
+
- **Object Identity:** `@Computed` uses `ApplyDiff` to merge changes into existing references, preventing DOM subtree recreation.
|
|
1554
|
+
- **StoreAsync Constraints:** Uses Web Workers for diffing; data must be JSON-serializable (no methods or circular references).
|
|
1555
|
+
- **`gate()` as Circuit Breaker:** Prevents reactivity propagation when result is unchanged (`===`). Ineffective with `@Scope` (always new ref).
|
|
1556
|
+
- **Scope Types:** `static` (fixed, zero overhead), `basic` (used by `@Value`), `dynamic` (tracks deps, caches, emits on change), `greedy` (batched via microtask queue — used for watch callbacks, async).
|
|
1557
|
+
- **Lazy Initialization:** Scopes created on first access, not construction.
|
|
1558
|
+
- **Memory Management:** All scopes tracked via WeakMap. `Destroy()` calls `ObservableScope.DestroyAll()` + `@Destroy` properties auto-cleaned.
|
|
1559
|
+
|
|
1560
|
+
---
|
|
1561
|
+
|
|
1562
|
+
## Glossary
|
|
1563
|
+
|
|
1564
|
+
| Term | Definition |
|
|
1565
|
+
|------|------------|
|
|
1566
|
+
| **vNode** | Virtual node — the internal representation of a DOM element or text node |
|
|
1567
|
+
| **Scope** | A reactive unit that tracks dependencies, caches a value, and emits on change |
|
|
1568
|
+
| **Static scope** | A scope with a fixed value — no dependency tracking, zero overhead |
|
|
1569
|
+
| **Basic scope** | A lightweight scope used by `@Value` — direct value storage, no proxy |
|
|
1570
|
+
| **Dynamic scope** | A scope with a getter function — tracks dependencies, re-evaluates on change |
|
|
1571
|
+
| **Greedy scope** | A dynamic scope that batches updates via microtask queue (used for `@Watch`, async) |
|
|
1572
|
+
| **Children function** | The function passed as the second argument to a DOM function (e.g., `div({}, () => ...)`) |
|
|
1573
|
+
| **ApplyDiff** | Deep merge that preserves object identity — used by `@Computed` to update existing references in-place |
|
|
1574
|
+
| **ObservableNode** | A reactive proxy wrapper around objects/arrays that tracks property-level mutations |
|
|
1575
|
+
| **Injector** | Scoped dependency injection container with parent-chain resolution (not publicly exported) |
|
|
1576
|
+
| **keyFunc** | A function passed to Store that extracts an ID from objects for automatic flattening |
|
|
1577
|
+
|
|
1578
|
+
---
|
|
1579
|
+
|
|
1580
|
+
## References
|
|
1581
|
+
|
|
1582
|
+
- **Source of truth:** `src/` (this primer documents `j-templates` v7.0.94).
|
|
1583
|
+
- **Pattern guides:** `docs/patterns/01-components.md`, `docs/patterns/02-reactivity.md`, `docs/patterns/03-templates-and-data.md`, `docs/patterns/04-dependency-injection.md`.
|
|
1584
|
+
- **Tutorials:** `docs/tutorials/` (01-getting-started through 08-building-complete-app).
|
|
1585
|
+
- **Worked example:** `examples/smart-tasks/src/` (the Smart Tasks app used above).
|
|
1586
|
+
- **Capstone project:** `examples/tutorial_project/tutorial-8/src/`.
|