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/Node/vNode.types.d.ts +1 -1
- package/SYNTAX_PRIMER.md +677 -1426
- package/Store/Tree/observableScope.d.ts +15 -15
- package/Store/Tree/observableScope.js +23 -26
- package/Utils/decorators.d.ts +14 -11
- package/Utils/decorators.js +39 -22
- package/Utils/emitter.d.ts +1 -1
- package/Utils/emitter.js +33 -28
- package/index.d.ts +1 -1
- package/index.js +1 -1
- package/package.json +1 -1
package/SYNTAX_PRIMER.md
CHANGED
|
@@ -1,122 +1,82 @@
|
|
|
1
|
-
# j-templates Syntax Primer
|
|
1
|
+
# j-templates Syntax Primer
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Oct 3, 2026 · @Jay Landrum
|
|
4
4
|
|
|
5
|
-
|
|
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
|
-
|
|
9
|
+
Read Mental model, then Templates and State decorators before writing a component. Check the Traps checklist before shipping.
|
|
10
10
|
|
|
11
|
-
- **
|
|
12
|
-
- **
|
|
13
|
-
- **
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
28
|
+
// Wrong: Template() re-runs on every change to count
|
|
41
29
|
Template() {
|
|
42
|
-
const
|
|
30
|
+
const count = this.count;
|
|
43
31
|
return div({}, () => `Count: ${count}`);
|
|
44
32
|
}
|
|
45
33
|
|
|
46
|
-
//
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
---
|
|
40
|
+
The same applies to `this.Data` and to `@Scope`/`@Computed` getters: read them where they are used.
|
|
55
41
|
|
|
56
|
-
##
|
|
42
|
+
## Setup and entry points
|
|
57
43
|
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
**
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
**
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
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
|
-
|
|
63
|
+
**Element functions** (all verified present):
|
|
103
64
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
141
|
-
Component.Attach(document.
|
|
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
|
-
|
|
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
|
-
**
|
|
102
|
+
**Members:**
|
|
835
103
|
|
|
836
|
-
|
|
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
|
-
**
|
|
110
|
+
**Lifecycle (verified order):**
|
|
839
111
|
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
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
|
-
|
|
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
|
-
|
|
120
|
+
**Host element.** The name passed to `ToFunction` is the host's tag. `Template()` output renders inside it:
|
|
863
121
|
|
|
864
|
-
```
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
}
|
|
122
|
+
```html
|
|
123
|
+
<my-counter> <!-- host: style by element selector -->
|
|
124
|
+
<div>…</div> <!-- template root -->
|
|
125
|
+
</my-counter>
|
|
869
126
|
```
|
|
870
127
|
|
|
871
|
-
|
|
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
|
-
|
|
130
|
+
**Mounting and teardown:**
|
|
874
131
|
|
|
875
|
-
|
|
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
|
-
|
|
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
|
-
**
|
|
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
|
-
|
|
140
|
+
## Templates
|
|
882
141
|
|
|
883
|
-
###
|
|
142
|
+
### Element function signature
|
|
884
143
|
|
|
885
144
|
```typescript
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
163
|
+
### What a children function may return
|
|
895
164
|
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
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
|
-
|
|
901
|
-
onDataChange(newValue: DataType) { /* ... */ }
|
|
172
|
+
In arrays, wrap strings in `text(() => "…")` and remove empty entries (`.filter(Boolean)` works).
|
|
902
173
|
|
|
903
|
-
|
|
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
|
-
|
|
176
|
+
The framework iterates and calls the children function once per item, passing the item.
|
|
911
177
|
|
|
912
|
-
|
|
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
|
-
|
|
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
|
-
|
|
917
|
-
|
|
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
|
-
|
|
191
|
+
### `data:` on components
|
|
921
192
|
|
|
922
|
-
|
|
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
|
-
|
|
926
|
-
|
|
196
|
+
div({ data: () => this.tasks }, (task) => div({}, () => task.name)); // iterated
|
|
197
|
+
taskList({ data: () => ({ tasks: this.tasks }) }); // passed through
|
|
927
198
|
```
|
|
928
199
|
|
|
929
|
-
|
|
200
|
+
### Conditional rendering
|
|
930
201
|
|
|
931
|
-
|
|
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
|
-
|
|
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
|
-
|
|
209
|
+
// 3. fragment (no wrapper node; nothing left behind when falsy)
|
|
210
|
+
fragment({ data: () => this.loading }, () => div({}, () => "Loading"));
|
|
949
211
|
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
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
|
-
//
|
|
972
|
-
|
|
973
|
-
|
|
231
|
+
// Right
|
|
232
|
+
div({}, () => [
|
|
233
|
+
div({ data: () => this.isEmpty }, () => div({}, () => "No items")),
|
|
234
|
+
div({ data: () => this.todos }, (t) => …),
|
|
235
|
+
]);
|
|
974
236
|
```
|
|
975
237
|
|
|
976
|
-
|
|
238
|
+
### fragment
|
|
977
239
|
|
|
978
|
-
|
|
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
|
-
|
|
244
|
+
### Form inputs (two-way binding)
|
|
981
245
|
|
|
982
|
-
|
|
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
|
-
###
|
|
255
|
+
### Scope granularity
|
|
985
256
|
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
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
|
-
##
|
|
268
|
+
## State decorators
|
|
1001
269
|
|
|
1002
|
-
|
|
270
|
+
Decorators work on component classes only. Services use `ObservableScope`, `ObservableNode` and the stores directly.
|
|
1003
271
|
|
|
1004
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
287
|
+
### @Value
|
|
1013
288
|
|
|
1014
289
|
```typescript
|
|
1015
|
-
|
|
1016
|
-
|
|
290
|
+
@Value() count = 0;
|
|
291
|
+
@Value() list: number[] = [];
|
|
1017
292
|
```
|
|
1018
293
|
|
|
1019
|
-
|
|
294
|
+
No proxy. Only assignment notifies: `this.list.push(3)` does **not** update the view; `this.list = [...this.list, 3]` does.
|
|
1020
295
|
|
|
1021
|
-
|
|
296
|
+
### @State
|
|
1022
297
|
|
|
1023
298
|
```typescript
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
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
|
-
`
|
|
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
|
-
|
|
307
|
+
### @Scope
|
|
1037
308
|
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
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
|
-
|
|
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
|
-
|
|
318
|
+
### @Computed
|
|
1069
319
|
|
|
1070
|
-
|
|
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
|
-
|
|
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
|
-
|
|
335
|
+
### @ComputedAsync
|
|
1075
336
|
|
|
1076
337
|
```typescript
|
|
1077
|
-
|
|
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
|
-
|
|
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
|
-
|
|
348
|
+
### @Watch
|
|
1092
349
|
|
|
1093
350
|
```typescript
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
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
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
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
|
-
|
|
1108
|
-
gate(() => computeA(), "id-a");
|
|
1109
|
-
gate(() => computeB(), "id-b");
|
|
360
|
+
### @Inject and @Destroy
|
|
1110
361
|
|
|
1111
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
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
|
-
|
|
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
|
-
**
|
|
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
|
-
**
|
|
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
|
-
**
|
|
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
|
-
|
|
390
|
+
### How memoization matches calls
|
|
1159
391
|
|
|
1160
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1171
|
-
|
|
1172
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
425
|
+
## Async scopes
|
|
1178
426
|
|
|
1179
|
-
|
|
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
|
-
|
|
430
|
+
@Scope() get user() {
|
|
431
|
+
return scope(async () => (await fetch(`/api/user/${this.userId}`)).json());
|
|
432
|
+
}
|
|
1183
433
|
|
|
1184
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
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
|
-
- `
|
|
1199
|
-
- `
|
|
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
|
-
|
|
457
|
+
There is no built-in loading placeholder: render one yourself while the value is `null`.
|
|
1204
458
|
|
|
1205
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: {
|
|
476
|
+
interface ChildEvents { save: { text: string }; done: void; }
|
|
1234
477
|
|
|
1235
478
|
class Child extends Component<{}, {}, ChildEvents> {
|
|
1236
|
-
Template() {
|
|
479
|
+
Template() {
|
|
480
|
+
return button({ on: { click: () => this.Fire("save", { text: "x" }) } }, () => "Save");
|
|
481
|
+
}
|
|
1237
482
|
}
|
|
1238
483
|
|
|
1239
|
-
|
|
484
|
+
child({ on: { save: (p) => console.log(p.text), done: () => {} } });
|
|
1240
485
|
```
|
|
1241
486
|
|
|
1242
|
-
Component events
|
|
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
|
|
489
|
+
### Template callbacks
|
|
1245
490
|
|
|
1246
|
-
|
|
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
|
-
|
|
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
|
-
|
|
501
|
+
list({ data: () => ({ items: users }) }, { render: (u) => div({}, () => u.name) });
|
|
1256
502
|
```
|
|
1257
503
|
|
|
1258
|
-
|
|
504
|
+
Use a dedicated child component instead when each item needs its own state, events, lifecycle or injected services.
|
|
1259
505
|
|
|
1260
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
510
|
+
### Injector
|
|
1273
511
|
|
|
1274
|
-
|
|
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
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
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
|
-
|
|
526
|
+
## Store
|
|
1295
527
|
|
|
1296
|
-
|
|
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
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
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
|
-
###
|
|
538
|
+
### API
|
|
1310
539
|
|
|
1311
540
|
```typescript
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
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
|
-
|
|
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
|
-
|
|
552
|
+
### keyFunc and flattening
|
|
1343
553
|
|
|
1344
|
-
|
|
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
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
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
|
-
|
|
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
|
-
|
|
568
|
+
### StoreAsync and its worker
|
|
1362
569
|
|
|
1363
|
-
|
|
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
|
|
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
|
-
|
|
1371
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1379
|
-
const store = new StoreAsync(worker, keyFunc); // pass the same keyFunc for local alias resolution
|
|
1380
|
-
```
|
|
588
|
+
### Projections
|
|
1381
589
|
|
|
1382
|
-
|
|
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
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
)
|
|
1389
|
-
|
|
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
|
-
|
|
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
|
-
|
|
609
|
+
## ObservableScope and ObservableNode
|
|
1395
610
|
|
|
1396
|
-
|
|
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
|
-
|
|
1402
|
-
|
|
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
|
-
|
|
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
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
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
|
-
|
|
642
|
+
A service deriving from a store:
|
|
1417
643
|
|
|
1418
644
|
```typescript
|
|
1419
|
-
class
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
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
|
-
###
|
|
654
|
+
### ObservableNode
|
|
1430
655
|
|
|
1431
656
|
```typescript
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1448
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1458
|
-
store.Get<number>(`${PROJECTION_PREFIX}activeCount`); // "$projection_activeCount"
|
|
1459
|
-
```
|
|
680
|
+
### Scheduling
|
|
1460
681
|
|
|
1461
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1482
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
701
|
+
### Testing
|
|
702
|
+
|
|
703
|
+
Verification used tsx with jsdom; Vitest with jsdom should behave the same. Requirements:
|
|
1490
704
|
|
|
1491
|
-
|
|
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
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
const
|
|
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
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
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
|
-
//
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
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
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
|
1637
|
-
|
|
1638
|
-
|
|
|
1639
|
-
|
|
|
1640
|
-
|
|
|
1641
|
-
|
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
|
1648
|
-
|
|
|
1649
|
-
|
|
|
1650
|
-
|
|
|
1651
|
-
|
|
|
1652
|
-
|
|
|
1653
|
-
|
|
|
1654
|
-
|
|
|
1655
|
-
|
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
|
1662
|
-
|
|
1663
|
-
|
|
|
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 |
|