j-templates 8.0.19 → 8.0.21

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 CHANGED
@@ -1,122 +1,82 @@
1
- # j-templates Syntax Primer — v5
1
+ # j-templates Syntax Primer
2
2
 
3
- Complete reference for the **j-templates** framework syntax. This documents **j-templates v8.0.16** (see `package.json`). For pattern-oriented guides, see `docs/patterns/`; for step-by-step tutorials, see `docs/tutorials/`.
3
+ Oct 3, 2026 · @Jay Landrum
4
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.
5
+ Reference for building apps with j-templates 8.0.21. Every behavioral claim was checked by running the library in jsdom; claims taken from type declarations or source alone are marked as such.
6
6
 
7
- ---
7
+ ## How to use this document
8
8
 
9
- ## How to Use This Document (for LLMs)
9
+ Read Mental model, then Templates and State decorators before writing a component. Check the Traps checklist before shipping.
10
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.
11
+ - **Version:** j-templates 8.0.21. The 8.0.19 claims were tested in jsdom with TypeScript (`experimentalDecorators`). 8.0.21 changed `peek`, how `@Computed` notifies readers, and emitter re-entry; those sections were re-verified against 8.0.21 in jsdom.
12
+ - **Not verified:** a real browser `Worker` (a structured-clone shim stood in), real focus behavior, and the `docs/`, `examples/` and `src/` folders the package does not ship.
13
+ - **Convention:** `this.x` in examples is a component field decorated with `@Value` or `@State` unless shown otherwise. Imports are omitted after the first example.
16
14
 
17
- ---
15
+ ## Mental model
18
16
 
19
- ## Mental Model
17
+ There is no vNode diffing. A *children function* (the function passed as an element's second argument) records every reactive value it reads. When one changes, that function re-runs and the elements it returns are built as new DOM nodes.
20
18
 
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).
19
+ - Optimize by reducing how often a function re-runs and how much it returns, not by making re-runs cheap.
20
+ - A read inside a children function, `data:`, `props:` or `attrs:` function subscribes only that function.
21
+ - A read directly in `Template()` subscribes `Template()` itself, so the whole component rebuilds on change.
22
+ - Items rendered from a `data:` array keep their DOM node and per-item scope while the same object reference stays in the array, even when reordered. A new reference gets a new node.
23
+ - Rendering is asynchronous by default: DOM updates land after a microtask or timeout, not on the line after a state write (see Scheduling, errors and testing).
22
24
 
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
- ### ⚠️ The Destructuring Trap
34
-
35
- If you've written React, you have a strong instinct to destructure state at the top of a component function — `const { count } = this.state` — because in React the whole component re-renders anyway, so hoisting reads costs nothing.
36
-
37
- **In j-templates this instinct is actively harmful.** There is no whole-component re-render on state change — only the specific children function that reads a value re-runs. Hoisting a read to the top of `Template()` forces the *entire* component to behave like React: a full rebuild on every change, defeating the framework's fine-grained reactivity.
25
+ **The Destructuring Trap.** React habit says hoist reads to the top of the component. Here that subscribes the whole `Template()`.
38
26
 
39
27
  ```typescript
40
- // ❌ React instinct — do NOT do this in j-templates
28
+ // Wrong: Template() re-runs on every change to count
41
29
  Template() {
42
- const { count } = this.state; // hoisted read = full-component rebuild
30
+ const count = this.count;
43
31
  return div({}, () => `Count: ${count}`);
44
32
  }
45
33
 
46
- // ✅ j-templates idiom — read inside the function that uses it
34
+ // Right: only the children function re-runs
47
35
  Template() {
48
36
  return div({}, () => `Count: ${this.count}`);
49
37
  }
50
38
  ```
51
39
 
52
- **If you find yourself writing `const x = this.something;` before a `return div(...)` in `Template()` — stop. That line is the bug.** This applies equally to `@Scope`/`@Computed` getters and to `this.Data` in components. This rule is referenced throughout the doc as **the Destructuring Trap**.
53
-
54
- ---
40
+ The same applies to `this.Data` and to `@Scope`/`@Computed` getters: read them where they are used.
55
41
 
56
- ## Cheat Sheet
42
+ ## Setup and entry points
57
43
 
58
- **Imports**
59
- ```typescript
60
- import { Component, scope, gate, peek, mapped } from "j-templates";
61
- import { div, button, input, span, h1, text, fragment, _var } from "j-templates/DOM";
62
- import { Value, State, Computed, ComputedAsync, Scope, Watch, Inject, Destroy, Bound, Animation, AnimationType, IDestroyable } from "j-templates/Utils";
63
- import { StoreSync, StoreAsync, ObservableScope, ObservableNode } from "j-templates/Store";
64
- import { CreateRootPropertyAssignment, CreateEventAssignment } from "j-templates/DOM";
44
+ ```bash
45
+ npm install j-templates
65
46
  ```
66
47
 
67
- **The core loop**
68
- ```typescript
69
- const MyComp = Component.ToFunction("my-comp", MyComponent); // class → function
70
- Component.Attach(document.body, MyComp({})); // mount
71
- Component.Register("my-comp", MyComponent); // Web Component (open shadow DOM)
72
- ```
48
+ - **tsconfig:** `"experimentalDecorators": true` and `"useDefineForClassFields": false`.
49
+ - **A build step is required.** The package is ES modules with extension-less relative imports and no `exports` or `type` field. Plain Node fails with `ERR_MODULE_NOT_FOUND`. Use a bundler (Vite, webpack, esbuild) or a TypeScript runner (tsx, Vitest).
50
+ - **`window` must exist at import time** unless `SYNC_SCHEDULING=true`: the scheduler reads `window.requestAnimationFrame`, `setTimeout`, `queueMicrotask` and `requestIdleCallback` when the module loads.
73
51
 
74
- **Decorators — one line each**
75
- | Decorator | Use for | Backend | Identity |
76
- |-----------|---------|---------|----------|
77
- | `@Value()` | primitives (`number`/`string`/`boolean`/`null`/`undefined`) | basic scope | — |
78
- | `@State()` | objects & arrays (deep proxy) | ObservableNode proxy | — |
79
- | `@Scope()` | cheap getters, filter/sort of existing refs | single scope | **new ref** |
80
- | `@Computed()` | new composite objects | StoreSync | **same ref** (ApplyDiff) |
81
- | `@ComputedAsync(default)` | sync getter + StoreAsync backend | StoreAsync | **same ref** |
82
- | `@Watch(fn)` | run on property change (fires immediately on `Bound()`) | greedy scope | — |
83
- | `@Inject(Type)` | DI from component injector | — | — |
84
- | `@Destroy()` | auto `.Destroy()` on teardown (needs `IDestroyable`) | — | — |
85
-
86
- **The 3 conditional patterns**
87
- ```typescript
88
- // 1. Nested children function — isolated scope, use when an "else" branch is needed.
89
- // Note: the "else" branch must return a vNode (text(() => "")), never null/undefined.
90
- div({}, () => this.isLoading ? div({}, () => "Loading") : text(() => ""));
52
+ **Public exports (verified at runtime):**
91
53
 
92
- // 2. data: boolean — falsy renders nothing, truthy renders child (no "else")
93
- div({ data: () => this.isLoading }, () => div({}, () => "Loading"));
94
-
95
- // 3. gate() — only re-evaluates when boolean flips; shares scope with siblings
96
- gate(() => this.isLoading) ? div({}, () => "Loading") : div({}, () => "Content");
54
+ | Import path | Exports |
55
+ | --- | --- |
56
+ | `j-templates` | `Component`, `scope`, `gate`, `peek`, `mapped`; type `vNode` |
57
+ | `j-templates/DOM` | element functions (below), `text`, `fragment`, `CreateRootPropertyAssignment`, `CreateEventAssignment`, `PropertyAssignment`, `EventAssignment` |
58
+ | `j-templates/Utils` | `Value`, `State`, `Computed`, `ComputedAsync`, `Scope`, `Watch`, `Inject`, `Destroy`, `Bound`, `Animation`, `AnimationType`, `Injector`; type `IDestroyable` |
59
+ | `j-templates/Store` | `Store`, `StoreSync`, `StoreAsync`, `ObservableScope`, `ObservableNode` |
97
60
 
98
- // 4. fragment() — conditional rendering with NO wrapper DOM node
99
- fragment({ data: () => this.isLoading }, () => div({}, () => "Loading"));
100
- ```
61
+ **Deep imports used below:** `j-templates/Store/Diff/diffTree` (`DiffTree`, `PROJECTION_PREFIX`), `j-templates/Store/Diff/diffTreeWorker` (`ConnectWorkerToDiffTree`), `j-templates/Node/vNode` (`vNode.Destroy`). `j-templates/Store/Diff/defaultDiffTreeWorker` runs worker code on import and throws outside a worker (`self is not defined`).
101
62
 
102
- > **⚠️ `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()`).
63
+ **Element functions** (all verified present):
103
64
 
104
- **The inline scope functions**
105
- | Function | Registers dependency | Gates on `===` | Use when |
106
- |----------|---------------------|----------------|----------|
107
- | `scope(fn)` | Yes | No | Full reactivity |
108
- | `gate(fn)` | Yes | Yes | Prevent unnecessary downstream updates |
109
- | `peek(fn)` | No | N/A | One-time reads, display-only values |
110
- | `mapped(data, fn)` | Yes (per item) | No | Per-item scopes (advanced; used internally by `data:`) |
65
+ - Layout: `div span section article aside nav main header footer hr blockquote address`
66
+ - Headings: `h1`–`h6`
67
+ - 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`
68
+ - Lists: `ul ol li dl dt dd`
69
+ - Tables: `table thead tbody tfoot tr th td col colgroup`
70
+ - Forms: `form input textarea button select option optgroup fieldset legend datalist output progress meter`
71
+ - Media: `img figure figcaption picture source audio video track embed object param iframe`
72
+ - Interactive: `details summary dialog menu`
73
+ - Other: `canvas svg map area template slot`
111
74
 
112
- **The 3 golden rules**
113
- 1. Pass arrays as `data:` — the framework iterates. Don't call `.map()` inside children.
114
- 2. Wrap children in functions for separate reactive scopes.
115
- 3. Avoid the Destructuring Trap — read scopes at the point of use (inside children functions / `data:` bindings), never at the top of `Template()`.
75
+ `_var` is `<var>`. `svg()` creates an HTML-namespace element, so it cannot render inline SVG. No SVG element functions are exported (see Components for SVG options).
116
76
 
117
- ---
77
+ ## Components
118
78
 
119
- ## Quick Start — Minimal Component
79
+ A component is a class with a `Template()` method, turned into an element function with `Component.ToFunction`.
120
80
 
121
81
  ```typescript
122
82
  import { Component } from "j-templates";
@@ -125,1584 +85,875 @@ import { Value } from "j-templates/Utils";
125
85
 
126
86
  class Counter extends Component {
127
87
  @Value() count = 0;
128
-
129
88
  Template() {
130
89
  return div({}, () => [
131
90
  text(() => `Count: ${this.count}`),
132
91
  button({ on: { click: () => this.count++ } }, () => "Increment"),
133
92
  ]);
134
93
  }
135
-
136
- Bound() { super.Bound(); }
137
- Destroy() { super.Destroy(); }
138
94
  }
139
95
 
140
- const CounterFn = Component.ToFunction("my-counter", Counter);
141
- Component.Attach(document.body, CounterFn({}));
142
- ```
143
-
144
- ---
145
-
146
- ## Complete Worked Example — Smart Tasks
147
-
148
- > 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.
149
-
150
- **`types.ts`**
151
- ```typescript
152
- export interface Task { id: string; text: string; completed: boolean; }
153
- export type FilterType = "all" | "active" | "completed";
154
- ```
155
-
156
- **`app.ts`** — the root component
157
- ```typescript
158
- import { Component } from "j-templates";
159
- import { Value, State } from "j-templates/Utils";
160
- import { div, h1, span, text } from "j-templates/DOM";
161
- import { taskInput } from "./task-input";
162
- import { taskItem } from "./task-item";
163
- import { filterBar } from "./filter-bar";
164
- import { statsBar } from "./stats-bar";
165
- import { Task, FilterType } from "./types";
166
-
167
- let nextId = 1;
168
-
169
- class App extends Component {
170
- @State() tasks: Task[] = []; // complex state → deep proxy
171
- @Value() filter: FilterType = "all"; // primitive state → lightweight
172
-
173
- // Plain getter — reactive because it reads @State/@Value values.
174
- // No decorator needed for simple derived reads.
175
- get filteredTasks(): Task[] {
176
- switch (this.filter) {
177
- case "active": return this.tasks.filter((t) => !t.completed);
178
- case "completed": return this.tasks.filter((t) => t.completed);
179
- default: return this.tasks;
180
- }
181
- }
182
-
183
- private handleAdd(payload: { text: string }): void {
184
- this.tasks.push({ id: String(nextId++), text: payload.text, completed: false });
185
- }
186
- private handleToggle(id: string): void {
187
- const task = this.tasks.find((t) => t.id === id);
188
- if (task) task.completed = !task.completed; // @State proxy → direct mutation works
189
- }
190
- private handleDelete(id: string): void {
191
- const idx = this.tasks.findIndex((t) => t.id === id);
192
- if (idx !== -1) this.tasks.splice(idx, 1);
193
- }
194
-
195
- Template() {
196
- return div({ props: { className: "app" } }, () => [
197
- h1({}, () => "Smart Tasks"),
198
-
199
- // Parent → child data (read-only in child)
200
- statsBar({ data: () => ({ tasks: this.tasks }) }),
201
-
202
- // Child → parent event
203
- taskInput({ on: { add: (p) => this.handleAdd(p) } }),
204
-
205
- filterBar({
206
- data: () => ({ activeFilter: this.filter }),
207
- on: { filterChange: (p) => { this.filter = p.filter; } },
208
- }),
209
-
210
- // Conditional rendering — isolated scope. Only this div re-evaluates
211
- // when tasks/filteredTasks/filter change. Siblings are unaffected.
212
- div({}, () => {
213
- if (this.tasks.length === 0) return div({}, () => "No tasks yet");
214
- if (this.filteredTasks.length === 0) return div({}, () => "No tasks match this filter.");
215
- return text(() => "");
216
- }),
217
-
218
- // data: binding — framework iterates. Each item gets its own scope,
219
- // so toggling one task does not re-render the others.
220
- div({ props: { className: "task-list" }, data: () => this.filteredTasks },
221
- (task: Task) =>
222
- taskItem({
223
- data: () => task,
224
- on: {
225
- toggle: () => this.handleToggle(task.id),
226
- delete: () => this.handleDelete(task.id),
227
- },
228
- }),
229
- ),
230
- ]);
231
- }
232
- }
233
-
234
- const app = Component.ToFunction("app", App);
235
- Component.Attach(document.getElementById("app")!, app({}));
236
- ```
237
-
238
- **`task-item.ts`** — child component with events
239
- ```typescript
240
- import { Component } from "j-templates";
241
- import { div, span } from "j-templates/DOM";
242
- import { Task } from "./types";
243
-
244
- export interface TaskItemEvents { toggle: { id: string }; delete: { id: string }; }
245
-
246
- class TaskItem extends Component<Task, void, TaskItemEvents> {
247
- Template() {
248
- return div({
249
- props: () => ({ className: this.Data.completed ? "task-item completed" : "task-item" }),
250
- }, () => [
251
- div({
252
- props: () => ({ className: this.Data.completed ? "task-check checked" : "task-check" }),
253
- on: { click: () => this.Fire("toggle", { id: this.Data.id }) },
254
- }),
255
- span({ props: { className: "task-text" } }, () => this.Data.text),
256
- div({
257
- props: { className: "task-delete" },
258
- on: { click: () => this.Fire("delete", { id: this.Data.id }) },
259
- }, () => "\u00d7"),
260
- ]);
261
- }
262
- }
263
- const taskItem = Component.ToFunction("task-item", TaskItem);
264
- export { taskItem };
265
- ```
266
-
267
- **`task-input.ts`** — two-way binding via reactive props
268
- ```typescript
269
- import { Component } from "j-templates";
270
- import { Value } from "j-templates/Utils";
271
- import { div, input, button } from "j-templates/DOM";
272
-
273
- export interface TaskInputEvents { add: { text: string }; }
274
-
275
- class TaskInput extends Component<void, void, TaskInputEvents> {
276
- @Value() text: string = "";
277
-
278
- private handleAdd(): void {
279
- const trimmed = this.text.trim();
280
- if (!trimmed) return;
281
- this.Fire("add", { text: trimmed });
282
- this.text = "";
283
- }
284
-
285
- Template() {
286
- return div({ props: { className: "task-input" } }, () => [
287
- input({
288
- props: () => ({ value: this.text, placeholder: "What needs doing?", type: "text" as const }),
289
- on: {
290
- input: (e: Event) => { this.text = (e.target as HTMLInputElement).value; },
291
- keydown: (e: KeyboardEvent) => { if (e.key === "Enter") this.handleAdd(); },
292
- },
293
- }),
294
- button({ props: () => ({ disabled: this.text.trim() === "" }), on: { click: () => this.handleAdd() } }, () => "Add"),
295
- ]);
296
- }
297
- }
298
- const taskInput = Component.ToFunction("task-input", TaskInput);
299
- export { taskInput };
300
- ```
301
-
302
- **`stats-bar.ts`** — `@Scope` vs `@Computed`
303
- ```typescript
304
- import { Component } from "j-templates";
305
- import { Scope, Computed } from "j-templates/Utils";
306
- import { div, span } from "j-templates/DOM";
307
- import { Task } from "./types";
308
-
309
- interface StatsBarData { tasks: Task[]; }
310
-
311
- class StatsBar extends Component<StatsBarData> {
312
- // @Scope — cheap derived values, new reference each update.
313
- @Scope() get total(): number { return this.Data.tasks.length; }
314
- @Scope() get activeCount(): number { return this.Data.tasks.filter((t) => !t.completed).length; }
315
- @Scope() get completedCount(): number { return this.Data.tasks.filter((t) => t.completed).length; }
316
-
317
- // @Computed — composite object, SAME reference preserved via ApplyDiff.
318
- @Computed()
319
- get summary(): { active: number; completed: number; total: number; pct: string } {
320
- const t = this.total;
321
- const c = this.completedCount;
322
- return { total: t, active: this.activeCount, completed: c, pct: t === 0 ? "0%" : `${Math.round((c / t) * 100)}%` };
323
- }
324
-
325
- Template() {
326
- return div({ props: { className: "stats-bar" } }, () => [
327
- span({}, () => `${this.activeCount} active`),
328
- span({}, () => `${this.completedCount} completed`),
329
- span({}, () => `${this.summary.pct} done`),
330
- ]);
331
- }
332
- }
333
- const statsBar = Component.ToFunction("stats-bar", StatsBar);
334
- export { statsBar };
335
- ```
336
-
337
- **What to notice:**
338
- - `@State` arrays support **direct mutation** (`push`, `splice`, `task.completed = ...`) because they're proxies. Plain arrays would need reassignment.
339
- - The plain getter `filteredTasks` needs **no decorator** — it's reactive because it reads `@State`/`@Value` values.
340
- - `@Scope` for cheap per-region derived values; `@Computed` for a composite object consumed by multiple spans.
341
- - `data:` is passed **raw** to components (`this.Data`), but **iterated** for DOM elements.
342
- - Events flow child→parent via `Fire()` + `on:`; data flows parent→child via `data:`.
343
-
344
- ---
345
-
346
- ## Imports & Setup
347
-
348
- ```bash
349
- npm install j-templates
350
- ```
351
-
352
- **tsconfig.json** requires `"experimentalDecorators": true, "useDefineForClassFields": false`.
353
-
354
- **File naming:** Components: kebab-case (`todo-list.ts`). Services: kebab-case + `-service` suffix. Exports: lowercase matching filename.
355
-
356
- **Public entry points:**
357
-
358
- | Import path | Exports |
359
- |-------------|---------|
360
- | `j-templates` | `Component`, `scope`, `gate`, `peek`, `mapped` |
361
- | `j-templates/DOM` | all DOM functions (`div`, `span`, `text`, `_var`, …), `CreateRootPropertyAssignment`, `CreateEventAssignment` |
362
- | `j-templates/Utils` | `Value`, `State`, `Computed`, `ComputedAsync`, `Scope`, `Watch`, `Inject`, `Destroy`, `Bound`, `Animation`, `AnimationType`, `IDestroyable` |
363
- | `j-templates/Store` | `StoreSync`, `StoreAsync`, `ObservableScope`, `ObservableNode` |
364
-
365
- > ⚠️ **`Injector` is NOT exported from any public entry point.** It exists internally but is never re-exported. Use `@Inject` and `this.Injector` on components instead of importing `Injector` directly.
366
-
367
- ### DOM Functions
368
-
369
- Layout: `div`, `span`, `section`, `article`, `aside`, `nav`, `main`, `header`, `footer`, `hr`, `blockquote`, `address`
370
-
371
- Headings: `h1`–`h6`
372
-
373
- 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`
374
-
375
- Lists: `ul`, `ol`, `li`, `dl`, `dt`, `dd`
376
-
377
- Tables: `table`, `thead`, `tbody`, `tfoot`, `tr`, `th`, `td`, `col`, `colgroup`
378
-
379
- Forms: `form`, `input`, `textarea`, `button`, `select`, `option`, `optgroup`, `fieldset`, `legend`, `datalist`, `output`, `progress`, `meter`
380
-
381
- Media: `img`, `figure`, `figcaption`, `picture`, `source`, `audio`, `video`, `track`, `embed`, `object`, `param`, `iframe`
382
-
383
- Interactive: `details`, `summary`, `dialog`, `menu`
384
-
385
- Scripting: `canvas`, `svg`, `map`, `area`
386
-
387
- Meta: `template`, `slot`
388
-
389
- Text node: `text`
390
-
391
- Fragment: `fragment` (no DOM node — children reconcile into the real ancestor)
392
-
393
- No SVG element functions are exported. 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.
394
-
395
- ---
396
-
397
- ## Component
398
-
399
- ### Class, Generics & Lifecycle
400
-
401
- ```typescript
402
- class MyComponent extends Component<D, T, E> {
403
- // D = data type from parent (default: void)
404
- // T = template functions from parent (default: void)
405
- // E = event map type (default: {})
406
-
407
- Template(): vNodeType | vNodeType[] { return div({}, () => "Hello"); }
408
- Bound() { super.Bound(); } // Required: initializes @Watch decorators
409
- Destroy() { super.Destroy(); } // Required: cleans up scopes and @Destroy properties
410
- }
411
- ```
412
-
413
- **Never override the constructor.** Use field initializers and `Bound()` for setup.
414
-
415
- ### Properties & Methods
416
-
417
- | Property | Access | Description |
418
- |----------|--------|-------------|
419
- | `Data` | `protected get` | Data from parent via `data: () => ({...})` |
420
- | `Templates` | `protected get` | Parent-provided template functions |
421
- | `Injector` | `public get` | Component's scoped DI injector |
422
- | `VNode` | `protected get` | Custom element host (not template root) |
423
- | `Scope` | `protected get` | Internal scoped observable |
424
- | `Destroyed` | `public get` | Whether component is destroyed |
425
-
426
- | Method | Description |
427
- |--------|-------------|
428
- | `Template()` | Override to define UI. Returns empty array by default. |
429
- | `Bound()` | Lifecycle hook after DOM attachment. Calls `Bound.All(this)`. |
430
- | `Destroy()` | Destroys scope + calls `Destroy.All(this)`. |
431
- | `Fire<P extends keyof E>(event: P, data?: E[P])` | Fire component event. `data` is optional. |
432
-
433
- ### ToFunction, Attach, Register
434
-
435
- ```typescript
436
- // Convert class to reusable function (required for template use)
437
- export const myComponent = Component.ToFunction("my-component", MyComponent);
438
-
439
- // With namespace (SVG)
440
- export const svgCircle = Component.ToFunction("circle", SvgCircle, "http://www.w3.org/2000/svg");
441
-
442
- // Attach to DOM
443
- Component.Attach(document.body, myComponent({}));
444
-
445
- // Register as Web Component (creates open shadow DOM)
446
- Component.Register("my-component", MyComponent);
447
- ```
448
-
449
- **ToFunction config type:**
450
- ```typescript
451
- type vComponentConfig<D, E, P = HTMLElement> = {
452
- data?: () => D | undefined;
453
- props?: FunctionOr<RecursivePartial<P>> | undefined;
454
- on?: ComponentEvents<E> | undefined;
455
- };
456
- type ComponentEvents<E> = { [P in keyof E]?: { (data: E[P]): void } };
457
- type FunctionOr<T> = { (): T | Promise<T> } | T;
458
- ```
459
-
460
- ### Custom Element Host
461
-
462
- `this.VNode.node` is the custom element host, not the template root. Query template children via `this.VNode.node.querySelector('.className')`.
463
-
464
- > **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`.
465
-
466
- ```html
467
- <my-component> <!-- Host (styled via element selector) -->
468
- <div class="container"> <!-- Template root (styled via class selector) -->
469
- ```
470
-
471
- ### Lifecycle
472
-
473
- ```
474
- 1. Component.Attach() called ─ DOM: Not attached
475
- 2. vNode.Init() called ─ DOM: Not attached
476
- 3. Component constructor runs ─ DOM: Not attached
477
- 4. Bound() called ─ DOM: Attached ✓ | Children: May not be ready ⚠
478
- 5. Template rendered, attached ─ DOM: Fully rendered ✓
479
- 6. ... reactivity updates ...
480
- 7. Component.Destroy() called ─ DOM: About to be removed
481
- 8. Destroy() called ─ Cleanup: Scopes, @Destroy properties
482
- ```
483
-
484
- - **Bound()** — DOM attached, `@Watch` initialized (fires immediately with initial value). Query children with `requestAnimationFrame()` if needed.
485
- - **Destroy()** — Always call `super.Destroy()` and `super.Bound()` when overriding.
486
-
487
- ### Async Initialization Pattern
488
-
489
- ```typescript
490
- @State() data: Data[] = [];
491
- @Value() isLoading = false;
492
-
493
- Bound() {
494
- super.Bound();
495
- this.LoadData();
496
- }
497
-
498
- async LoadData() {
499
- this.isLoading = true;
500
- try {
501
- const result = await fetchData();
502
- await this.store.Write(result, "data");
503
- } finally {
504
- this.isLoading = false;
505
- }
506
- }
507
- ```
508
-
509
- ---
510
-
511
- ## Template System
512
-
513
- ### DOM Function Signature
514
-
515
- ```typescript
516
- function element<P, E, T>(
517
- config?: {
518
- props?: FunctionOr<RecursivePartial<P>>; // DOM properties (static or reactive)
519
- attrs?: FunctionOr<{ [name: string]: string }>; // HTML attributes
520
- on?: FunctionOr<vNodeEvents<E>>; // Event handlers
521
- data?: () => T | Array<T> | Promise<Array<T>> | Promise<T>; // Reactive data
522
- },
523
- children?: vNodeType[] | ((data: T) => vNodeType[] | vNodeType | string)
524
- ): vNodeType
525
- ```
526
-
527
- **Functions are reactive:** When referenced scope values change, the vNode re-renders.
528
-
529
- ### Template Patterns
530
-
531
- ```typescript
532
- Template() {
533
- return div({ props: { className: "container" } }, () => [
534
- h1({}, () => "Title"),
535
-
536
- // Reactive data binding - framework iterates array
537
- div({ data: () => this.Data.items }, (item) =>
538
- div({}, () => item.name)
539
- ),
540
-
541
- // Reactive props
542
- div({ props: () => ({ className: this.isActive ? "active" : "" }) }, () => "Content"),
543
-
544
- // Event handlers
545
- button({ on: { click: (e: MouseEvent) => this.handleClick(e) } }, () => "Click"),
546
-
547
- // Conditional rendering — three patterns depending on isolation needs:
548
-
549
- // 1. Nested children function — isolated scope, use when an "else" branch is needed
550
- div({}, () =>
551
- this.isLoading ? div({}, () => "Loading") : text(() => "")
552
- ),
553
-
554
- // 2. data: boolean — falsy renders nothing, truthy renders child, no "else" needed
555
- div({ data: () => this.isLoading }, () => div({}, () => "Loading")),
556
-
557
- // 3. gate() — only re-evaluates when boolean flips; use when the condition
558
- // shares a children function with other reactive siblings
559
- gate(() => this.isLoading) ? div({}, () => "Loading") : div({}, () => "Content"),
560
-
561
- // Child component
562
- childComponent({ data: () => ({ id: 1 }) }),
563
-
564
- // Reactive text node
565
- text(() => `Count: ${this.count}`),
566
-
567
- // Bare string as the sole children-function return value — valid per
568
- // vNodeChildrenFunction<T>, and equivalent to using text() as the only child.
569
- div({}, () => `Count: ${this.count}`),
570
-
571
- // Raw HTML (use innerHTML prop, NOT { __html: })
572
- div({ props: { innerHTML: "<strong>Bold</strong>" } }),
573
-
574
- // Mixed text + elements (use text(), not plain strings in arrays)
575
- text(() => "Click "), button({}, () => "here"), text(() => " to continue")
576
- ]);
577
- }
578
- ```
579
-
580
- ### data: Binding Behavior
581
-
582
- > **🔑 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`.
583
-
584
- #### DOM Elements
585
-
586
- | Return Value | Behavior |
587
- |--------------|----------|
588
- | `[]` | No children rendered |
589
- | `[a, b, c]` | Iterates, renders child for each item |
590
- | `{ id: 1 }` | Wraps as `[{ id: 1 }]`, renders once |
591
- | `"text"` / `123` | Wraps as `[value]`, renders once |
592
- | `true` | Wraps as `[true]`, renders child once |
593
- | `false` / `null` / `undefined` | Returns `[]`, no children rendered |
594
-
595
- > ⚠️ **Falsy edge case:** the falsy-collapse rule applies broadly — **`0`, `""`, and `NaN` also collapse to `[]`**, not just `false`/`null`/`undefined`. Only *truthy* non-array values are wrapped as `[value]`.
596
-
597
- **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. While a newer promise is pending, the last resolved value is kept.
598
-
599
- 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.
600
-
601
- **Reordering:** when the array itself changes order (e.g. `.sort()`, `.reverse()`) without changing which object references it contains, each item's existing DOM node and per-item scope move to the new position rather than being destroyed and recreated. Only genuinely new or removed references trigger create/destroy.
602
-
603
- #### Components
604
-
605
- Components receive the raw return value as `this.Data` — no iteration, no wrapping, no `false`/`null` short-circuit.
606
-
607
- | Return Value | `this.Data` in component |
608
- |--------------|--------------------------|
609
- | `[a, b, c]` | `[a, b, c]` — component must iterate itself |
610
- | `{ id: 1 }` | `{ id: 1 }` — passed as-is |
611
- | `"text"` | `"text"` — passed as-is |
612
- | `false` / `null` / `undefined` | The actual value — component decides how to handle |
613
- | `Promise<T>` | Resolved by the scope — `this.Data` is the resolved value, or `null` while pending |
614
-
615
- ```typescript
616
- // DOM element: framework iterates and renders a child per item
617
- div({ data: () => this.tasks }, (task) => div({}, () => task.name));
618
-
619
- // Component: data passes through as this.Data — no framework iteration
620
- taskList({ data: () => ({ tasks: this.tasks }) });
621
- // Inside TaskList: this.Data.tasks — component manages its own iteration
622
- ```
623
-
624
- **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.
625
-
626
- ### Fragment Elements
627
-
628
- `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.
629
-
630
- ```typescript
631
- import { fragment } from "j-templates/DOM";
632
-
633
- // Conditional rendering with no wrapper node — the ternary is its own scope
634
- fragment({ data: () => this.show }, (show) =>
635
- show === "admin" ? div({}, () => "ADMIN") : div({}, () => "LOGIN"),
636
- );
637
-
638
- // Iteration with no wrapper node
639
- fragment({ data: () => this.items }, (item) => div({}, () => item.name));
640
-
641
- // Nested fragments flatten into the real ancestor
642
- fragment({}, () => [
643
- div({}, () => "OUTER"),
644
- fragment({}, () => (this.showExtra ? div({}, () => "EXTRA") : div({}, () => "BASE"))),
645
- ]);
646
- ```
647
-
648
- Key behaviors:
649
- - **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).
650
- - **`data:` behaves like any DOM element** — iterates arrays, wraps truthy scalars, collapses falsy values to nothing.
651
- - **Nesting is fine** — fragments inside fragments flatten into the real ancestor.
652
- - **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.
653
-
654
- ### Key Template Rules
655
-
656
- 1. **Pass arrays as `data:`** — framework iterates automatically. Don't call `.map()` inside children.
657
- 2. **Wrap children in functions** for separate reactive scopes. Without function wrapper, all children share parent scope.
658
- 3. **Two-way binding requires reactive props**: `props: () => ({ value: this.text })` (not static `props: { value: this.text }` which causes focus loss).
659
- 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.
660
-
661
- ```typescript
662
- // Anti-pattern: condition and list share a children function scope.
663
- div({}, () => [
664
- this.isEmpty ? div({}, () => "No items") : text(() => ""),
665
- div({ data: () => this.visibleTodos }, (item) => ...)
666
- ])
667
-
668
- // Correct: each has its own isolated scope.
669
- div({}, () => [
670
- div({}, () => this.isEmpty ? div({}, () => "No items") : text(() => "")),
671
- div({ data: () => this.visibleTodos }, (item) => ...)
672
- ])
673
-
674
- // Also correct: data: boolean — no else branch needed, isolated scope.
675
- div({}, () => [
676
- div({ data: () => this.isEmpty }, () => div({}, () => "No items")),
677
- div({ data: () => this.visibleTodos }, (item) => ...)
678
- ])
679
-
680
- // Also correct: gate() — condition shares scope with siblings but only
681
- // re-evaluates when the boolean flips, not on every upstream emission.
682
- div({}, () => [
683
- gate(() => this.visibleTodos.length === 0)
684
- ? div({}, () => "No items")
685
- : text(() => ""),
686
- div({ data: () => this.visibleTodos }, (item) => ...)
687
- ])
688
- ```
689
-
690
- 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.
691
- 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.
692
-
693
- ```typescript
694
- // Anti-pattern — helper function creates new vNodes each Template() call
695
- private renderItem = (item: Item) =>
696
- div({ data: () => item }, (data) => span({}, () => data.name));
697
- Template() {
698
- return div({}, () => [
699
- this.renderItem(a), // New vNode, destroyed and recreated each render
700
- this.renderItem(b),
701
- ]);
702
- }
703
-
704
- // Correct — inline with @Scope data source
705
- @Scope() get groupA() { return items.filter(...); }
706
- Template() {
707
- return div({}, () => [
708
- div({ data: () => this.groupA }, (item) => renderItem(item)),
709
- div({ data: () => this.groupB }, (item) => renderItem(item)),
710
- ]);
711
- }
712
- ```
713
-
714
- 7. **Avoid the Destructuring Trap — read `@Scope` getters at the point of use.** See Mental Model. 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.
715
- 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.
716
-
717
- ```typescript
718
- // Anti-pattern — this.Data read at top of Template()
719
- Template() {
720
- const task = this.Data; // Subscribes entire Template
721
- return div({}, () => [
722
- span({}, () => task.name), // Any parent data change rebuilds ALL
723
- span({}, () => task.status),
724
- ]);
725
- }
726
-
727
- // Correct — this.Data read inside children function
728
- Template() {
729
- return div({}, () => [
730
- span({}, () => this.Data.name), // Only this subtree re-renders
731
- span({}, () => this.Data.status), // Only this subtree re-renders
732
- ]);
733
- }
734
-
735
- // Also correct — in props: reactive function
736
- Template() {
737
- return div({ props: () => ({ className: this.Data.active ? "active" : "" }) }, () =>
738
- "Content"
739
- );
740
- }
741
- ```
742
-
743
- ```typescript
744
- // Anti-pattern — scope read at top of Template
745
- Template() {
746
- const derived = this.computedValue; // Subscribes entire Template
747
- return div({}, () => [
748
- div({}, () => `${derived}`), // Any change re-runs ALL
749
- div({}, () => "other static content"),
750
- ]);
751
- }
752
-
753
- // Correct — scope read inside children function
754
- Template() {
755
- return div({}, () => [
756
- div({}, () => { // Subscription scoped to this subtree
757
- const derived = this.computedValue;
758
- return `${derived}`;
759
- }),
760
- div({}, () => "other static content"), // Unaffected by derived changes
761
- ]);
762
- }
763
- ```
764
-
765
- ### Granular Reactive Scopes
766
-
767
- ```typescript
768
- // No function wrappers
769
- div({}, [span({ data: this.value1 }, (v) => v), span({ data: this.value2 }, (v) => v)]);
770
- // Changing value1 re-renders all elements
771
-
772
- // With children function wrapper
773
- div({}, () => [span({ data: this.value1 }, (v) => v), span({ data: this.value2 }, (v) => v)]);
774
- // Changing value1 re-renders both spans, div is unaffected
775
-
776
- // With data function wrappers
777
- div({}, () => [span({ data: () => this.value1 }, (v) => v), span({ data: () => this.value2 }, (v) => v)]);
778
- // Changing value1 only updates the first span, other elements are unaffected
779
- ```
780
-
781
- ---
782
-
783
- ## How Updates Propagate
784
-
785
- > **🔑 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.
786
-
787
- When a reactive scope emits, only the children functions that read it re-run, producing new vNodes for that subtree — everything else is untouched. DOM nodes are reused when the new vNode is the same object reference as the old one (this is why `@Computed`'s identity preservation matters — see below); a new reference always creates a new DOM node. Text content is updated in place rather than replaced.
788
-
789
- This means:
790
- - A scope read at the top of `Template()` rebuilds the entire component vNode tree.
791
- - A scope read inside a children function rebuilds only that subtree.
792
- - A scope read inside a `data:` binding rebuilds only that iteration's vNode.
793
- - Per-item scopes are reused when the same data object reference reappears (identity-based, not key-based); reordering an array of existing references moves nodes rather than recreating them.
794
-
795
- The optimization goal is minimizing **how often** children functions re-run through fine-grained scopes, not making the re-run itself cheap.
796
-
797
- ---
798
-
799
- ## State Decorators
800
-
801
- All decorators are for **Component classes only**. Services must use `ObservableScope`/`Store` APIs directly.
802
-
803
- ### @Value — Primitive State
804
-
805
- ```typescript
806
- @Value() count: number = 0;
807
- @Value() isLoading: boolean = false;
808
- ```
809
-
810
- Lightweight, no proxy. For `number`, `string`, `boolean`, `null`, `undefined`. Backed by a `basic` scope (`ObservableScope.Basic`).
811
-
812
- **When NOT to use:** for objects or arrays — use `@State()` instead (deep reactivity).
813
-
814
- ### @State — Complex State
815
-
816
- ```typescript
817
- @State() user: { name: string } = { name: "" };
818
- @State() items: Item[] = [];
819
- ```
820
-
821
- 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.
822
-
823
- **When NOT to use:** for primitives — `@State()` creates a proxy, leaf scopes, and caches for no benefit; use `@Value()`.
824
-
825
- ### @Scope — Cached Getter (New Reference)
826
-
827
- ```typescript
828
- @Scope()
829
- get fullName() { return `${this.firstName} ${this.lastName}`; }
96
+ export const counter = Component.ToFunction("my-counter", Counter);
97
+ Component.Attach(document.getElementById("app")!, counter({}));
830
98
  ```
831
99
 
832
- 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`).
100
+ **Generics:** `Component<D, T, E>`. `D` = data from the parent (`this.Data`), `T` = template callbacks from the parent (`this.Templates`), `E` = event map for `this.Fire`. All default to empty.
833
101
 
834
- **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.
102
+ **Members:**
835
103
 
836
- > **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.
104
+ - `Template(): vNode | vNode[]` — override to render. May return an array. Not overriding renders an empty host.
105
+ - `Bound()` / `Destroy()` — optional overrides. If you override either, call `super.Bound()` / `super.Destroy()`. `super.Bound()` starts `@Watch`; `super.Destroy()` tears down scopes and runs `@Destroy` properties.
106
+ - `Fire(event, payload?)` — sends a component event to the parent's `on:` handler. The payload is optional.
107
+ - `this.Data`, `this.Templates`, `this.Injector`, `this.VNode` (host vNode; `this.VNode.node` is the host element), `this.Destroyed`.
108
+ - Never override the constructor. Use field initializers and `Bound()`.
837
109
 
838
- **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:
110
+ **Lifecycle (verified order):**
839
111
 
840
- ```typescript
841
- // Anti-pattern — single scope for multiple regions
842
- @Scope()
843
- get grouped() {
844
- return {
845
- categoryA: items.filter(i => i.category === "A"),
846
- categoryB: items.filter(i => i.category === "B"),
847
- categoryC: items.filter(i => i.category === "C"),
848
- };
849
- }
850
- // Changing one group's data creates a new { categoryA, categoryB, categoryC } object,
851
- // causing all three groups to re-render.
852
-
853
- // Correct — one scope per independent region
854
- @Scope() get groupA() { return items.filter(i => i.category === "A"); }
855
- @Scope() get groupB() { return items.filter(i => i.category === "B"); }
856
- @Scope() get groupC() { return items.filter(i => i.category === "C"); }
857
- // Each region reads its own scope. Only affected regions re-render.
858
- ```
112
+ 1. Field initializers run.
113
+ 2. `Bound()` runs. The host element exists but is **not in the document and has no children**. `@Watch` handlers fire here with their initial values.
114
+ 3. `Template()` runs.
115
+ 4. Children functions run and build the DOM. By default this happens **after** `Attach` returns; with `SYNC_SCHEDULING=true`, before.
116
+ 5. The host is in the document.
859
117
 
860
- **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 `===`).
118
+ To touch rendered DOM from `Bound()`, defer with `requestAnimationFrame` or `setTimeout` (verified: host connected and children present by then). Attach native listeners to an element you render, not to the host.
861
119
 
862
- ### @Computed — Cached Getter (Same Reference)
120
+ **Host element.** The name passed to `ToFunction` is the host's tag. `Template()` output renders inside it:
863
121
 
864
- ```typescript
865
- @Computed()
866
- get summary() {
867
- return { total: this.items.reduce((s, i) => s + i.value, 0), count: this.items.length };
868
- }
122
+ ```html
123
+ <my-counter> <!-- host: style by element selector -->
124
+ <div>…</div> <!-- template root -->
125
+ </my-counter>
869
126
  ```
870
127
 
871
- 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`.
128
+ **Component element config** (what `counter({...})` accepts): `data?: () => D`, `props?: object | () => object` (from type declarations; untested), `on?: { [event]: (payload) => void }`. A second argument passes template callbacks (`T`).
872
129
 
873
- `@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.
130
+ **Mounting and teardown:**
874
131
 
875
- > **`@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. 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.
132
+ - `Component.Attach(element, vnode)` mounts and returns the root vNode.
133
+ - A child component dropped by its parent's re-render is destroyed: its `Destroy()` runs and its host is removed.
134
+ - No public unmount exists for a root. `vNode.Destroy(root)` from `j-templates/Node/vNode` runs `Destroy()` hooks but **leaves the host in the DOM**; remove it yourself.
876
135
 
877
- > **Recompute timing.** `@Computed`'s `StoreSync` backend is created lazily on first read; after that it recomputes eagerly on emit (pulling from the source). This contrasts with `@Scope`, which re-evaluates lazily on the next read.
136
+ **Web Components.** `Component.Register("my-tag", MyComponent)` defines a custom element with an open shadow root. The template renders inside the shadow root wrapped in a `<my-tag-component>` element.
878
137
 
879
- **Dependency tracking note:** `@Computed` 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).
138
+ **SVG.** `Component.ToFunction("circle", Circle, "http://www.w3.org/2000/svg")` creates the **host** in the SVG namespace, but elements built inside `Template()` with DOM functions are still HTML-namespace. For SVG content, either make each SVG element its own namespaced component, or set markup with `props: { innerHTML: "<svg>…</svg>" }` (parsed into the SVG namespace correctly).
880
139
 
881
- **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).
140
+ ## Templates
882
141
 
883
- ### @ComputedAsync — Sync Getter with StoreAsync Backend
142
+ ### Element function signature
884
143
 
885
144
  ```typescript
886
- @ComputedAsync(null)
887
- get userData(): User | null {
888
- return getUserSync(this.Data.userId); // Must be synchronous — "Async" refers to the StoreAsync backend
889
- }
145
+ el(
146
+ config: { // required; pass {} for none
147
+ props?: object | (() => object); // DOM properties
148
+ attrs?: { [name: string]: string } | (() => {…}); // HTML attributes
149
+ on?: { [event: string]: (e) => void }; // DOM event handlers
150
+ data?: () => T | T[]; // MUST be a function; may be async
151
+ },
152
+ children?: vNode[] | ((item: T) => vNode | vNode[] | string | null)
153
+ )
154
+ text(() => string) // reactive text node
155
+ fragment({ data?: () => … }, (item) => …) // no DOM node; children function required
890
156
  ```
891
157
 
892
- 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)`.
158
+ - A **function** for `props`, `attrs` or `children` creates its own reactive scope. A plain object or array is evaluated once, inside whatever scope is building it.
159
+ - `data:` given a non-function value throws `data is not a function` and halts rendering.
160
+ - A static children array (`div({}, [span(…), span(…)])`) works, and each child's own functions stay reactive.
161
+ - Raw HTML: `props: { innerHTML: "<b>x</b>" }`.
893
162
 
894
- ### @Watch — Property Change Handler
163
+ ### What a children function may return
895
164
 
896
- ```typescript
897
- @Watch((self) => self.count)
898
- handleCountChanged(newValue: number) { console.log("Count:", newValue); }
165
+ | Return value | Result |
166
+ | --- | --- |
167
+ | a vNode, or an array of vNodes | rendered |
168
+ | a string (as the only return value) | text node |
169
+ | `null` or `undefined` (as the only return value) | nothing rendered, no error |
170
+ | an array containing a plain string or `null` | **throws during DOM update and halts all rendering** |
899
171
 
900
- @Watch((self) => self.Data.value)
901
- onDataChange(newValue: DataType) { /* ... */ }
172
+ In arrays, wrap strings in `text(() => "…")` and remove empty entries (`.filter(Boolean)` works).
902
173
 
903
- // Syncing child @Value state with parent Data
904
- @Watch((self) => self.Data.filter)
905
- syncFromParent(newFilter: FilterType): void {
906
- this.localFilter = newFilter;
907
- }
908
- ```
174
+ ### `data:` on DOM elements
909
175
 
910
- Fires immediately with initial value when `Bound()` runs, then on each change. Subscription auto-cleaned on `Destroy()`. Uses a greedy (batched) scope (`ObservableScope.Gated`) — multiple synchronous changes to the watched value within the same tick are debounced into a single callback invocation, firing once with the latest value.
176
+ The framework iterates and calls the children function once per item, passing the item.
911
177
 
912
- **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).
178
+ | `data` returns | Children rendered |
179
+ | --- | --- |
180
+ | `[a, b, c]` | one per item |
181
+ | `[]` | none |
182
+ | a truthy non-array (`{}`, `"x"`, `123`, `true`) | one, called with that value |
183
+ | any falsy value (`false`, `null`, `undefined`, `0`, `""`, `NaN`) | none |
184
+ | an `async` function's promise | none until it resolves, then as above |
913
185
 
914
- ### @Inject — Dependency Injection
186
+ `data:` may return `null` or `undefined` (typed since 8.0.19). The children function's parameter is still typed as the non-null item, matching the runtime, which never calls it for a falsy value.
915
187
 
916
- ```typescript
917
- @Inject(DataService) dataService!: DataService;
918
- ```
188
+ - A falsy `data:` removes the **children only**; the element stays in the DOM (a styled box stays visible). `fragment` leaves nothing.
189
+ - Each item gets its own scope, keyed by object identity. Reordering existing references moves their DOM nodes; a new reference (including a copy) gets a new node.
919
190
 
920
- Creates getter/setter using component's injector. Getter: `this.Injector.Get(type)`. Setter: `this.Injector.Set(type, value)`.
191
+ ### `data:` on components
921
192
 
922
- ### @Destroy — Auto-Cleanup
193
+ A component receives the raw value as `this.Data`: no iteration, wrapping or falsy collapse. `false`, `null`, `0` and arrays arrive unchanged. The component iterates arrays itself.
923
194
 
924
195
  ```typescript
925
- @Destroy() timer: Timer = new Timer(); // Timer must implement IDestroyable
926
- @Destroy() @Inject(DataService) dataService = new DataService();
196
+ div({ data: () => this.tasks }, (task) => div({}, () => task.name)); // iterated
197
+ taskList({ data: () => ({ tasks: this.tasks }) }); // passed through
927
198
  ```
928
199
 
929
- Calls `.Destroy()` on marked properties during component teardown. Requires `IDestroyable` interface.
200
+ ### Conditional rendering
930
201
 
931
- ### Decorator Selection
202
+ ```typescript
203
+ // 1. Ternary in a nested children function (isolated scope, has an else)
204
+ div({}, () => (this.loading ? div({}, () => "Loading") : div({}, () => "Ready")));
932
205
 
933
- | Value Type | Decorator | Why |
934
- |------------|-----------|-----|
935
- | `number`, `string`, `boolean` | `@Value` | Lightweight, no proxy |
936
- | `null`, `undefined` | `@Value` | Simple scope |
937
- | `{ nested: objects }` | `@State` | Deep reactivity via proxy |
938
- | `arrays (Item[])` | `@State` | Array mutations tracked |
939
- | Cheap getter / primitives | `@Scope` | Cached, new ref, minimal overhead |
940
- | Array map/filter/sort (existing refs) | `@Scope` | Object identity preserved |
941
- | Creating new objects | `@Computed()` | Cached + same ref via ApplyDiff |
942
- | Sync getter + StoreAsync backend | `@ComputedAsync(default)` | Same ref, StoreAsync diffing |
943
- | Watch changes | `@Watch` | Callback on change (greedy/batched) |
944
- | DI from injector | `@Inject` | Lazy resolution from injector |
945
- | Cleanup on destroy | `@Destroy` | Auto `.Destroy()` (requires `IDestroyable`) |
946
- | Simple property access | None (plain getter) | Reading `this.Data` is reactive |
206
+ // 2. data: boolean (no else; the outer div stays in the DOM, empty)
207
+ div({ data: () => this.loading }, () => div({}, () => "Loading"));
947
208
 
948
- ### @Computed vs @Scope vs @ComputedAsync
209
+ // 3. fragment (no wrapper node; nothing left behind when falsy)
210
+ fragment({ data: () => this.loading }, () => div({}, () => "Loading"));
949
211
 
950
- | Aspect | `@Computed()` | `@Scope()` | `@ComputedAsync(default)` |
951
- |--------|------------|------------|--------------------------|
952
- | Object identity | Same ref (ApplyDiff) | New ref | Same ref (ApplyDiff) |
953
- | Backend | StoreSync | Single scope | StoreAsync |
954
- | Default param | No | N/A | Required |
955
- | Best for | New objects, expensive ops | Primitives, cheap ops, existing-ref arrays | Sync getter + StoreAsync diffing |
956
- | Composite object for multiple consumers | Yes — same ref, sub-property mutations tracked | No — new ref invalidates all consumers | Yes |
212
+ // 4. gate(): the enclosing function re-runs only when the boolean flips
213
+ div({}, () =>
214
+ gate(() => this.todos.length === 0)
215
+ ? div({}, () => "No items")
216
+ : div({ data: () => this.todos }, (t) => div({}, () => t.name)),
217
+ );
218
+ ```
957
219
 
958
- **Key:** `@Computed` preserves object identity across updates. Critical when DOM reuse depends on reference stability (e.g., iterating arrays with `data:`).
220
+ In pattern 4, adding an item to a non-empty list does not re-run the outer function (verified); the list still updates through its own `data:` scope.
959
221
 
960
- ### Async Patterns
222
+ Avoid reading a condition and a list in the same children function. Isolate the condition with any pattern above:
961
223
 
962
224
  ```typescript
963
- // 1. Direct async in services (new reference on each update)
964
- private dataScope = ObservableScope.Create(async () => fetch('/api/data'));
965
- get data(): Data | null { return ObservableScope.Value(this.dataScope); }
966
-
967
- // 2. Component async with scope() (new reference on each update)
968
- @Scope()
969
- get CurrentUser() { return scope(async () => fetchUser(`/api/user/${this.userId}`)); }
225
+ // Wrong: changing todos re-runs both the message and the list
226
+ div({}, () => [
227
+ this.isEmpty ? div({}, () => "No items") : text(() => ""),
228
+ div({ data: () => this.todos }, (t) => …),
229
+ ]);
970
230
 
971
- // 3. @ComputedAsync — sync getter only, StoreAsync backend (same reference via ApplyDiff)
972
- @ComputedAsync(null)
973
- get userData(): User | null { return getUserSync(this.Data.userId); }
231
+ // Right
232
+ div({}, () => [
233
+ div({ data: () => this.isEmpty }, () => div({}, () => "No items")),
234
+ div({ data: () => this.todos }, (t) => …),
235
+ ]);
974
236
  ```
975
237
 
976
- **Async patterns 1 & 2:** Async functions auto-detected. Automatically sets `greedy: true` (batched updates). Initial value: `null` or Promise. New reference on each update.
238
+ ### fragment
977
239
 
978
- **Async limitation:** Dependencies are only captured synchronously. Read all reactive values before the first `await`. Reactive reads after `await` are not tracked.
240
+ - Produces no DOM node; its children are placed in the nearest real ancestor. Nested fragments flatten.
241
+ - `data:` behaves as on a DOM element.
242
+ - `Component.Attach(el, fragment(…))` throws: wrap it in a real element.
979
243
 
980
- **Pattern 3 (@ComputedAsync):** Getter must be synchronous. Returns default value initially, then computed value with same reference via ApplyDiff.
244
+ ### Form inputs (two-way binding)
981
245
 
982
- Shared async-scope behavior — typing, pending/old-value semantics, batching, and `scope()` composition — is covered in [Async Scopes](#async-scopes).
246
+ Use a `props` **function**. A static `props: { value: this.text }` inside a children function subscribes that function, so every keystroke rebuilds the `<input>` (new node, focus lost).
247
+
248
+ ```typescript
249
+ input({
250
+ props: () => ({ value: this.text }),
251
+ on: { input: (e) => { this.text = (e.target as HTMLInputElement).value; } },
252
+ });
253
+ ```
983
254
 
984
- ### State Location
255
+ ### Scope granularity
985
256
 
986
- | State Type | Location | API |
987
- |------------|----------|-----|
988
- | Raw data store | Service | `StoreAsync`/`StoreSync` |
989
- | Derived (shared) | Service | `ObservableScope.Create()` |
990
- | Derived (local, cheap) | Component | `@Scope()` |
991
- | Derived (local, new objects) | Component | `@Computed()` |
992
- | Primitives (local) | Component | `@Value()` |
993
- | Complex (local) | Component | `@State()` |
994
- | Async (component) | Component | `@Scope() + scope(async)` |
995
- | Async (service) | Service | `ObservableScope.Create(async)` |
996
- | External resources | Service | `IDestroyable` |
257
+ ```typescript
258
+ // Coarse: the outer function reads both values; changing value1 rebuilds both spans
259
+ div({}, () => {
260
+ const a = this.value1, b = this.value2;
261
+ return [span({}, () => a), span({}, () => b)];
262
+ });
997
263
 
998
- ---
264
+ // Fine: each span reads its own value; changing value1 updates only the first span
265
+ div({}, () => [span({}, () => this.value1), span({}, () => this.value2)]);
266
+ ```
999
267
 
1000
- ## Async Scopes
268
+ ## State decorators
1001
269
 
1002
- Any function that supplies a scope value can be `async` — a `@Scope()` getter, a `props` or `data` function, or a `scope()`/`gate()`/`peek()`/`mapped()` callback. Services use the same mechanism through `ObservableScope.Create(async)` (see [Async Patterns](#async-patterns)).
270
+ Decorators work on component classes only. Services use `ObservableScope`, `ObservableNode` and the stores directly.
1003
271
 
1004
- **Typing unwraps the resolved value.** The helper signatures declare `() => T | Promise<T>`, so TypeScript infers the resolved value, not the promise:
272
+ | Decorator | Use for | Returns | Recomputes |
273
+ | --- | --- | --- | --- |
274
+ | `@Value()` | primitives, or values you always replace | the value | on assignment |
275
+ | `@State()` | plain objects and arrays mutated in place | a deep reactive proxy | on any tracked write |
276
+ | `@Scope()` getter | cheap derivations; filters/sorts of existing objects | whatever the getter returns (new object each run if it builds one) | lazily, on the next read after a change |
277
+ | `@Computed()` getter | building new objects consumed in several places | the **same** object every time, updated in place | eagerly, when a dependency changes |
278
+ | `@ComputedAsync(default)` getter | as `@Computed`, diffed off-thread | same object every time; `default` until first result | eagerly |
279
+ | `@Watch(sel)` method | side effects on change | — | — |
280
+ | `@Inject(Type)` field | dependency injection | — | — |
281
+ | `@Destroy()` field | calling `.Destroy()` at teardown | — | — |
1005
282
 
1006
- ```typescript
1007
- scope(async () => (await fetch(url)).text()); // typed string, not Promise<string>
1008
- ```
283
+ A plain getter with no decorator is reactive too (its reads register in whatever scope calls it) but is not cached.
1009
284
 
1010
- The same unwrap applies to config functions — `FunctionOr<T>` for `props`/`attrs`/`on`, and the `Promise<Array<T>> | Promise<T>` union for `data:`. **The `async` keyword is required:** `IsAsync` detects it via `Symbol.toStringTag`. A plain function that merely *returns* a promise (`() => fetch(url)`) is treated as a synchronous scope whose value is the promise itself — it is never resolved (see Traps #17).
285
+ **Writes notify even when the value is unchanged.** Assigning a value equal to the current one re-runs every reader. This holds for `@Value` fields, `@State` properties and `ObservableNode` properties (verified). Guard writes that can repeat (`if (s.view !== v) s.view = v;`), or have readers use `gate()`, which ignores equal values.
1011
286
 
1012
- **Pending behavior.** An async scope renders nothing until its first result arrives: the initial value is `null`, so async `data:` collapses to no children and an async `props:` function applies nothing (the assignment closures skip null inputs). After the first result, the scope **keeps the last result while a new one is pending** — the value is replaced only when the promise resolves, and a resolution that arrives after a newer re-run has started is discarded.
287
+ ### @Value
1013
288
 
1014
289
  ```typescript
1015
- // Valid: nothing is applied to the element until the promise resolves.
1016
- div({ props: async () => ({ innerHTML: await getMarkup() }) });
290
+ @Value() count = 0;
291
+ @Value() list: number[] = [];
1017
292
  ```
1018
293
 
1019
- **Batching.** Async scopes are greedy — a dependency change marks the scope dirty and queues the re-run on the microtask queue (batched), instead of re-evaluating and propagating synchronously. This is the `greedy: true` behavior noted in Async Patterns.
294
+ No proxy. Only assignment notifies: `this.list.push(3)` does **not** update the view; `this.list = [...this.list, 3]` does.
1020
295
 
1021
- **Composition with `scope()`.** `scope()` can compose an async value inside a synchronous scope. The inner scope registers as a dependency of the enclosing watch context, so the **outer scope re-runs when the inner one resolves**:
296
+ ### @State
1022
297
 
1023
298
  ```typescript
1024
- Template() {
1025
- return div({
1026
- props: () => ({
1027
- innerText: scope(async () => (await fetch(`/api/summary?open=${this.openCount}`)).text()),
1028
- className: "summary-container",
1029
- }),
1030
- });
1031
- }
299
+ @State() tasks: Task[] = [];
300
+ this.tasks.push(t); // tracked
301
+ this.tasks[0].completed = true; // tracked
302
+ this.tasks.splice(i, 1); // tracked
1032
303
  ```
1033
304
 
1034
- `scope()` must be called inside a watch context — a `@Scope()` getter, a children function, or a `props:`/`data:` function (see Traps #11).
305
+ Tracked array methods: `push`, `pop`, `shift`, `unshift`, `splice`, `sort`, `reverse`. Only plain objects and arrays are deep-tracked; mutations inside class instances, `Map` and `Set` are not.
1035
306
 
1036
- **Dependency capture.** Only reactive reads before the first `await` are tracked — reads after it happen outside the watch context and don't re-run the scope (see Traps #2). Read every reactive value the async scope depends on before the first `await`.
307
+ ### @Scope
1037
308
 
1038
- ---
1039
-
1040
- ## Scope Selection Decision Tree
1041
-
1042
- Need derived data?
1043
- -> Is it a simple read from `this.Data` without computation?
1044
- - Yes -> No decorator needed — plain getter. Reading `this.Data` is already reactive.
1045
- - Read it inside children function, `props:` function, or `data:` binding for scoped subscriptions
1046
- -> Consumed by one UI region?
1047
- - Yes -> `@Scope()` per region, read inside children function
1048
- - No, multiple regions need different slices?
1049
- - Cheap, same reference (filter/sort of existing array) -> `@Scope()` per region
1050
- - Creating new composite object?
1051
- - Need reference stability across updates -> `@Computed()`
1052
- - Each region independent -> `@Scope()` per region
1053
-
1054
- Where to read a scope in Template()?
1055
- - Avoid the Destructuring Trap (see Mental Model): don't hoist reads to the top of `Template()`.
1056
- - Inside children function -> subscribes only that subtree (preferred)
1057
- - Inside `data:` binding -> subscribes only that iteration (preferred)
1058
-
1059
- Need conditional rendering?
1060
- - No "else" branch needed -> `data:` boolean (`data: () => this.condition`)
1061
- - "Else" branch needed, condition isolated from siblings -> nested children function with ternary
1062
- - "Else" branch needed, condition shares scope with frequently-changing siblings -> `gate()` ternary
1063
-
1064
- ---
309
+ ```typescript
310
+ @Scope() get activeCount() { return this.tasks.filter((t) => !t.completed).length; }
311
+ ```
1065
312
 
1066
- ## Inline Computed Scopes: scope(), gate(), peek(), mapped()
313
+ - Created on first read. When a dependency changes it notifies consumers but re-runs the getter only on the next read.
314
+ - No equality check: consumers re-run even if the new value equals the old one (a boolean that stays `false` still re-runs its readers). Wrap in `gate()` to suppress that (see Inline scopes).
315
+ - Returning an existing object (`return this.tasks`) passes that same reference through. Returning `.filter(...)` returns a new array each run.
316
+ - Use one `@Scope` per independent UI region. A single getter returning `{ a: …, b: … }` is a new object on every change, so every region reading it re-runs.
1067
317
 
1068
- 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.
318
+ ### @Computed
1069
319
 
1070
- **Watch context** includes children functions, `data:`/`props:`/`attrs:` functions, `@Scope`/`@Computed`/`@ComputedAsync` getters, and `@Watch` callbacks. It does **not** include `on:` event handlers, which run outside reactive evaluation. Calling `scope()`/`gate()`/`peek()`/`mapped()` outside a watch context throws (e.g. `scope() must be called within a watch context`).
320
+ ```typescript
321
+ @Computed() get summary() {
322
+ return { total: this.tasks.length, done: this.tasks.filter((t) => t.completed).length };
323
+ }
324
+ ```
1071
325
 
1072
- ### scope() — Full Reactivity
326
+ - Created on first read; afterwards recomputes immediately when a dependency changes, then merges the result into the existing object.
327
+ - No store is involved. The getter runs inside an `ObservableScope.Gated` scope that is watched; each result is copied with `ObservableNode.Clone` and merged into an internal `ObservableNode` with `ObservableNode.Apply`. The property reads that node's root through a separate scope (from source).
328
+ - Readers of the property itself re-run only when the root is replaced. A reader of a sub-property (`this.summary.done`) re-runs only when that path changes (verified).
329
+ - `this.summary === this.summary` holds across content updates, including arrays growing or shrinking (verified), so `gate()` and identity-based item reuse see a stable reference.
330
+ - The root is replaced, with a new identity, when the result's type changes or an object result loses a key (verified: `{ a, b }` → `{ a }`). A primitive result therefore behaves like `gate()`: readers re-run only when the value changes (verified).
331
+ - The result is a **copy**. `@Computed() get x() { return this.tasks; }` returns an object that is not `this.tasks`. Use `@Scope` when you need the original reference.
332
+ - The copy step reads every property of the result, so all of them become dependencies. Returning a `@State` array therefore re-runs the getter when any item property changes (verified).
333
+ - No arguments. Prefer `@Scope` or a plain getter for cheap values.
1073
334
 
1074
- Creates an inline computed scope registered as a dependency of the parent. Emits on every recomputation — no `===` gating.
335
+ ### @ComputedAsync
1075
336
 
1076
337
  ```typescript
1077
- import { scope } from "j-templates";
1078
-
1079
- // Inline computed value
1080
- div({ data: () => scope(() => this.Data.items) }, (item) => div({}, () => item.name));
1081
-
1082
- // Async data fetching in a getter
1083
- @Scope()
1084
- get userData(): User {
1085
- return scope(async () => fetchUser(`/api/user/${this.userId}`));
338
+ @ComputedAsync(null) get user(): User | null {
339
+ return { id: this.userId, name: lookupName(this.userId) }; // must be synchronous
1086
340
  }
1087
341
  ```
1088
342
 
1089
- ### gate() — Emission Gatekeeper
343
+ - The getter must be synchronous. "Async" refers to the backend: results are diffed in a `StoreAsync` worker.
344
+ - Returns `default` first, then the computed object, with the same reference across updates (verified).
345
+ - Each instance creates `new Worker(new URL("../Store/Diff/defaultDiffTreeWorker.js", import.meta.url), { type: "module" })` (from source). It needs a real `Worker` and a bundler that handles `new URL(…, import.meta.url)`. In jsdom tests, stub `globalThis.Worker` (see Scheduling, errors and testing).
346
+ - For genuinely async data, use `@Scope()` + `scope(async () => …)` or `ObservableScope.Create(async () => …)`.
1090
347
 
1091
- Like `scope()`, but only emits when the value actually changes (`===` comparison). Prevents unnecessary downstream re-evaluations.
348
+ ### @Watch
1092
349
 
1093
350
  ```typescript
1094
- import { gate } from "j-templates";
1095
-
1096
- // Primitive gating — prevents emission when result unchanged
1097
- gate(() => this.Data.count > 10);
1098
-
1099
- // Array reference gating
1100
- div({ data: () => gate(() => this.Data.items) }, (item) => div({}, () => item.name));
351
+ @Watch((self) => self.Data.filter)
352
+ syncFilter(value: FilterType) { this.localFilter = value; }
353
+ ```
1101
354
 
1102
- // Conditional rendering — only re-evaluates when boolean flips, not on every upstream emission.
1103
- gate(() => this.visibleTodos.length === 0)
1104
- ? div({}, () => "No items")
1105
- : div({ data: () => this.visibleTodos }, (item) => ...),
355
+ - Fires once in `super.Bound()` with the initial value, then on every change.
356
+ - Several synchronous writes in one tick produce one call with the latest value (`n = 1; n = 2; n = 3` → one call with `3`).
357
+ - Without `super.Bound()` it never fires.
358
+ - Use it for side effects and for copying parent `Data` into local `@Value` state. Don't use it to drive rendering; reads in templates are already reactive.
1106
359
 
1107
- // Multiple gate scopes with custom IDs
1108
- gate(() => computeA(), "id-a");
1109
- gate(() => computeB(), "id-b");
360
+ ### @Inject and @Destroy
1110
361
 
1111
- > **⚠️ 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.
362
+ ```typescript
363
+ @Destroy() @Inject(DataService) dataService = new DataService(); // provide (and destroy on teardown)
364
+ @Inject(DataService) dataService!: DataService; // consume in a descendant
1112
365
  ```
1113
366
 
1114
- #### When to Use gate()
367
+ - `@Inject(Type)` makes the field a getter (`this.Injector.Get(Type)`) and setter (`this.Injector.Set(Type, value)`). Assigning registers the value on this component's injector; descendants find it by walking up the parent chain.
368
+ - `@Destroy()` calls `.Destroy()` on the field's value during teardown. The value should implement `IDestroyable` (`{ Destroy(): void }`).
369
+ - Abstract classes work as keys: `@Inject(IDataService)` with `abstract class IDataService`.
1115
370
 
1116
- | Scenario | Use gate()? | Why |
1117
- |----------|-------------|-----|
1118
- | Condition shares scope with reactive siblings | Yes | Prevents sibling re-render when boolean doesn't flip |
1119
- | Conditional rendering (boolean flip) | Yes | Re-evaluates ternary only when value changes true↔false |
1120
- | Direct `@State` array access | Optional | Value gating when parent changes |
1121
- | Static constant array | No | Never changes, adds overhead |
1122
- | Primitive derived state | Yes | `===` prevents unnecessary emissions |
1123
- | Parent aggregates multiple values | Yes | Child only cares about specific parts |
1124
- | Array transformations (filter/map) | No | Always new references, never helps |
1125
- | Multiple uses in same template | Yes | Scope reuse avoids duplicate work |
1126
- | Wrapping `@Scope` getter | No | `@Scope` always returns new ref; `===` always differs |
371
+ ## Inline scopes: scope, gate, peek, mapped
1127
372
 
1128
- #### Empty state vs. list — the canonical `gate()` pattern
373
+ `scope`, `gate` and `mapped` create a memoized child scope inside the scope currently being evaluated and return its value. `peek` creates no scope: it calls its function immediately with dependency tracking turned off.
1129
374
 
1130
- The most common real-world use of `gate()` is choosing between an empty-state
1131
- message and a `data:` list, where the two must be mutually exclusive (never both
1132
- in the DOM):
375
+ | Function | Parent re-runs when the value changes | Equality check | Use for |
376
+ | --- | --- | --- | --- |
377
+ | `scope(fn, id?)` | yes | none: parent re-runs on every emit | composing a value, especially async |
378
+ | `gate(fn, id?)` | yes | `!==` only | booleans and other primitives that change less often than their inputs |
379
+ | `peek(fn)` | no | — | reading without subscribing; works anywhere |
380
+ | `mapped(data, fn, onUpdated?, onDestroyed?)` | yes | none | a per-item scope for one value (what `data:` does internally) |
1133
381
 
1134
- ```typescript
1135
- div({}, () =>
1136
- gate(() => this.visibleTodos.length === 0)
1137
- ? div({}, () => "No items")
1138
- : div({ data: () => this.visibleTodos }, (item) => div({}, () => item.name)),
1139
- )
1140
- ```
382
+ Verified example: with `n` going from 0 to 1, `scope(() => n > 10)` re-runs the parent, `gate(() => n > 10)` and `peek(() => n > 10)` do not.
1141
383
 
1142
- **Why `gate()` and not a nested children function?** The wrapper children
1143
- function reads `this.visibleTodos.length`. Without `gate()`, *every* change to
1144
- `visibleTodos` (e.g. toggling one item while the list stays non-empty) re-runs
1145
- the wrapper and rebuilds the whole subtree. `gate()` only emits when the boolean
1146
- flips, so the ternary is not re-evaluated on those upstream emissions. The list
1147
- still updates correctly because its `data: () => this.visibleTodos` binding has
1148
- its own reactive scope that subscribes to `visibleTodos` directly — independent
1149
- of the wrapper.
384
+ **Where they can be called.** `scope`, `gate` and `mapped` only while a scope is evaluating: children functions, `data:`/`props:`/`attrs:` functions, decorated getters, and `ObservableScope.Create` value functions. Elsewhere, including `on:` handlers, they throw `scope() must be called within a watch context` (or `gate()`; `mapped()` reports `scope()`). They also throw inside a `peek` callback, because tracking is off there (verified).
1150
385
 
1151
- **Why not `data:` boolean?** `data:` boolean only controls the element's
1152
- *children*; the element itself stays in the DOM. A styled empty-state container
1153
- would remain visible as an empty box. `gate()` (or a nested children function)
1154
- actually removes the element.
386
+ **peek() details.** `peek` can be called anywhere, including `on:` handlers and plain code (verified). It is not memoized and takes no id: the callback runs on every call, so a parent that re-runs calls it again (verified). It does not await: an `async` callback returns its Promise.
1155
387
 
1156
- **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`).
388
+ **gate() with @Scope.** Works for a getter that returns a primitive: `gate(() => this.isEmpty)` suppresses re-runs while the boolean is unchanged. Useless for a getter that builds a new object or array each run, because every result is `!==` the last.
1157
389
 
1158
- `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.
390
+ ### How memoization matches calls
1159
391
 
1160
- ### peek() — Read Without Subscribing
392
+ This applies to `scope`, `gate` and `mapped`; `peek` has no scope to match. On each re-run of the parent, each call is matched to a scope from the previous run with the same key, in call order. The key is `id` if given, otherwise one default key per function. `mapped()` keys by the `data` object instead. Consequences:
1161
393
 
1162
- 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.
394
+ 1. **Unconditional duplicates are safe.** Two `gate()` calls without ids, always made in the same order, stay independent across re-runs (verified).
395
+ 2. **Conditional or reordered calls need ids.** If a call without an id is skipped, the next same-function call takes its old scope and returns the wrong value, permanently:
1163
396
 
1164
397
  ```typescript
1165
- import { peek } from "j-templates";
398
+ // Bug: both gates run in the same children function. After showA becomes false,
399
+ // the b gate takes a's old scope and shows "b=A" from then on (verified).
400
+ div({}, () => {
401
+ const parts: string[] = [];
402
+ if (this.showA) parts.push("a=" + gate(() => this.a));
403
+ parts.push("b=" + gate(() => this.b));
404
+ return parts.join(" ");
405
+ });
406
+
407
+ // Fix (verified): give each call its own id
408
+ if (this.showA) parts.push("a=" + gate(() => this.a, "a"));
409
+ parts.push("b=" + gate(() => this.b, "b"));
410
+ ```
1166
411
 
1167
- // Read reactive data without subscribing
1168
- const timestamp = peek(() => Date.now());
412
+ 3. **The callback from the first run is kept.** On re-runs the existing scope is reused and the new callback is ignored, so a callback that closes over a local variable keeps the first run's value:
1169
413
 
1170
- // Read with custom ID for multiple uses
1171
- const id = peek(() => this.Data.id, "id");
1172
- const name = peek(() => this.Data.name, "name");
414
+ ```typescript
415
+ div({}, () => {
416
+ const local = this.n; // re-read on every run
417
+ return `${gate(() => local * 10)}`; // stays at the first value of local
418
+ });
1173
419
  ```
1174
420
 
1175
- `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.
421
+ Read reactive values inside the callback (`gate(() => this.n * 10)`), not through captured locals.
422
+
423
+ 4. **mapped() duplicates are independent.** `mapped(obj, f)` and `mapped(obj, g)` in one run return `f(obj)` and `g(obj)` separately (verified).
1176
424
 
1177
- ### mapped() — Per-Item Scopes (Advanced)
425
+ ## Async scopes
1178
426
 
1179
- `mapped(data, callback, onUpdated?, onDestroyed?)` creates a per-item scope for a single data value. This is the mechanism `data:` uses internally — array iteration is effectively `array.map(item => mapped(item, callback))`, one `mapped()` call per item, keyed by object identity. Only needed for advanced manual per-item scoping.
427
+ Any scope function can be `async`: `data:`, `props:` and `attrs:` functions, `@Scope` getters (via `scope()`), the inline helpers, and `ObservableScope.Create`.
1180
428
 
1181
429
  ```typescript
1182
- import { mapped } from "j-templates";
430
+ @Scope() get user() {
431
+ return scope(async () => (await fetch(`/api/user/${this.userId}`)).json());
432
+ }
1183
433
 
1184
- mapped(data, (d) => /* ... */, (lastValue, scope) => /* onUpdated */, (lastValue) => /* onDestroyed */);
434
+ div({ data: async () => await loadItems() }, (item) => div({}, () => item.name));
435
+ div({ props: async () => ({ innerHTML: await getMarkup() }) });
436
+ div({ props: () => ({ textContent: scope(async () => fetchSummary(this.openCount)) }) });
1185
437
  ```
1186
438
 
1187
- Signature:
439
+ Verified behavior:
440
+
441
+ - **The `async` keyword is required.** A plain function that returns a promise (`() => fetch(url)`) is a synchronous scope whose value is the promise object, never resolved.
442
+ - **Pending = `null`.** The value is `null` until the first result: async `data:` renders no children and async `props:` applies nothing.
443
+ - **Old value kept while re-running.** After the first result, the previous value stays until the new promise resolves. A result that arrives after a newer run has started is discarded.
444
+ - **Only reads before the first `await` are tracked.** Read every reactive value you depend on first.
1188
445
 
1189
446
  ```typescript
1190
- function mapped<D, T>(
1191
- data: D,
1192
- callback: (data: D) => T | Promise<T>,
1193
- onUpdated?: (lastValue: T, scope: IObservableScope<T>) => void,
1194
- onDestroyed?: (lastValue: T) => void
1195
- ): T
447
+ scope(async () => {
448
+ const id = this.userId; // tracked
449
+ const res = await fetch(`/u/${id}`);
450
+ return this.format(res); // reads inside format() are NOT tracked
451
+ });
1196
452
  ```
1197
453
 
1198
- - `data` is a **single** value — there is no array-iterating form. Iterate arrays with `data:` on a DOM element instead.
1199
- - `onUpdated` fires when the per-item scope's value changes.
1200
- - `onDestroyed` fires when the per-item scope is torn down.
1201
- - Like the other inline scopes, `mapped()` must be called within a watch context.
454
+ - **Composition.** `scope(async …)` inside a synchronous `props:` function works: the outer function re-runs when the inner value resolves and when its tracked inputs change.
455
+ - TypeScript types the helpers as `() => T | Promise<T>`, so `scope(async () => "x")` is typed `string`.
1202
456
 
1203
- #### Comparison
457
+ There is no built-in loading placeholder: render one yourself while the value is `null`.
1204
458
 
1205
- | Function | Registers dependency | Gates on `===` | Use when |
1206
- |----------|---------------------|----------------|----------|
1207
- | `scope()` | Yes | No | Full reactivity needed |
1208
- | `gate()` | Yes | Yes | Prevent unnecessary downstream updates |
1209
- | `peek()` | No | N/A | One-time reads, display-only values |
1210
- | `mapped()` | Yes (per item) | No | Per-item scopes (advanced) |
459
+ ## Composition and dependency injection
1211
460
 
1212
- ---
1213
-
1214
- ## Component Composition
1215
-
1216
- ### Parent → Child (Data)
461
+ ### Parent to child: data
1217
462
 
1218
463
  ```typescript
1219
- // Parent passes data (read-only in child)
1220
- childComponent({ data: () => ({ userId: this.userId }) });
464
+ child({ data: () => ({ userId: this.userId }) });
1221
465
 
1222
- // Child receives via this.Data
1223
466
  class Child extends Component<{ userId: string }> {
1224
467
  Template() { return div({}, () => this.Data.userId); }
1225
468
  }
1226
-
1227
- // @Value() is for component-internal mutable state, not parent data
1228
469
  ```
1229
470
 
1230
- ### Child → Parent (Events)
471
+ `this.Data` is read-only input. For local editable state, copy it into a `@Value` with `@Watch((self) => self.Data.userId)`.
472
+
473
+ ### Child to parent: events
1231
474
 
1232
475
  ```typescript
1233
- interface ChildEvents { save: { data: string }; }
476
+ interface ChildEvents { save: { text: string }; done: void; }
1234
477
 
1235
478
  class Child extends Component<{}, {}, ChildEvents> {
1236
- Template() { return button({ on: { click: () => this.Fire("save", { data: "value" }) } }); }
479
+ Template() {
480
+ return button({ on: { click: () => this.Fire("save", { text: "x" }) } }, () => "Save");
481
+ }
1237
482
  }
1238
483
 
1239
- childComponent({ on: { save: (payload) => console.log(payload.data) } });
484
+ child({ on: { save: (p) => console.log(p.text), done: () => {} } });
1240
485
  ```
1241
486
 
1242
- Component events don't bubble through DOM — only via j-templates `on:` system.
487
+ Component events go only to the parent's `on:` handler. They are not DOM events: DOM listeners on ancestors never see them.
1243
488
 
1244
- ### Template Callbacks
489
+ ### Template callbacks
1245
490
 
1246
- ```typescript
1247
- interface ItemTemplate<D> { render: (data: D) => vNode; }
491
+ A parent can pass render functions as the second argument; the child calls them via `this.Templates`.
1248
492
 
1249
- class Container<D> extends Component<{ items: D[] }, ItemTemplate<D>> {
493
+ ```typescript
494
+ class List<D> extends Component<{ items: D[] }, { render: (item: D) => vNode }> {
1250
495
  Template() {
1251
496
  return div({ data: () => this.Data.items }, (item: D) => this.Templates.render(item));
1252
497
  }
1253
498
  }
499
+ const list = Component.ToFunction("item-list", List);
1254
500
 
1255
- container({ data: () => ({ items: users }) }, { render: (user) => div({}, () => user.name) });
501
+ list({ data: () => ({ items: users }) }, { render: (u) => div({}, () => u.name) });
1256
502
  ```
1257
503
 
1258
- 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:
504
+ Use a dedicated child component instead when each item needs its own state, events, lifecycle or injected services.
1259
505
 
1260
- ```typescript
1261
- // Callback — no state, no events, no lifecycle
1262
- container({ data: () => items }, (item) => div({}, () => item.name));
1263
-
1264
- // Component — full reactivity, events, lifecycle
1265
- container({ data: () => items }, (item) =>
1266
- itemCard({ data: () => ({ item }), on: { deleted: (p) => handleDelete(p) } })
1267
- );
1268
- ```
506
+ ### Shared services between siblings
1269
507
 
1270
- Prefer dedicated components when the item needs internal state (`@Value`, `@State`), fires events, requires `Bound()`/`Destroy()` lifecycle, or injects services.
508
+ Provide one service instance from a common ancestor with `@Inject`, and expose reactive state from it with `ObservableScope` (see the `CounterService` example under ObservableScope).
1271
509
 
1272
- ### Sibling Communication (Shared Service)
510
+ ### Injector
1273
511
 
1274
- Inject same service, use `ObservableScope` for reactive messaging. See `docs/patterns/04-dependency-injection.md`.
1275
-
1276
- ### Accessing DOM Elements
512
+ `Injector` **is** exported from `j-templates/Utils`. Inside components, prefer `@Inject` and `this.Injector`.
1277
513
 
1278
514
  ```typescript
1279
- Bound() {
1280
- super.Bound();
1281
- const host = this.VNode.node as HTMLElement;
1282
- const scrollEl = host.querySelector('.scroll-container');
1283
- scrollEl?.scrollTo(0, scrollEl.scrollHeight);
1284
- // Use requestAnimationFrame() if child elements aren't ready yet
515
+ class Injector {
516
+ constructor(); // parent = Injector.Current()
517
+ Get<T>(type: any): T; // searches up the parent chain; undefined if absent
518
+ Set<T>(type: any, instance: T): T; // stores at this level; returns instance
519
+ static Current(): Injector;
520
+ static Scope<R>(injector: Injector, fn: (...args) => R, ...args): R;
1285
521
  }
1286
522
  ```
1287
523
 
1288
- ---
1289
-
1290
- ## Dependency Injection
1291
-
1292
- ### Injector API
524
+ `Injector.Scope` runs `fn` with `injector` as current, so injectors created inside it get it as their parent. It also **catches any exception from `fn`**, logs `Error evaluating injector scope` with `console.error`, and returns `undefined`. The framework evaluates `Template()` and children functions through it (see Scheduling, errors and testing).
1293
525
 
1294
- > ⚠️ 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.
526
+ ## Store
1295
527
 
1296
- ```typescript
1297
- class Injector {
1298
- constructor(); // Sets parent to Injector.Current()
1299
- Get<T>(type: any): T; // Searches parent chain. Returns undefined as T if not found.
1300
- Set<T>(type: any, instance: T): T; // Sets at this scope, returns instance.
1301
- }
528
+ A store holds reactive data under string keys and diffs every write into existing objects in place. Use `StoreSync` unless diffing large data blocks the main thread.
1302
529
 
1303
- namespace Injector {
1304
- Current(): Injector | null;
1305
- Scope<R, P>(injector: Injector, action: (...args: P) => R, ...args: P): R;
1306
- }
1307
- ```
530
+ | | `StoreSync` | `StoreAsync` |
531
+ | --- | --- | --- |
532
+ | Constructor | `new StoreSync(keyFunc?, projections?)` | `new StoreAsync(worker, keyFunc?)` |
533
+ | Diffing | main thread, synchronous | in the `Worker` you supply |
534
+ | Writes | `void`; readable immediately | `Promise`; readable only after `await` |
535
+ | `Destroy()` | none | required; terminates the worker |
536
+ | Backs | nothing (`@Computed` uses `ObservableNode` directly) | `@ComputedAsync` |
1308
537
 
1309
- ### Abstract Service Pattern
538
+ ### API
1310
539
 
1311
540
  ```typescript
1312
- abstract class IDataService implements IDestroyable {
1313
- abstract getData(): Data[];
1314
- abstract Destroy(): void;
1315
- }
1316
-
1317
- class DataService implements IDataService {
1318
- private store = new StoreSync();
1319
- getData(): Data[] { return this.store.Get<Data[]>("data", []); }
1320
- Destroy(): void { /* cleanup */ }
1321
- }
1322
-
1323
- // Parent provides, child consumes
1324
- class App extends Component {
1325
- @Destroy() @Inject(IDataService) dataService = new DataService();
1326
- }
1327
- class Child extends Component {
1328
- @Inject(IDataService) dataService!: IDataService;
1329
- }
541
+ Get<T>(key: string): T | undefined; // reactive proxy; undefined if absent
542
+ Get<T>(key: string, defaultValue: T): T; // creates the key with the default if absent
543
+ Has(key: string): boolean;
544
+ Write(data: unknown, key?: string); // replace/diff the value at key
545
+ Patch(key: string, patch: unknown); // deep merge; throws "Key not found in store"
546
+ Push(key: string, ...items: unknown[]); // append to the array at key
547
+ Splice(key: string, start: number, deleteCount?: number, ...items: unknown[]); // returns removed items
1330
548
  ```
1331
549
 
1332
- **Key:** Injector has parent chain — `Get` traverses up. `@Inject` provides getter + setter. Combine `@Inject` + `@Destroy` for services needing cleanup. `@Destroy` requires `IDestroyable`.
1333
-
1334
- ---
1335
-
1336
- ## Store (StoreSync & StoreAsync)
1337
-
1338
- ### Choosing Between StoreSync and StoreAsync
1339
-
1340
- Both stores share the same API and flattening model, but differ fundamentally in where and how diffing occurs.
550
+ `StoreAsync` has the same methods; the four writers return promises.
1341
551
 
1342
- **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 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.
552
+ ### keyFunc and flattening
1343
553
 
1344
- **StoreAsync** runs diff computation on a `Worker` you supply, off the main thread, so large dataset operations don't block rendering or input. Use StoreAsync when diffing genuinely large or deeply nested datasets — message feeds, large tables, real-time data. `@ComputedAsync` uses `StoreAsync` internally, with a shared prebuilt worker.
554
+ `keyFunc(value)` returns an id for any object (or `undefined`). On `Write`/`Push`, every nested object with an id is also stored under that id, so it can be patched directly while `Get` still returns the original shape.
1345
555
 
1346
- **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.
1347
-
1348
- | Aspect | StoreSync | StoreAsync |
1349
- |--------|-----------|------------|
1350
- | Diff execution | Main thread, synchronous | Caller-supplied `Worker`, asynchronous |
1351
- | Write consistency | Immediate — readable same tick | Eventual — must `await` before reading |
1352
- | Construction | `new StoreSync(keyFunc?, projections?)` | `new StoreAsync(diffWorker, keyFunc?)` — the worker is built separately (see below) |
1353
- | `keyFunc`/projections used for diffing | Passed straight to the constructor, can close over outer scope | Must be baked into the worker's own entry file — the constructor's `keyFunc` only resolves aliases locally, on the main thread |
1354
- | Data constraint | Any JS value | **JSON-serialisable only** — no class instances, methods, `Date`, `Map`, `Set`, circular refs (carried over `postMessage`'s structured clone) |
1355
- | `Destroy()` required | No | Yes — terminates the worker and queue |
1356
- | Best for | Most app state | Large / real-time datasets |
1357
- | Decorator backend | `@Computed` | `@ComputedAsync` |
556
+ ```typescript
557
+ const store = new StoreSync((v: any) => v?.id);
558
+ store.Write([{ id: "a", done: false }, { id: "b", done: false }], "todos");
559
+ store.Patch("a", { done: true }); // targets the nested item by id
560
+ store.Get<Todo[]>("todos", [])[0].done; // true
561
+ ```
1358
562
 
1359
- ### StoreAsync: Supplying the Worker
563
+ - The explicit `key` argument names the root, overriding `keyFunc` for it.
564
+ - If an id appears twice in one tree, the last object wins and both positions show it.
565
+ - An object without an id is reachable only through its explicit key. Get with an entity id returns that entity directly, the same object that appears in its list (verified).
566
+ - `Patch` merges deeply: `Patch("u", { profile: { age: 2 } })` keeps `profile.name`.
1360
567
 
1361
- `StoreAsync` doesn't build or serialize a worker itself anymore — you construct a real `Worker` (a bundler-built module, not a Blob) and hand it in. Internally, a worker becomes usable by connecting a `DiffTree` to it with `ConnectWorkerToDiffTree`; that's what a worker *entry file* does.
568
+ ### StoreAsync and its worker
1362
569
 
1363
- **Most portable option — write your own tiny entry file.** This always works regardless of bundler, because it's a relative import inside your own source tree, exactly like any other bundler-built worker:
570
+ Supply a running `Worker` whose entry file connects a `DiffTree`. Any `keyFunc` or projections used for diffing must live in that file; the constructor's `keyFunc` only resolves ids on the main thread.
1364
571
 
1365
572
  ```typescript
1366
- // diff-worker.ts — a separate entry file; your bundler compiles it as its own chunk
573
+ // diff-worker.ts (bundled as its own worker entry)
1367
574
  import { DiffTree } from "j-templates/Store/Diff/diffTree";
1368
575
  import { ConnectWorkerToDiffTree } from "j-templates/Store/Diff/diffTreeWorker";
576
+ ConnectWorkerToDiffTree(new DiffTree((v: any) => v?.id), self as any as Worker);
1369
577
 
1370
- const diffTree = new DiffTree(/* keyFunc, projections — see below */);
1371
- ConnectWorkerToDiffTree(diffTree, self as any as Worker);
578
+ // main thread
579
+ const worker = new Worker(new URL("./diff-worker.ts", import.meta.url), { type: "module" });
580
+ const store = new StoreAsync(worker, (v: any) => v?.id);
581
+ await store.Write(todos, "todos");
582
+ store.Get("todos"); // only now defined
583
+ store.Destroy(); // when done
1372
584
  ```
1373
585
 
1374
- ```typescript
1375
- // wherever you construct the store
1376
- import { StoreAsync } from "j-templates/Store";
586
+ Data crosses the worker boundary by structured clone. Class instances arrive as plain objects without their methods; functions cannot be sent. Store plain data.
1377
587
 
1378
- const worker = new Worker(new URL("./diff-worker.ts", import.meta.url), { type: "module" });
1379
- const store = new StoreAsync(worker, keyFunc); // pass the same keyFunc for local alias resolution
1380
- ```
588
+ ### Projections
1381
589
 
1382
- **No custom `keyFunc`/projections needed?** The package ships a ready no-argument entry file, `defaultDiffTreeWorker.js`, which is what `@ComputedAsync` builds internally (see `src/Utils/decorators.ts`) via a relative import from inside the package itself:
590
+ A projection is a derived value the store computes and stores under `$projection_<name>`. Pass them as `StoreSync`'s second argument (for `StoreAsync`, to the worker's `DiffTree`).
1383
591
 
1384
592
  ```typescript
1385
- const worker = new Worker(
1386
- new URL("../Store/Diff/defaultDiffTreeWorker.js", import.meta.url),
1387
- { type: "module" },
1388
- );
1389
- const store = new StoreAsync(worker);
593
+ import { PROJECTION_PREFIX } from "j-templates/Store/Diff/diffTree"; // "$projection_"
594
+
595
+ const store = new StoreSync((v: any) => v?.id, {
596
+ activeCount: { reads: ["todos"], projection: (keys, todos) => todos.filter((t) => !t.done).length },
597
+ doubled: { reads: [`${PROJECTION_PREFIX}activeCount`], projection: (keys, n) => n * 2 },
598
+ });
599
+ store.Write(todos, "todos");
600
+ store.Get<number>(`${PROJECTION_PREFIX}activeCount`);
1390
601
  ```
1391
602
 
1392
- That relative path only resolves correctly from *inside* the installed package (it's relative to the file doing the importing), so from your own app code, writing your own entry file (above) is the reliable choice.
603
+ - `projection(keys, ...values)`: `keys` lists the resolved read paths; values follow in `reads` order.
604
+ - `reads` entries: a tree path (`"todos"`), an entity id from `keyFunc`, or another projection (chaining).
605
+ - The last read may be a root wildcard such as `"task_*"`: each matching root's key is added to `keys` and its value appended (verified: `[["task_1","task_2"], v1, v2]`).
606
+ - A projection has no value until every read resolves. It re-runs when a read's value changes.
607
+ - A cycle or a read of an undefined projection throws at construction.
1393
608
 
1394
- Because the worker module runs in its own realm, its `keyFunc`/projections can only close over things defined in that file — there's no shared runtime state with the main thread, and nothing is serialised with `.toString()`/`eval` (unlike older versions of this library). Data crossing the worker boundary, via `postMessage`, still goes through the structured clone algorithm, so it must be JSON-serialisable:
609
+ ## ObservableScope and ObservableNode
1395
610
 
1396
- ```typescript
1397
- // ❌ Methods and class instances are stripped by structured clone
1398
- class Todo { constructor(public id: string, public text: string) {} getText() { return this.text; } }
1399
- await store.Write(new Todo("1", "Buy milk"), "todo"); // getText() lost in transit
611
+ Use these directly in services and plain modules, where decorators are unavailable.
1400
612
 
1401
- // ✅ Plain objects only
1402
- await store.Write({ id: "1", text: "Buy milk" }, "todo");
613
+ ### ObservableScope
614
+
615
+ ```typescript
616
+ ObservableScope.Create(fn) // derived scope; tracks reads in fn; fn may be async
617
+ ObservableScope.Gated(fn) // like Create, but emits batched on the next microtask (per type docs)
618
+ ObservableScope.Basic(fn) // no tracking, no cache: emits whenever Update() is called
619
+ ObservableScope.Value(s) // read + subscribe the current scope
620
+ ObservableScope.Peek(s) // read without subscribing
621
+ ObservableScope.Touch(s) // subscribe without reading
622
+ ObservableScope.Watch(s, cb) // cb(scope) on every emit; Unwatch(s, cb) to stop
623
+ ObservableScope.OnUpdated(s, (lastValue, scope) => …)
624
+ ObservableScope.OnDestroyed(s, cb)
625
+ ObservableScope.Update(s) // mark changed and emit
626
+ ObservableScope.Destroy(s) / DestroyAll([s1, s2])
627
+ ObservableScope.Register(emitter)
1403
628
  ```
1404
629
 
1405
- ### Base Store API
630
+ **Static scopes.** If `Create`'s function reads no reactive value, it returns a static scope that ignores `Update()` (verified: zero emits). For a manually updated value, use `Basic`:
1406
631
 
1407
632
  ```typescript
1408
- class Store {
1409
- constructor(keyFunc?: (value: any) => string | undefined);
1410
- Get<O>(id: string): O | undefined; // Returns undefined if not found
1411
- Get<O>(id: string, defaultValue: O): O; // Creates and returns default if not found
1412
- Has(id: string): boolean; // Whether a root object exists for id
633
+ class CounterService implements IDestroyable {
634
+ private _count = 0;
635
+ private countScope = ObservableScope.Basic(() => this._count);
636
+ get count() { return ObservableScope.Value(this.countScope); } // reactive when read in a template
637
+ increment() { this._count++; ObservableScope.Update(this.countScope); }
638
+ Destroy() { ObservableScope.Destroy(this.countScope); }
1413
639
  }
1414
640
  ```
1415
641
 
1416
- ### StoreSync API
642
+ A service deriving from a store:
1417
643
 
1418
644
  ```typescript
1419
- class StoreSync extends Store {
1420
- constructor(keyFunc?: (value: any) => string | undefined, projections?: DiffTreeProjectionMap);
1421
- Write(data: unknown, key?: string): void;
1422
- Patch(key: string, patch: unknown): void; // Deep merge; throws if key not found
1423
- Push(key: string, ...data: unknown[]): void;
1424
- Splice(key: string, start: number, deleteCount?: number, ...items: unknown[]): unknown[];
1425
- // No Destroy() method
645
+ class TodoService implements IDestroyable {
646
+ private store = new StoreSync((v: any) => v?.id);
647
+ private active = ObservableScope.Create(() =>
648
+ this.store.Get<Todo[]>("todos", []).filter((t) => !t.done));
649
+ get Active() { return ObservableScope.Value(this.active); }
650
+ Destroy() { ObservableScope.Destroy(this.active); }
1426
651
  }
1427
652
  ```
1428
653
 
1429
- ### StoreAsync API
654
+ ### ObservableNode
1430
655
 
1431
656
  ```typescript
1432
- class StoreAsync extends Store {
1433
- constructor(diffWorker: Worker, keyFunc?: (value: any) => string | undefined);
1434
- async Write(data: unknown, key?: string): Promise<void>;
1435
- async Patch(key: string, patch: unknown): Promise<void>; // Deep merge; throws if key not found
1436
- async Push(key: string, ...data: unknown[]): Promise<void>;
1437
- async Splice(key: string, start: number, deleteCount?: number, ...items: unknown[]): Promise<unknown[]>;
1438
- Destroy(): void; // Always call when service is destroyed — terminates the worker
1439
- }
657
+ ObservableNode.Create(value) // deep reactive proxy (what @State uses)
658
+ ObservableNode.Unwrap(proxy) // raw underlying value
659
+ ObservableNode.Apply(node, value) // merge a full value in place; node identity kept
660
+ ObservableNode.Snapshot(node) // cached plain copy
661
+ ObservableNode.Clone(value) // replaces proxies with plain data, mutating plain containers in place; returns value
662
+ ObservableNode.Update(node, prop?) // force a change notification
663
+ ObservableNode.CreateFactory(alias?) // proxy factory with aliasing (used by stores)
1440
664
  ```
1441
665
 
1442
- ### Projections — Derived Values Computed Inside the Store
666
+ - `Snapshot` returns the same object on repeated calls until a write. After a write, unchanged nested parts are shared (`===`) with the previous snapshot. Snapshots are **not frozen**; treat them as read-only.
667
+ - `JSON.stringify(node)` and `node.toJSON()` use the cached snapshot.
668
+ - `ApplyDiff`, `ApplySplice`, `Assign`, `Touch` and `Read` also exist; they are internal to the stores.
1443
669
 
1444
- A projection is a derived value the store computes for you and stores alongside your written data, readable through `Get` like anything else. Declare a map of them at construction (`StoreSync`'s second argument; for `StoreAsync`, baked into the worker's `DiffTree` as shown above):
670
+ **Snapshot reads do not subscribe.** `JSON.stringify(node)`, `node.toJSON()` and `ObservableNode.Snapshot(node)` register no dependencies, so a scope that reads only through them never updates (verified). Read properties directly, or use `ObservableNode.Clone(node)`, which tracks every nested field and leaves the source untouched:
1445
671
 
1446
672
  ```typescript
1447
- import { StoreSync } from "j-templates/Store";
1448
- import { PROJECTION_PREFIX } from "j-templates/Store/Diff/diffTree";
673
+ const saver = ObservableScope.Gated(() => JSON.stringify(ObservableNode.Clone(state)));
674
+ ObservableScope.Watch(saver, (s) => localStorage.setItem("state", ObservableScope.Value(s)));
675
+ ObservableScope.Value(saver); // first read starts tracking
676
+ ```
1449
677
 
1450
- const store = new StoreSync((value: any) => value?.id, {
1451
- activeCount: {
1452
- reads: ["todos"],
1453
- projection: (keys: string[], todos: Todo[]) => todos.filter((t) => !t.completed).length,
1454
- },
1455
- });
678
+ ## Scheduling, errors and testing
1456
679
 
1457
- store.Write(todos, "todos");
1458
- store.Get<number>(`${PROJECTION_PREFIX}activeCount`); // "$projection_activeCount"
1459
- ```
680
+ ### Scheduling
1460
681
 
1461
- - `reads` is a list of dot-separated paths: a plain tree path (`"todos"`), a keyed entity's id (whatever `keyFunc` returns for it), or another projection's output via `` `${PROJECTION_PREFIX}otherId` `` (i.e. `"$projection_otherId"`) — chaining. The dependency graph is topologically sorted at construction, so a cycle (or a read of an unregistered id) throws immediately rather than on first write.
1462
- - `projection` is called as `(keys, ...values)` — every resolved read path, then each value positionally in the same order as `reads`.
1463
- - The final `reads` entry may instead be a wildcard root prefix (e.g. `"task_*"`, no dot nesting) — every root key with that prefix is read and appended as its own trailing key/value pair, so `projection` receives one extra `(key, value)` pair per match. Only the last read may be a wildcard, and it can't target a projection's `$projection_<id>` key.
1464
- - A projection only runs once every one of its `reads` resolves — a path never written, an entity never seen, or a projection that hasn't produced a value yet blocks it (and blocks anything chaining off it). It re-runs whenever a read's resolved value changes, and its result is diffed and applied in place like any other write, so only the fields that actually changed reach reactive readers.
682
+ By default, rendering is queued and runs in time slices of about 16 ms (microtask first, then `setTimeout` for leftover work).
1465
683
 
1466
- ### keyFunc and Automatic Flattening
684
+ - `Component.Attach` returns before the DOM is built.
685
+ - A state write is not visible in the DOM on the next line. Await a timeout before reading the DOM.
1467
686
 
1468
- `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.
687
+ Setting the environment variable `SYNC_SCHEDULING=true` (read from `process.env` once, at import) makes all of this synchronous: the DOM is complete when `Attach` returns and writes apply immediately. It is **off by default**, including in test runners, unless you set it. With it on, batching behavior (such as `@Watch` coalescing) cannot be observed.
1469
688
 
1470
- This means:
1471
- - `Get("todos")` returns the full original shape as an `ObservableNode` proxy tree
1472
- - `Patch(id, patch)` can target any registered entry by ID regardless of nesting depth — no need to know where in the hierarchy it lives
1473
- - 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
1474
- - If the same ID appears multiple times in a tree, the last encountered object wins
1475
- - `Patch` performs a deep merge into the located entry
689
+ ### Errors
1476
690
 
1477
- ```typescript
1478
- // keyFunc extracts the ID from any stored object
1479
- const store = new StoreSync((value: any) => value?.id);
691
+ There are no error boundaries. What happens depends on where the exception is thrown (all verified):
1480
692
 
1481
- // Write a list — each TodoItem is also registered individually by its id
1482
- store.Write(todos, "todos");
693
+ | Exception thrown in | Effect |
694
+ | --- | --- |
695
+ | `Template()` or a children function | caught and logged (`Error evaluating injector scope`); that element renders empty; rendering continues. The scope keeps its dependencies, so it recovers when they change. |
696
+ | a `props:`, `attrs:` or `data:` function | **uncaught; halts all further rendering on the page**, including components mounted earlier. Nothing recovered it in testing. |
697
+ | DOM update, from a children array containing a plain string or `null` | same as above: all rendering halts |
1483
698
 
1484
- // Patch a nested TodoItem directly by ID — no need to rewrite the whole list
1485
- store.Patch(todo.id, { completed: true });
699
+ Wrap any logic that can throw inside `props:`, `attrs:` and `data:` functions in `try`/`catch`, and never put strings or `null` into a returned array.
1486
700
 
1487
- // Get returns an ObservableNode proxy tree — reflects the patched item immediately
1488
- const updated = store.Get<TodoItem[]>("todos", []);
1489
- ```
701
+ ### Testing
702
+
703
+ Verification used tsx with jsdom; Vitest with jsdom should behave the same. Requirements:
1490
704
 
1491
- 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.
705
+ 1. Create `window`, `document` and the DOM classes as globals **before** importing j-templates.
706
+ 2. Set `SYNC_SCHEDULING=true` for synchronous assertions, or await a timeout (100–150 ms was reliable in testing) after each action.
707
+ 3. For `@ComputedAsync` or `StoreAsync`, stub `Worker` with an in-process shim:
1492
708
 
1493
- **Additional key points:**
1494
- - Explicit key passed to `Write`/`Push` overrides `keyFunc` for the root object
1495
- - Use separate keys for separate data states (`"messages"` vs `"pending-messages"`)
1496
- - Always `await` StoreAsync operations before reading back
1497
- - Cast `Get()` return value with generics: `Get<Type[]>("key", [])`
709
+ ```typescript
710
+ import { DiffTree } from "j-templates/Store/Diff/diffTree";
711
+ import { ConnectWorkerToDiffTree } from "j-templates/Store/Diff/diffTreeWorker";
1498
712
 
1499
- ### Store Operations Summary
713
+ function fakeWorker(tree = new DiffTree()) {
714
+ const inner: any = { onmessage: null,
715
+ postMessage: (d: any) => queueMicrotask(() => outer.onmessage?.({ data: structuredClone(d) })) };
716
+ const outer: any = { onmessage: null, onerror: null, terminate() {},
717
+ postMessage: (d: any) => queueMicrotask(() => inner.onmessage?.({ data: structuredClone(d) })) };
718
+ ConnectWorkerToDiffTree(tree, inner);
719
+ return outer;
720
+ }
721
+ (globalThis as any).Worker = class { constructor() { return fakeWorker(); } }; // for @ComputedAsync
722
+ const store = new StoreAsync(fakeWorker(new DiffTree((v: any) => v?.id)), (v: any) => v?.id);
723
+ ```
1500
724
 
1501
- | Operation | StoreSync | StoreAsync |
1502
- |-----------|-----------|------------|
1503
- | Write | sync | async (Promise) |
1504
- | Push | sync | async (Promise) |
1505
- | Patch | sync | async (Promise) |
1506
- | Splice | sync | async (Promise) |
1507
- | Get | sync | sync |
1508
- | Destroy | N/A | `Destroy(): void` — required |
1509
- | Construction | `keyFunc`/`projections` args | `diffWorker` (required) + local `keyFunc` |
1510
- | Data constraint | Any JS value | JSON-serialisable only |
725
+ jsdom has no `innerText`; use `textContent` in tests.
1511
726
 
1512
- ---
727
+ ## Worked example: Smart Tasks
1513
728
 
1514
- ## ObservableScope API
729
+ This exact code passed 18 interaction checks (add, toggle, delete, filter, Enter key, stats) and type-checks under `strict`. It uses `@State` mutation, `@Value`, a plain reactive getter, `@Scope`, `@Computed`, parent-to-child data, child-to-parent events, `data:` iteration, conditional rendering and two-way binding. Shown as one file; split per component as you prefer.
1515
730
 
1516
731
  ```typescript
1517
- namespace ObservableScope {
1518
- Create<T>(valueFunction: { (): T | Promise<T> }): IObservableScope<T>; // Non-greedy scope
1519
- Gated<T>(valueFunction: { (): T | Promise<T> }): IObservableScope<T>; // Greedy scope (batches via microtask)
1520
- Basic<T>(valueFunction: { (): T }): IBasicObservableScope<T>; // Direct-value scope; no dep tracking, no cache — Update() to emit
1521
- Value<T>(scope: IObservableScope<T>): T; // Get value + register dependency
1522
- Peek<T>(scope: IObservableScope<T>): T; // Get value without registering dependency
1523
- Touch<T>(scope: IObservableScope<T>): void; // Register as dependency without reading value
1524
- Watch<T>(scope: IObservableScope<T>, callback: EmitterCallback<[IObservableScope<T>]>): void;
1525
- Unwatch<T>(scope: IObservableScope<T>, callback: EmitterCallback<[IObservableScope<T>]>): void;
1526
- OnUpdated<T>(scope: IObservableScope<T>, callback: (lastValue: T, scope: IObservableScope<T>) => void): void;
1527
- OnDestroyed(scope: IObservableScope<unknown>, callback: EmitterCallback): void;
1528
- Update(scope: IObservableScope<any>): void; // Mark dirty, triggers recomputation
1529
- Register(emitter: Emitter): void;
1530
- Destroy<T>(scope: IObservableScope<T>): void;
1531
- DestroyAll(scopes: IObservableScope<unknown>[]): void;
732
+ import { Component } from "j-templates";
733
+ import { Value, State, Scope, Computed } from "j-templates/Utils";
734
+ import { div, h1, span, input, button } from "j-templates/DOM";
735
+
736
+ export interface Task { id: string; text: string; completed: boolean; }
737
+ export type FilterType = "all" | "active" | "completed";
738
+
739
+ // ---- TaskItem: renders one task, fires events upward
740
+ interface TaskItemEvents { toggle: { id: string }; delete: { id: string }; }
741
+ class TaskItem extends Component<Task, void, TaskItemEvents> {
742
+ Template() {
743
+ return div({
744
+ props: () => ({ className: this.Data.completed ? "task-item completed" : "task-item" }),
745
+ }, () => [
746
+ div({
747
+ props: () => ({ className: this.Data.completed ? "task-check checked" : "task-check" }),
748
+ on: { click: () => this.Fire("toggle", { id: this.Data.id }) },
749
+ }),
750
+ span({ props: { className: "task-text" } }, () => this.Data.text),
751
+ div({
752
+ props: { className: "task-delete" },
753
+ on: { click: () => this.Fire("delete", { id: this.Data.id }) },
754
+ }, () => "×"),
755
+ ]);
756
+ }
1532
757
  }
1533
- ```
758
+ const taskItem = Component.ToFunction("task-item", TaskItem);
759
+
760
+ // ---- TaskInput: two-way binding via a props function
761
+ interface TaskInputEvents { add: { text: string }; }
762
+ class TaskInput extends Component<void, void, TaskInputEvents> {
763
+ @Value() text: string = "";
1534
764
 
1535
- **Async limitation:** Dependencies are only captured synchronously. Read all reactive values before the first `await`. Reactive reads after `await` are not tracked.
765
+ private handleAdd(): void {
766
+ const trimmed = this.text.trim();
767
+ if (!trimmed) return;
768
+ this.Fire("add", { text: trimmed });
769
+ this.text = "";
770
+ }
1536
771
 
1537
- **Static scope edge case:** `Create` returns a *static* scope when its valueFunction reads no
1538
- reactive dependencies (no `@Value`/`@State`/other scope reads). Static scopes do **not** emit when
1539
- passed to `Update` — reactivity silently breaks with no error. If you need a manually-updatable
1540
- scope whose value doesn't derive from reactive state, use `Basic` instead, which always emits on
1541
- `Update`.
772
+ Template() {
773
+ return div({ props: { className: "task-input" } }, () => [
774
+ input({
775
+ props: () => ({ value: this.text, placeholder: "What needs doing?", type: "text" as const }),
776
+ on: {
777
+ input: (e: Event) => { this.text = (e.target as HTMLInputElement).value; },
778
+ keydown: (e: KeyboardEvent) => { if (e.key === "Enter") this.handleAdd(); },
779
+ },
780
+ }),
781
+ button({ props: () => ({ disabled: this.text.trim() === "" }), on: { click: () => this.handleAdd() } }, () => "Add"),
782
+ ]);
783
+ }
784
+ }
785
+ const taskInput = Component.ToFunction("task-input", TaskInput);
1542
786
 
1543
- ### Service Patterns
787
+ // ---- StatsBar: @Scope for cheap values, @Computed for a composite object
788
+ interface StatsBarData { tasks: Task[]; }
789
+ class StatsBar extends Component<StatsBarData> {
790
+ @Scope() get total(): number { return this.Data.tasks.length; }
791
+ @Scope() get activeCount(): number { return this.Data.tasks.filter((t) => !t.completed).length; }
792
+ @Scope() get completedCount(): number { return this.Data.tasks.filter((t) => t.completed).length; }
1544
793
 
1545
- ```typescript
1546
- // Derived state in services — worker built once, e.g. in a module-level helper
1547
- // (see "StoreAsync: Supplying the Worker" above for how diff-worker.ts is written)
1548
- const worker = new Worker(new URL("./diff-worker.ts", import.meta.url), { type: "module" });
794
+ @Computed()
795
+ get summary(): { active: number; completed: number; total: number; pct: string } {
796
+ const t = this.total;
797
+ const c = this.completedCount;
798
+ return { total: t, active: this.activeCount, completed: c, pct: t === 0 ? "0%" : `${Math.round((c / t) * 100)}%` };
799
+ }
1549
800
 
1550
- class DataService implements IDestroyable {
1551
- private store = new StoreAsync(worker, (value) => value.id);
1552
- private derived = ObservableScope.Create(() => {
1553
- const items = this.store.Get<Item[]>("items", []);
1554
- return ObservableNode.Unwrap(items).filter(i => i.active);
1555
- });
1556
- get DerivedData() { return ObservableScope.Value(this.derived); }
1557
- Destroy(): void { this.store.Destroy(); ObservableScope.Destroy(this.derived); }
801
+ Template() {
802
+ return div({ props: { className: "stats-bar" } }, () => [
803
+ span({}, () => `${this.activeCount} active`),
804
+ span({}, () => `${this.completedCount} completed`),
805
+ span({}, () => `${this.summary.pct} done`),
806
+ ]);
807
+ }
1558
808
  }
809
+ const statsBar = Component.ToFunction("stats-bar", StatsBar);
1559
810
 
1560
- // Reactive counter via shared service
1561
- // NOTE: _count is a plain field (not reactive), so Create() would yield a static scope that
1562
- // ignores Update(). Use Basic() for manually-updatable scopes.
1563
- class CounterService implements IDestroyable {
1564
- private _count = 0;
1565
- private countScope = ObservableScope.Basic(() => this._count);
1566
- get count() { return ObservableScope.Value(this.countScope); }
1567
- increment() { this._count++; ObservableScope.Update(this.countScope); }
1568
- Destroy(): void { ObservableScope.Destroy(this.countScope); }
811
+ // ---- FilterBar: reads parent data, fires filterChange
812
+ class FilterBar extends Component<{ activeFilter: FilterType }, void, { filterChange: { filter: FilterType } }> {
813
+ Template() {
814
+ return div({ props: { className: "filter-bar" } }, () =>
815
+ (["all", "active", "completed"] as FilterType[]).map((f) =>
816
+ button({
817
+ props: () => ({ className: this.Data.activeFilter === f ? `f-${f} on` : `f-${f}` }),
818
+ on: { click: () => this.Fire("filterChange", { filter: f }) },
819
+ }, () => f)));
820
+ }
1569
821
  }
1570
- ```
822
+ const filterBar = Component.ToFunction("filter-bar", FilterBar);
1571
823
 
1572
- ---
824
+ // ---- App: owns state, wires children
825
+ let nextId = 1;
826
+ class App extends Component {
827
+ @State() tasks: Task[] = [];
828
+ @Value() filter: FilterType = "all";
1573
829
 
1574
- ## ObservableNode API
830
+ // Plain getter: reactive because it reads @State/@Value; not cached
831
+ get filteredTasks(): Task[] {
832
+ switch (this.filter) {
833
+ case "active": return this.tasks.filter((t) => !t.completed);
834
+ case "completed": return this.tasks.filter((t) => t.completed);
835
+ default: return this.tasks;
836
+ }
837
+ }
1575
838
 
1576
- ```typescript
1577
- namespace ObservableNode {
1578
- Create<T>(value: T): T; // Wrap in reactive proxy
1579
- Unwrap<T>(value: T): T; // Get raw value from proxy
1580
- Clone<T>(value: T): T; // Strip proxies into plain data (mutates plain objects in place)
1581
- Snapshot<T>(proxy: T): T | undefined; // Immutable cached plain-value snapshot of a node
1582
- Update(value: unknown, prop?: string | number): void; // Manually trigger change on a node/property
1583
- Apply(rootNode: any, value: any): void; // Merge a full value in-place, preserving identity (public)
1584
- ApplyDiff(rootNode: any, diffResult: JsonDiffResult): void; // Apply diff in-place (internal — used by Store/@Computed)
1585
- CreateFactory(alias?: (value: any) => any | undefined): <T>(value: T) => T; // Factory with aliasing
839
+ private handleAdd(p: { text: string }): void {
840
+ this.tasks.push({ id: String(nextId++), text: p.text, completed: false });
841
+ }
842
+ private handleToggle(id: string): void {
843
+ const task = this.tasks.find((t) => t.id === id);
844
+ if (task) task.completed = !task.completed;
845
+ }
846
+ private handleDelete(id: string): void {
847
+ const idx = this.tasks.findIndex((t) => t.id === id);
848
+ if (idx !== -1) this.tasks.splice(idx, 1);
849
+ }
850
+
851
+ Template() {
852
+ return div({ props: { className: "app" } }, () => [
853
+ h1({}, () => "Smart Tasks"),
854
+ statsBar({ data: () => ({ tasks: this.tasks }) }),
855
+ taskInput({ on: { add: (p) => this.handleAdd(p) } }),
856
+ filterBar({
857
+ data: () => ({ activeFilter: this.filter }),
858
+ on: { filterChange: (p) => { this.filter = p.filter; } },
859
+ }),
860
+ // Isolated scope for the empty-state message
861
+ div({}, () => {
862
+ if (this.tasks.length === 0) return div({}, () => "No tasks yet");
863
+ if (this.filteredTasks.length === 0) return div({}, () => "No tasks match this filter.");
864
+ return null;
865
+ }),
866
+ // Iterated list: toggling one task does not rebuild the other rows (verified)
867
+ div({ props: { className: "task-list" }, data: () => this.filteredTasks },
868
+ (task: Task) =>
869
+ taskItem({
870
+ data: () => task,
871
+ on: {
872
+ toggle: () => this.handleToggle(task.id),
873
+ delete: () => this.handleDelete(task.id),
874
+ },
875
+ }),
876
+ ),
877
+ ]);
878
+ }
1586
879
  }
880
+
881
+ export const app = Component.ToFunction("smart-tasks", App);
882
+ Component.Attach(document.getElementById("app")!, app({}));
1587
883
  ```
1588
884
 
1589
- **`Apply` vs `ApplyDiff`:** `Apply(rootNode, value)` merges a full replacement value into an observable node in-place, preserving the node's reference (so `===` checks and DOM reuse stay stable). Use this when you want to update an observable node in place — assigning a property directly on an observable node does **not** generate a diff. `ApplyDiff` is internal-only (used by `StoreSync`/`StoreAsync`/`@Computed`) — ignore it.
1590
-
1591
- **`Snapshot` and `toJSON`.** `ObservableNode.Snapshot(proxy)` returns an immutable plain copy of a node's current value (arrays/objects only; keyed children are read through their root). The same snapshot is returned on repeated calls until a write invalidates it, and unchanged nested parts are shared between snapshots, so `===` holds for anything that hasn't changed — useful for cheap change detection or handing data to non-reactive code. Calling `.toJSON()` on an observable node (e.g. via `JSON.stringify`) returns this same cached snapshot.
1592
-
1593
- **Array operations on ObservableNode proxies:** `push`, `pop`, `shift`, `unshift`, `splice`, `sort`, `reverse` — all trigger reactive updates.
1594
-
1595
- ---
1596
-
1597
- ## Testing
1598
-
1599
- The standard setup is **Vitest + JSDOM**, which provides browser-like globals (`document`, DOM node classes, etc.) so components can be attached and inspected without a real browser.
1600
-
1601
- Set **`SYNC_SCHEDULING=true`** to make reactive updates apply synchronously — a state mutation is reflected in the DOM immediately, with no `flush()`/`await` step needed before asserting. This is the default for standard test runs. Omit it only when a test specifically needs to verify real async or batching timing (e.g. confirming `@Watch` debounces multiple synchronous writes into one call, or that an async scope resolves correctly across a microtask boundary) — with `SYNC_SCHEDULING` on, that behavior collapses and can't be observed.
1602
-
1603
- ---
1604
-
1605
- ## Traps & Gotchas
1606
-
1607
- These are the subtle behaviors that cause the most bugs. Read this before writing components.
1608
-
1609
- 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.
1610
- 2. **Async dependencies are captured synchronously only.** Read all reactive values before the first `await`. Reads after `await` are not tracked.
1611
- 3. **`data:` collapses ALL falsy non-array values to `[]`** — `false`, `null`, `undefined`, **and `0`, `""`, `NaN`**. Only truthy non-array values wrap as `[value]`.
1612
- 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()`.
1613
- 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)`.
1614
- 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.
1615
- 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. Reordering an array of existing references moves the DOM node and scope to the new position rather than recreating them.
1616
- 8. **`@Watch` fires immediately on `Bound()`** with the initial value — not just on changes. Missing `super.Bound()` means `@Watch` never fires. Multiple synchronous changes in the same tick are debounced into a single callback.
1617
- 9. **`@State` arrays support direct mutation** (`push`, `splice`, item property writes) because they're proxies. Plain arrays require reassignment.
1618
- 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.
1619
- 11. **`scope()`/`gate()`/`peek()`/`mapped()` throw outside a watch context.** They must be called inside a children function, `data:`/`props:`/`attrs:` function, `@Scope`/`@Computed`/`@ComputedAsync` getter, or `@Watch` callback — not inside `on:` event handlers.
1620
- 12. **`Injector` is not publicly exported.** Use `@Inject` and `this.Injector` on components.
1621
- 13. **StoreAsync data must be JSON-serialisable** (carried over `postMessage`'s structured clone), and `StoreAsync` now takes an already-running `Worker` as its first constructor argument — any `keyFunc`/projections needed for diffing must be baked into that worker's own entry file, not passed to `StoreAsync` directly. Always `await` StoreAsync writes before reading.
1622
- 14. **Two-way binding needs reactive props** (`props: () => ({ value })`). A static `props: { value }` object causes input focus loss.
1623
- 15. **The Destructuring Trap.** Reading a scope or `this.Data` at the top of `Template()` subscribes the whole component — the React instinct to hoist state reads is backwards here. Read scopes inside children functions or `data:` bindings for fine-grained updates. See Mental Model.
1624
- 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.
1625
- 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.
1626
- 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.
1627
- 19. **`@State`/`ObservableNode` only deep-tracks plain objects and arrays.** 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.
1628
- 20. **`ObservableScope.Create` with no reactive deps yields a static scope.** If the valueFunction reads no `@Value`/`@State`/other scope, `Create` returns a static scope that silently ignores `ObservableScope.Update` — reactivity breaks with no error. Use `ObservableScope.Basic` for manually-updatable scopes whose value doesn't derive from reactive state.
1629
- 21. **No error boundaries.** An exception thrown in a children function, `props:`/`attrs:` function, or getter propagates uncaught — there's no per-subtree isolation. Guard risky logic with your own `try`/`catch`.
1630
- 22. **Children functions can't return `null`/`undefined`.** For conditional presence, use the element's own `data:` (falsy → no children) or `fragment({ data: () => condition }, ...)` for no wrapper. A ternary's "else" branch must return a vNode (`text(() => "")`), not `null`.
1631
-
1632
- ---
1633
-
1634
- ## Anti-Patterns
1635
-
1636
- | Mistake | Fix |
1637
- |---------|-----|
1638
- | `@State()` for primitives | Use `@Value()` |
1639
- | `@Computed()` for cheap ops | Use `@Scope()` or plain getter |
1640
- | `@Computed()` for array filter/sort of existing refs | Use `@Scope()` — identity already preserved |
1641
- | `@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 |
1642
- | Single `@Scope` for multiple independent UI regions | One `@Scope` per region |
1643
- | `gate()` wrapping `@Scope` getter | Use `@Computed` or remove `gate()` |
1644
- | `data:` binding inside helper function called from `Template()` | Inline in `Template()` with `@Scope` data source |
1645
- | Destructuring/hoisting a scope read at top of `Template()` | Read inside children function or `data:` binding — see Mental Model |
1646
- | `this.Data` read at top of `Template()` | Read inside children function, `props:` function, or `data:` binding |
1647
- | Render callback for items needing state/events | Use dedicated component |
1648
- | Child `@Value` not synced with parent `Data` | Use `@Watch((self) => self.Data.prop)` |
1649
- | `.filter(Boolean)` for conditional rendering | Use ternary with `text(() => "")` fallback |
1650
- | Condition and sibling `data:` list in same children function | Wrap condition in nested children function, use `data:` boolean, or use `gate()` |
1651
- | `data:` boolean for a styled container that should disappear | Use nested children function or `gate()` — `data:` boolean keeps the element in the DOM (empty) |
1652
- | Assuming framework diffs vNode trees | It doesn't — optimize by minimizing scope emission frequency |
1653
- | `@State()` on class instances / `Date` / `Map` / `Set` | Use plain objects/arrays — non-plain objects are treated as primitives (no deep reactivity) |
1654
- | Promise-returning arrow without `async` keyword in an async scope | Use `async () => ...` — `IsAsync` only detects `async` functions |
1655
- | Children function returning `null`/`undefined` | Return `text(() => "")`, or move the condition into `data:`/`fragment()` |
1656
-
1657
- ---
1658
-
1659
- ## Debugging Reactivity
1660
-
1661
- | Symptom | Likely Cause | Fix |
1662
- |---------|--------------|-----|
1663
- | Initial render works, updates don't | Direct array mutation | Replace array, don't mutate (or use `@State` proxy) |
1664
- | Template never re-renders | `data:` not a function | Use `data: () => this.state` |
1665
- | Getter value stale | Not using `this.Data` | Read from `this.Data` in getter |
1666
- | `@Watch` never fires | Missing `super.Bound()` | Call in `Bound()` method |
1667
- | Memory leak | Missing cleanup | `@Destroy()` + `super.Destroy()` |
1668
- | Input loses focus | Static `props` object | Use `props: () => ({ value })` |
1669
- | Entire Template re-runs on small change | Destructuring Trap — scope read at top of `Template()` | Read scope inside children function or `data:` binding |
1670
- | Entire section re-renders on small change | Single `@Scope` feeds multiple regions | Split into per-region `@Scope` getters |
1671
- | `gate()` doesn't prevent re-renders | Wrapping `@Scope` getter (always new ref) | Read `@Scope` directly or use `@Computed` |
1672
- | `data:` binding re-renders every time | Element created in helper, not `Template()` | Inline element in `Template()` |
1673
- | Child form controls don't reflect parent changes | No sync from `this.Data` to `@Value` | Add `@Watch((self) => self.Data.prop)` |
1674
- | 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()` |
1675
- | Expensive Template re-runs on every change | Large vNode subtree subscribed to frequently-changing scope | Split into smaller scopes to reduce rebuild surface |
1676
- | Async scope resolves to a Promise instead of a value | Callback not declared `async` | Use `async () => ...` so `IsAsync` detects it |
1677
- | Uncaught exception crashes a render | No error boundaries exist | Add `try`/`catch` inside the risky function |
1678
-
1679
- ---
1680
-
1681
- ## Glossary
1682
-
1683
- | Term | Definition |
1684
- |------|------------|
1685
- | **vNode** | Virtual node — the internal representation of a DOM element or text node |
1686
- | **Scope** | A reactive unit that tracks dependencies, caches a value, and emits on change |
1687
- | **Static scope** | A scope with a fixed value — no dependency tracking, zero overhead |
1688
- | **Basic scope** | A lightweight scope used by `@Value` — stores a value directly, with no dependency tracking or caching; `ObservableScope.Update` must be called to emit |
1689
- | **Dynamic scope** | A scope with a getter function — tracks dependencies, re-evaluates on change |
1690
- | **Greedy scope** | A dynamic scope that batches updates via microtask queue (used for `@Watch`, async) |
1691
- | **Children function** | The function passed as the second argument to a DOM function (e.g., `div({}, () => ...)`) |
1692
- | **Watch context** | Code executed during evaluation of an `ObservableScope` — children functions, `data:`/`props:`/`attrs:` functions, `@Scope`/`@Computed`/`@ComputedAsync` getters, `@Watch` callbacks. Excludes `on:` handlers. |
1693
- | **The Destructuring Trap** | The React-primed instinct to hoist a state/scope read to the top of a component function; in j-templates this subscribes the whole `Template()` instead of a subtree. |
1694
- | **ApplyDiff** | Deep merge that preserves object identity — used internally by `@Computed` to update existing references in-place |
1695
- | **ObservableNode** | A reactive proxy wrapper around objects/arrays that tracks property-level mutations |
1696
- | **Injector** | Scoped dependency injection container with parent-chain resolution (not publicly exported) |
1697
- | **keyFunc** | A function passed to Store that extracts an ID from objects for automatic flattening |
1698
- | **Projection** | A derived value declared at Store construction (`reads` + a computing function), stored and read like any other data at `$projection_<id>` |
1699
-
1700
- ---
1701
-
1702
- ## References
1703
-
1704
- - **Source of truth:** `src/` (this primer documents `j-templates` v8.0.16).
1705
- - **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`.
1706
- - **Tutorials:** `docs/tutorials/` (01-getting-started through 08-building-complete-app).
1707
- - **Worked example:** `examples/smart-tasks/src/` (the Smart Tasks app used above).
1708
- - **Capstone project:** `examples/tutorial_project/tutorial-8/src/`.
885
+ ## Traps checklist
886
+
887
+ 1. **No vNode diffing.** A re-running children function rebuilds what it returns as new DOM nodes.
888
+ 2. **Destructuring Trap.** Reading state, `this.Data` or a getter at the top of `Template()` rebuilds the whole component on every change.
889
+ 3. **`data:` must be a function.** A plain value throws and halts rendering.
890
+ 4. **`data:` collapses every falsy value** (`0`, `""`, `NaN` included) to no children, and a falsy `data:` keeps the element itself in the DOM.
891
+ 5. **Arrays returned from children functions** must contain only vNodes. A string or `null` in the array halts all rendering.
892
+ 6. **Exceptions in `props:`, `attrs:` or `data:` functions halt all rendering on the page.** Exceptions in `Template()` and children functions are only logged.
893
+ 7. **Static `props` objects** inside a children function rebuild the element on every change it reads (inputs lose focus). Use `props: () => (…)`.
894
+ 8. **`@Value` does not see in-place mutation.** Reassign, or use `@State`.
895
+ 9. **`@State` deep-tracks plain objects and arrays only**, not class instances, `Map` or `Set`.
896
+ 10. **`@Scope` has no equality check**; consumers re-run on every change of its inputs. `gate()` it if it returns a primitive.
897
+ 11. **`@Computed` returns a copy**, not the object the getter returned. Its identity changes only when the result's type changes or an object result loses a key.
898
+ 12. **`Bound()` runs before `Template()`**, with the host detached and empty. Defer DOM access with `requestAnimationFrame`. Forgetting `super.Bound()` silences `@Watch`.
899
+ 13. **`@Watch` fires immediately** with the initial value, and coalesces synchronous changes into one call.
900
+ 14. **`scope()`, `gate()` and `mapped()` outside a scope throw**, including in `on:` handlers and inside `peek()` callbacks. `peek()` works anywhere.
901
+ 15. **Conditional `scope()`/`gate()` calls need ids**, or a later call takes a skipped call's scope. Callbacks are fixed at first run: don't close over locals.
902
+ 16. **Async scopes need the `async` keyword**, track only reads before the first `await`, and are `null` until resolved.
903
+ 17. **Rendering is asynchronous** unless `SYNC_SCHEDULING=true`: the DOM is not updated on the line after a write, and not built when `Attach` returns.
904
+ 18. **Native DOM listeners belong on elements you render**, not the host.
905
+ 19. **`svg()` is HTML-namespace.** Use namespaced components or `innerHTML` for SVG.
906
+ 20. **`ObservableScope.Create` with no reactive reads is static** and ignores `Update()`. Use `Basic`.
907
+ 21. **`StoreAsync` writes must be awaited**; data crosses by structured clone (class methods are lost).
908
+ 22. **The package needs a bundler or TypeScript runner**; plain Node cannot import it.
909
+
910
+ 23) **Equal-value writes still notify.** Assigning the current value again re-runs every reader. Guard repeated writes or read through `gate()`.
911
+ 24) **`JSON.stringify(node)` and `ObservableNode.Snapshot(node)` don't subscribe.** Read properties directly, or `ObservableNode.Clone(node)` to track everything.
912
+ 25) **Don't update a scope from its own emit.** Calling `ObservableScope.Update(s)` from a `Watch(s, …)` callback, or any write that re-emits the emitter currently notifying, throws `Emitter.Emit: re-entry during emit is not supported` (verified). Defer the write (`queueMicrotask`) or restructure.
913
+
914
+ ## Debugging
915
+
916
+ | Symptom | Likely cause | Fix |
917
+ | --- | --- | --- |
918
+ | Nothing renders anywhere after some point | exception in `props:`/`attrs:`/`data:`, or a string/`null` in a returned array | find the error in the console; guard the function; use `text()` |
919
+ | One element renders empty | exception in its children function or `Template()` (logged as `Error evaluating injector scope`) | fix the throwing code |
920
+ | View doesn't update after mutation | in-place mutation of a `@Value` array/object | reassign, or use `@State` |
921
+ | `data is not a function` | `data:` given a value | `data: () => value` |
922
+ | Whole component re-renders on small changes | Destructuring Trap | move reads into children functions |
923
+ | Input loses focus while typing | static `props` reading state | `props: () => ({ value })` |
924
+ | `@Watch` never fires | `Bound()` overridden without `super.Bound()` | call `super.Bound()` |
925
+ | Wrong value from a `gate()`/`scope()` after a condition flips | id-less calls made conditionally | add distinct ids |
926
+ | Inline helper shows a stale value | callback closes over a local from the first run | read reactive values inside the callback |
927
+ | `gate()` doesn't stop re-runs | gated value is a new object/array each time | gate a primitive, or use `@Computed` |
928
+ | Async value never resolves (shows a Promise) | callback lacks `async` | `async () => …` |
929
+ | Async value doesn't refresh | reactive read after `await` | read before the first `await` |
930
+ | `querySelector` in `Bound()` finds nothing | children not built yet | `requestAnimationFrame(() => …)` |
931
+ | Manual service scope never updates | `ObservableScope.Create` with no reactive reads | `ObservableScope.Basic` |
932
+ | Test sees old DOM after a write | asynchronous rendering | `SYNC_SCHEDULING=true` or await a timeout |
933
+ | `ReferenceError: self is not defined` | importing `defaultDiffTreeWorker` on the main thread | write your own worker entry file |
934
+ | A page or region rebuilds although nothing visibly changed | a write assigned the same value again (common in routers and sync code) | write only changed values, or read through gate() |
935
+ | A watcher or scope over JSON.stringify/Snapshot never fires | snapshot reads register no dependencies | read properties directly, or JSON.stringify(ObservableNode.Clone(node)) |
936
+ | `Emitter.Emit: re-entry during emit is not supported` | a `Watch`/`OnUpdated` callback updates the scope or value that is notifying it | defer the write, or move it out of the callback |
937
+ | `scope() must be called within a watch context` inside `peek()` | tracking is off inside `peek` | call `scope()`/`gate()` outside the `peek` callback |
938
+
939
+ ## Corrections from the v5 primer
940
+
941
+ For maintainers comparing against the previous `SYNTAX_PRIMER.md`. Builders can skip this section.
942
+
943
+ | Previous claim | Observed in 8.0.18 and 8.0.19 |
944
+ | --- | --- |
945
+ | `Injector` is not exported from any public entry point | exported from `j-templates/Utils` |
946
+ | `@Computed` returning `this.tasks` won't re-run on item property changes | it re-runs |
947
+ | a second un-ID'd `scope()`/`gate()` call silently resolves to the first | unconditional duplicates are independent; conditional ones mis-pair |
948
+ | exceptions in children functions/getters propagate uncaught | children-function and `Template()` exceptions are caught and logged; `props:`/`attrs:`/`data:` exceptions halt all rendering |
949
+ | children functions can't return `null`/`undefined` | allowed as the sole return value; forbidden inside arrays |
950
+ | `Bound()` runs with the DOM attached, after the template renders | runs before `Template()`, host detached and empty |
951
+ | `gate()` and `@Scope` are incompatible | fine for primitive-returning getters |
952
+ | `data: this.value` (non-function) in the granular-scopes example | throws `data is not a function` |
953
+ | `StoreAsync` data must be JSON-serialisable (no `Date`, `Map`, `Set`) | transport is structured clone: `Date`, `Map`, `Set` survive; class methods and functions do not |
954
+ | `.filter(Boolean)` is an anti-pattern for conditional rendering | works; strings and `null` in arrays are the real hazard |
955
+ | `SYNC_SCHEDULING=true` is the default for test runs | off unless set |
956
+ | "No compile step" | decorators and module resolution need TypeScript plus a bundler or runner |
957
+ | documents v8.0.16; see `docs/`, `examples/`, `src/` | package is 8.0.19 and ships none of those folders |
958
+ | Smart Tasks imports `./filter-bar` | not shown; supplied above |
959
+ | @Computed is backed by StoreSync and ApplyDiff | a gated scope + ObservableNode.Clone + ObservableNode.Apply; no store |