j-templates 7.0.97 → 7.0.99
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/SYNTAX_PRIMER.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
# j-templates Syntax Primer —
|
|
1
|
+
# j-templates Syntax Primer — v4
|
|
2
2
|
|
|
3
|
-
Complete reference for the **j-templates** framework syntax. This documents **j-templates v7.0.
|
|
3
|
+
Complete reference for the **j-templates** framework syntax. This documents **j-templates v7.0.98** (see `package.json`). For pattern-oriented guides, see `docs/patterns/`; for step-by-step tutorials, see `docs/tutorials/`.
|
|
4
4
|
|
|
5
5
|
> **Core concepts:** Components define UI via `Template()`. State decorators (`@Value`, `@State`, `@Computed`) enable reactivity. DOM functions (`div()`, `button()`) create virtual nodes. No compile step, minimal dependencies.
|
|
6
6
|
|
|
@@ -30,6 +30,27 @@ This one fact drives every design decision in this framework:
|
|
|
30
30
|
|
|
31
31
|
**The core loop:** `@Value` → `Template()` → `Component.ToFunction` → `Component.Attach`. Everything else is a refinement.
|
|
32
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.
|
|
38
|
+
|
|
39
|
+
```typescript
|
|
40
|
+
// ❌ React instinct — do NOT do this in j-templates
|
|
41
|
+
Template() {
|
|
42
|
+
const { count } = this.state; // hoisted read = full-component rebuild
|
|
43
|
+
return div({}, () => `Count: ${count}`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ✅ j-templates idiom — read inside the function that uses it
|
|
47
|
+
Template() {
|
|
48
|
+
return div({}, () => `Count: ${this.count}`);
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
**If you find yourself writing `const x = this.something;` before a `return div(...)` in `Template()` — stop. That line is the bug.** This applies equally to `@Scope`/`@Computed` getters and to `this.Data` in components. This rule is referenced throughout the doc as **the Destructuring Trap**.
|
|
53
|
+
|
|
33
54
|
---
|
|
34
55
|
|
|
35
56
|
## Cheat Sheet
|
|
@@ -64,7 +85,8 @@ Component.Register("my-comp", MyComponent); // Web Component
|
|
|
64
85
|
|
|
65
86
|
**The 3 conditional patterns**
|
|
66
87
|
```typescript
|
|
67
|
-
// 1. Nested children function — isolated scope, use when an "else" branch is needed
|
|
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.
|
|
68
90
|
div({}, () => this.isLoading ? div({}, () => "Loading") : text(() => ""));
|
|
69
91
|
|
|
70
92
|
// 2. data: boolean — falsy renders nothing, truthy renders child (no "else")
|
|
@@ -90,7 +112,7 @@ fragment({ data: () => this.isLoading }, () => div({}, () => "Loading"));
|
|
|
90
112
|
**The 3 golden rules**
|
|
91
113
|
1. Pass arrays as `data:` — the framework iterates. Don't call `.map()` inside children.
|
|
92
114
|
2. Wrap children in functions for separate reactive scopes.
|
|
93
|
-
3.
|
|
115
|
+
3. Avoid the Destructuring Trap — read scopes at the point of use (inside children functions / `data:` bindings), never at the top of `Template()`.
|
|
94
116
|
|
|
95
117
|
---
|
|
96
118
|
|
|
@@ -331,7 +353,7 @@ npm install j-templates
|
|
|
331
353
|
|
|
332
354
|
**File naming:** Components: kebab-case (`todo-list.ts`). Services: kebab-case + `-service` suffix. Exports: lowercase matching filename.
|
|
333
355
|
|
|
334
|
-
**Public entry points
|
|
356
|
+
**Public entry points:**
|
|
335
357
|
|
|
336
358
|
| Import path | Exports |
|
|
337
359
|
|-------------|---------|
|
|
@@ -340,7 +362,7 @@ npm install j-templates
|
|
|
340
362
|
| `j-templates/Utils` | `Value`, `State`, `Computed`, `ComputedAsync`, `Scope`, `Watch`, `Inject`, `Destroy`, `Bound`, `Animation`, `AnimationType`, `IDestroyable` |
|
|
341
363
|
| `j-templates/Store` | `StoreSync`, `StoreAsync`, `ObservableScope`, `ObservableNode` |
|
|
342
364
|
|
|
343
|
-
> ⚠️ **`Injector` is NOT exported from any public entry point.** It
|
|
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.
|
|
344
366
|
|
|
345
367
|
### DOM Functions
|
|
346
368
|
|
|
@@ -368,7 +390,7 @@ Text node: `text`
|
|
|
368
390
|
|
|
369
391
|
Fragment: `fragment` (no DOM node — children reconcile into the real ancestor)
|
|
370
392
|
|
|
371
|
-
No SVG
|
|
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.
|
|
372
394
|
|
|
373
395
|
---
|
|
374
396
|
|
|
@@ -570,12 +592,14 @@ Template() {
|
|
|
570
592
|
| `true` | Wraps as `[true]`, renders child once |
|
|
571
593
|
| `false` / `null` / `undefined` | Returns `[]`, no children rendered |
|
|
572
594
|
|
|
573
|
-
> ⚠️ **Falsy edge case:** the
|
|
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]`.
|
|
574
596
|
|
|
575
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.
|
|
576
598
|
|
|
577
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.
|
|
578
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
|
+
|
|
579
603
|
#### Components
|
|
580
604
|
|
|
581
605
|
Components receive the raw return value as `this.Data` — no iteration, no wrapping, no `false`/`null` short-circuit.
|
|
@@ -687,8 +711,8 @@ Template() {
|
|
|
687
711
|
}
|
|
688
712
|
```
|
|
689
713
|
|
|
690
|
-
7. **
|
|
691
|
-
8. **Same applies to `this.Data` in components
|
|
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.
|
|
692
716
|
|
|
693
717
|
```typescript
|
|
694
718
|
// Anti-pattern — this.Data read at top of Template()
|
|
@@ -760,25 +784,13 @@ div({}, () => [span({ data: () => this.value1 }, (v) => v), span({ data: () => t
|
|
|
760
784
|
|
|
761
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.
|
|
762
786
|
|
|
763
|
-
When a reactive scope emits, the
|
|
764
|
-
|
|
765
|
-
1. Re-runs the children function that read the scope, producing a new vNode tree.
|
|
766
|
-
2. Patches the DOM from the old vNode tree to the new vNode tree.
|
|
767
|
-
|
|
768
|
-
The framework does **not** diff two vNode trees against each other. There is no vNode-to-vNode reconciliation — no keyed diffing, no positional matching of old vNodes to new vNodes. The "surgical" aspect comes from scoping — only the children functions that subscribed to the changed scope re-run. Everything else is untouched.
|
|
769
|
-
|
|
770
|
-
**What actually happens at the DOM level:** when a children function re-runs, it produces a fresh array of vNodes. Each vNode maps to a DOM node, and `reconcileChildren` (in `src/DOM/domNodeConfig.ts`) reconciles the element's real DOM children against that list. Reuse is purely by **reference identity**:
|
|
771
|
-
- A vNode that is the *same object* as before (which is what per-item `MappedScope` reuse produces) keeps its existing DOM node — no rebuild.
|
|
772
|
-
- A *new* vNode object creates a *new* DOM node (`createNode`); the old node is removed.
|
|
773
|
-
- Text nodes are special-cased: if the incoming child is a string and the current child is a text node, the text node is **reused and its value updated** (`setText`) rather than replaced.
|
|
774
|
-
|
|
775
|
-
So "no vNode diffing" is precise: there is no keyed reconciliation and no vNode-to-vNode matching. But there *is* a cheap DOM-node reconciliation that reuses nodes by `===` reference. This is exactly why `@Computed` (same reference across updates) avoids DOM churn while `@Scope` (new reference) forces node recreation, and why per-item identity reuse is the framework's only mechanism for stable DOM.
|
|
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.
|
|
776
788
|
|
|
777
789
|
This means:
|
|
778
790
|
- A scope read at the top of `Template()` rebuilds the entire component vNode tree.
|
|
779
791
|
- A scope read inside a children function rebuilds only that subtree.
|
|
780
792
|
- A scope read inside a `data:` binding rebuilds only that iteration's vNode.
|
|
781
|
-
- Per-item scopes are reused when the same data object reference reappears (identity-based, not key-based).
|
|
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.
|
|
782
794
|
|
|
783
795
|
The optimization goal is minimizing **how often** children functions re-run through fine-grained scopes, not making the re-run itself cheap.
|
|
784
796
|
|
|
@@ -860,11 +872,11 @@ Cached, preserves object identity via `ApplyDiff`. No default value parameter. F
|
|
|
860
872
|
|
|
861
873
|
`@Computed` uses `ApplyDiff` to merge changes into the existing object reference. Downstream consumers using `===` comparison (like `gate()` or `data:` bindings) only see a change when actual structure differs, not when the getter re-runs. Use `@Computed` when returning composite objects consumed by multiple UI regions. Prefer per-region `@Scope` when you need granular updates.
|
|
862
874
|
|
|
863
|
-
> **`@Computed` returns a copy, not the source reference.** The getter's result is copied into a new reactive object whose identity is preserved across updates
|
|
875
|
+
> **`@Computed` returns a copy, not the source reference.** The getter's result is copied into a new reactive object whose identity is preserved across updates. It is a distinct object from whatever the getter read. Consequently, changes to the *source* object do not fire on the copy — the copy only re-evaluates when its own source dependencies change. If you need downstream consumers to observe mutations to an existing reactive object directly, use `@Scope` (which passes the existing reference through) instead.
|
|
864
876
|
|
|
865
|
-
> **Recompute timing.** `@Computed`'s `StoreSync` is created lazily on first read; after that it recomputes eagerly on emit (pulling from the source)
|
|
877
|
+
> **Recompute timing.** `@Computed`'s `StoreSync` backend is created lazily on first read; after that it recomputes eagerly on emit (pulling from the source). This contrasts with `@Scope`, which re-evaluates lazily on the next read.
|
|
866
878
|
|
|
867
|
-
**Dependency tracking note:** `@Computed`
|
|
879
|
+
**Dependency tracking note:** `@Computed` registers dependencies based on what properties the getter accesses through the proxy. If the getter returns `this.tasks` without iterating or reading individual item properties, per-item mutations (e.g., `task.completed = true`) won't trigger re-evaluation. The getter must touch every reactive property it intends to track — `.filter()`, `.map()`, `.reduce()`, and manual property reads all register deps. Returning the array reference alone only tracks array-level mutations (push, splice, reassignment).
|
|
868
880
|
|
|
869
881
|
**When NOT to use:** for cheap ops or array filter/sort of existing refs — use `@Scope()` or a plain getter (identity already preserved, less overhead).
|
|
870
882
|
|
|
@@ -895,7 +907,7 @@ syncFromParent(newFilter: FilterType): void {
|
|
|
895
907
|
}
|
|
896
908
|
```
|
|
897
909
|
|
|
898
|
-
Fires immediately with initial value when `Bound()` runs, then on each change. Subscription auto-cleaned on `Destroy()`. Uses greedy (batched) scope (`ObservableScope.
|
|
910
|
+
Fires immediately with initial value when `Bound()` runs, then on each change. Subscription auto-cleaned on `Destroy()`. Uses a greedy (batched) scope (`ObservableScope.Gated`) — multiple synchronous changes to the watched value within the same tick are debounced into a single callback invocation, firing once with the latest value.
|
|
899
911
|
|
|
900
912
|
**When NOT to use:** for values you only read in `Template()` — reading a scope there is already reactive; `@Watch` is for side effects (syncing state, logging, triggering external calls).
|
|
901
913
|
|
|
@@ -998,7 +1010,7 @@ Need derived data?
|
|
|
998
1010
|
- Each region independent -> `@Scope()` per region
|
|
999
1011
|
|
|
1000
1012
|
Where to read a scope in Template()?
|
|
1001
|
-
-
|
|
1013
|
+
- Avoid the Destructuring Trap (see Mental Model): don't hoist reads to the top of `Template()`.
|
|
1002
1014
|
- Inside children function -> subscribes only that subtree (preferred)
|
|
1003
1015
|
- Inside `data:` binding -> subscribes only that iteration (preferred)
|
|
1004
1016
|
|
|
@@ -1011,9 +1023,9 @@ Need conditional rendering?
|
|
|
1011
1023
|
|
|
1012
1024
|
## Inline Computed Scopes: scope(), gate(), peek(), mapped()
|
|
1013
1025
|
|
|
1014
|
-
Four functions for creating memoized computed scopes inline within a watch context (template functions, `@Scope` getters, etc.). All accept `() => T | Promise<T>` — async callbacks are resolved and the resolved value is emitted.
|
|
1026
|
+
Four functions for creating memoized computed scopes inline within a watch context (template functions, `@Scope` getters, etc.). All accept `() => T | Promise<T>` — async callbacks are resolved and the resolved value is emitted.
|
|
1015
1027
|
|
|
1016
|
-
**
|
|
1028
|
+
**Watch context** includes children functions, `data:`/`props:`/`attrs:` functions, `@Scope`/`@Computed`/`@ComputedAsync` getters, and `@Watch` callbacks. It does **not** include `on:` event handlers, which run outside reactive evaluation. Calling `scope()`/`gate()`/`peek()`/`mapped()` outside a watch context throws (e.g. `scope() must be called within a watch context`).
|
|
1017
1029
|
|
|
1018
1030
|
### scope() — Full Reactivity
|
|
1019
1031
|
|
|
@@ -1122,7 +1134,7 @@ const name = peek(() => this.Data.name, "name");
|
|
|
1122
1134
|
|
|
1123
1135
|
### mapped() — Per-Item Scopes (Advanced)
|
|
1124
1136
|
|
|
1125
|
-
`mapped(data, callback, onUpdated?, onDestroyed?)` creates a per-item scope for a single data value. This is the mechanism `data:` uses internally. Only needed for advanced manual per-item scoping.
|
|
1137
|
+
`mapped(data, callback, onUpdated?, onDestroyed?)` creates a per-item scope for a single data value. This is the mechanism `data:` uses internally — array iteration is effectively `array.map(item => mapped(item, callback))`, one `mapped()` call per item, keyed by object identity. Only needed for advanced manual per-item scoping.
|
|
1126
1138
|
|
|
1127
1139
|
```typescript
|
|
1128
1140
|
import { mapped } from "j-templates";
|
|
@@ -1130,7 +1142,7 @@ import { mapped } from "j-templates";
|
|
|
1130
1142
|
mapped(data, (d) => /* ... */, (lastValue, scope) => /* onUpdated */, (lastValue) => /* onDestroyed */);
|
|
1131
1143
|
```
|
|
1132
1144
|
|
|
1133
|
-
Signature
|
|
1145
|
+
Signature:
|
|
1134
1146
|
|
|
1135
1147
|
```typescript
|
|
1136
1148
|
function mapped<D, T>(
|
|
@@ -1287,7 +1299,7 @@ Both stores share the same API and flattening model, but differ fundamentally in
|
|
|
1287
1299
|
|
|
1288
1300
|
**StoreSync** computes diffs synchronously on the main thread. Writes are immediate and consistent — a value written is readable in the same tick. It has no worker overhead, no serialisation constraints, and no `Destroy()` requirement. Use StoreSync for the vast majority of application state: user data, UI state, app config, form data, and any dataset where diff computation is not a bottleneck. `@Computed` uses `StoreSync` internally.
|
|
1289
1301
|
|
|
1290
|
-
**StoreAsync** offloads all diff computation to a dedicated Web Worker via a serialised message queue
|
|
1302
|
+
**StoreAsync** offloads all diff computation to a dedicated Web Worker via a serialised message queue, computing minimal diffs off the main thread so large dataset operations don't block rendering or input. Use StoreAsync when diffing genuinely large or deeply nested datasets — message feeds, large tables, real-time data. `@ComputedAsync` uses `StoreAsync` internally.
|
|
1291
1303
|
|
|
1292
1304
|
**If you are unsure which to use, start with StoreSync.** StoreAsync introduces meaningful constraints (see below) that are only worth accepting when the dataset size justifies off-thread diffing.
|
|
1293
1305
|
|
|
@@ -1295,7 +1307,7 @@ Both stores share the same API and flattening model, but differ fundamentally in
|
|
|
1295
1307
|
|--------|-----------|------------|
|
|
1296
1308
|
| Diff execution | Main thread, synchronous | Web Worker, asynchronous |
|
|
1297
1309
|
| Write consistency | Immediate — readable same tick | Eventual — must `await` before reading |
|
|
1298
|
-
| `keyFunc` constraint | None — can close over outer scope | **Must be self-contained** — serialised
|
|
1310
|
+
| `keyFunc` constraint | None — can close over outer scope | **Must be self-contained** — serialised and executed in worker |
|
|
1299
1311
|
| Data constraint | Any JS value | **JSON-serialisable only** — no class instances, methods, `Date`, `Map`, `Set`, circular refs |
|
|
1300
1312
|
| `Destroy()` required | No | Yes — terminates worker and queue |
|
|
1301
1313
|
| Best for | Most app state | Large / real-time datasets |
|
|
@@ -1303,11 +1315,11 @@ Both stores share the same API and flattening model, but differ fundamentally in
|
|
|
1303
1315
|
|
|
1304
1316
|
### StoreAsync Constraints
|
|
1305
1317
|
|
|
1306
|
-
StoreAsync's worker is bootstrapped by serialising `keyFunc` and the diff engine
|
|
1318
|
+
StoreAsync's worker is bootstrapped by serialising `keyFunc` and the diff engine and executing them in a worker context. This has two hard constraints:
|
|
1307
1319
|
|
|
1308
1320
|
**1. `keyFunc` must be self-contained — no closed-over variables.**
|
|
1309
1321
|
|
|
1310
|
-
The function is serialised
|
|
1322
|
+
The function is serialised and evaluated in the worker context. Any variable from the outer scope will be undefined inside the worker.
|
|
1311
1323
|
|
|
1312
1324
|
```typescript
|
|
1313
1325
|
// ❌ Breaks at runtime — prefix is not accessible in the worker
|
|
@@ -1417,12 +1429,15 @@ If an object has no `id` property (or `keyFunc` returns `undefined`), it is stor
|
|
|
1417
1429
|
|
|
1418
1430
|
```typescript
|
|
1419
1431
|
namespace ObservableScope {
|
|
1420
|
-
Create<T>(valueFunction: { (): T | Promise<T> }
|
|
1432
|
+
Create<T>(valueFunction: { (): T | Promise<T> }): IObservableScope<T>; // Non-greedy scope
|
|
1433
|
+
Gated<T>(valueFunction: { (): T | Promise<T> }): IObservableScope<T>; // Greedy scope (batches via microtask)
|
|
1434
|
+
Basic<T>(valueFunction: { (): T }): IBasicObservableScope<T>; // Direct-value scope; no dep tracking, no cache — Update() to emit
|
|
1421
1435
|
Value<T>(scope: IObservableScope<T>): T; // Get value + register dependency
|
|
1422
1436
|
Peek<T>(scope: IObservableScope<T>): T; // Get value without registering dependency
|
|
1423
1437
|
Touch<T>(scope: IObservableScope<T>): void; // Register as dependency without reading value
|
|
1424
1438
|
Watch<T>(scope: IObservableScope<T>, callback: EmitterCallback<[IObservableScope<T>]>): void;
|
|
1425
1439
|
Unwatch<T>(scope: IObservableScope<T>, callback: EmitterCallback<[IObservableScope<T>]>): void;
|
|
1440
|
+
OnUpdated<T>(scope: IObservableScope<T>, callback: (lastValue: T, scope: IObservableScope<T>) => void): void;
|
|
1426
1441
|
OnDestroyed(scope: IObservableScope<unknown>, callback: EmitterCallback): void;
|
|
1427
1442
|
Update(scope: IObservableScope<any>): void; // Mark dirty, triggers recomputation
|
|
1428
1443
|
Register(emitter: Emitter): void;
|
|
@@ -1433,6 +1448,12 @@ namespace ObservableScope {
|
|
|
1433
1448
|
|
|
1434
1449
|
**Async limitation:** Dependencies are only captured synchronously. Read all reactive values before the first `await`. Reactive reads after `await` are not tracked.
|
|
1435
1450
|
|
|
1451
|
+
**Static scope edge case:** `Create` returns a *static* scope when its valueFunction reads no
|
|
1452
|
+
reactive dependencies (no `@Value`/`@State`/other scope reads). Static scopes do **not** emit when
|
|
1453
|
+
passed to `Update` — reactivity silently breaks with no error. If you need a manually-updatable
|
|
1454
|
+
scope whose value doesn't derive from reactive state, use `Basic` instead, which always emits on
|
|
1455
|
+
`Update`.
|
|
1456
|
+
|
|
1436
1457
|
### Service Patterns
|
|
1437
1458
|
|
|
1438
1459
|
```typescript
|
|
@@ -1448,9 +1469,11 @@ class DataService implements IDestroyable {
|
|
|
1448
1469
|
}
|
|
1449
1470
|
|
|
1450
1471
|
// Reactive counter via shared service
|
|
1472
|
+
// NOTE: _count is a plain field (not reactive), so Create() would yield a static scope that
|
|
1473
|
+
// ignores Update(). Use Basic() for manually-updatable scopes.
|
|
1451
1474
|
class CounterService implements IDestroyable {
|
|
1452
1475
|
private _count = 0;
|
|
1453
|
-
private countScope = ObservableScope.
|
|
1476
|
+
private countScope = ObservableScope.Basic(() => this._count);
|
|
1454
1477
|
get count() { return ObservableScope.Value(this.countScope); }
|
|
1455
1478
|
increment() { this._count++; ObservableScope.Update(this.countScope); }
|
|
1456
1479
|
Destroy(): void { ObservableScope.Destroy(this.countScope); }
|
|
@@ -1465,16 +1488,28 @@ class CounterService implements IDestroyable {
|
|
|
1465
1488
|
namespace ObservableNode {
|
|
1466
1489
|
Create<T>(value: T): T; // Wrap in reactive proxy
|
|
1467
1490
|
Unwrap<T>(value: T): T; // Get raw value from proxy
|
|
1468
|
-
|
|
1469
|
-
|
|
1491
|
+
Clone<T>(value: T): T; // Strip proxies into plain data (mutates plain objects in place)
|
|
1492
|
+
Update(value: unknown, prop?: string | number): void; // Manually trigger change on a node/property
|
|
1493
|
+
Apply(rootNode: any, value: any): void; // Merge a full value in-place, preserving identity (public)
|
|
1494
|
+
ApplyDiff(rootNode: any, diffResult: JsonDiffResult): void; // Apply diff in-place (internal — used by Store/@Computed)
|
|
1470
1495
|
CreateFactory(alias?: (value: any) => any | undefined): <T>(value: T) => T; // Factory with aliasing
|
|
1471
1496
|
}
|
|
1472
1497
|
```
|
|
1473
1498
|
|
|
1499
|
+
**`Apply` vs `ApplyDiff`:** `Apply(rootNode, value)` merges a full replacement value into an observable node in-place, preserving the node's reference (so `===` checks and DOM reuse stay stable). Use this when you want to update an observable node in place — assigning a property directly on an observable node does **not** generate a diff. `ApplyDiff` is internal-only (used by `StoreSync`/`StoreAsync`/`@Computed`) — ignore it.
|
|
1500
|
+
|
|
1474
1501
|
**Array operations on ObservableNode proxies:** `push`, `pop`, `shift`, `unshift`, `splice`, `sort`, `reverse` — all trigger reactive updates.
|
|
1475
1502
|
|
|
1476
1503
|
---
|
|
1477
1504
|
|
|
1505
|
+
## Testing
|
|
1506
|
+
|
|
1507
|
+
The standard setup is **Vitest + JSDOM**, which provides browser-like globals (`document`, DOM node classes, etc.) so components can be attached and inspected without a real browser.
|
|
1508
|
+
|
|
1509
|
+
Set **`SYNC_SCHEDULING=true`** to make reactive updates apply synchronously — a state mutation is reflected in the DOM immediately, with no `flush()`/`await` step needed before asserting. This is the default for standard test runs. Omit it only when a test specifically needs to verify real async or batching timing (e.g. confirming `@Watch` debounces multiple synchronous writes into one call, or that an async scope resolves correctly across a microtask boundary) — with `SYNC_SCHEDULING` on, that behavior collapses and can't be observed.
|
|
1510
|
+
|
|
1511
|
+
---
|
|
1512
|
+
|
|
1478
1513
|
## Traps & Gotchas
|
|
1479
1514
|
|
|
1480
1515
|
These are the subtle behaviors that cause the most bugs. Read this before writing components.
|
|
@@ -1485,19 +1520,22 @@ These are the subtle behaviors that cause the most bugs. Read this before writin
|
|
|
1485
1520
|
4. **`data:` boolean renders the element, not nothing.** A falsy `data:` value removes the element's *children*, but the element itself stays in the DOM. A styled container (padding/background/border) will still show as an empty box. To remove an element entirely, use a nested children function or `gate()`.
|
|
1486
1521
|
5. **`@ComputedAsync` is a *sync* getter.** The "Async" refers to the `StoreAsync` backend, not the getter signature. For real async, use `@Scope() + scope(async)` or `ObservableScope.Create(async)`.
|
|
1487
1522
|
6. **`gate()` is incompatible with `@Scope`.** `@Scope` returns a new reference every update, so `gate()`'s `===` always sees a change. Use `@Computed()` for reference stability.
|
|
1488
|
-
7. **Per-item scope reuse is identity-based, not key-based.** The same data object reference reuses its scope; a new reference creates a new scope.
|
|
1489
|
-
8. **`@Watch` fires immediately on `Bound()`** with the initial value — not just on changes. Missing `super.Bound()` means `@Watch` never fires.
|
|
1523
|
+
7. **Per-item scope reuse is identity-based, not key-based.** The same data object reference reuses its scope; a new reference creates a new scope. Reordering an array of existing references moves the DOM node and scope to the new position rather than recreating them.
|
|
1524
|
+
8. **`@Watch` fires immediately on `Bound()`** with the initial value — not just on changes. Missing `super.Bound()` means `@Watch` never fires. Multiple synchronous changes in the same tick are debounced into a single callback.
|
|
1490
1525
|
9. **`@State` arrays support direct mutation** (`push`, `splice`, item property writes) because they're proxies. Plain arrays require reassignment.
|
|
1491
1526
|
10. **`@Computed` only tracks what the getter touches.** Returning `this.tasks` without reading item properties won't re-trigger on per-item mutations. Touch every property you track.
|
|
1492
|
-
11. **`scope()`/`gate()`/`peek()`/`mapped()` throw outside a watch context.** They must be called inside a
|
|
1527
|
+
11. **`scope()`/`gate()`/`peek()`/`mapped()` throw outside a watch context.** They must be called inside a children function, `data:`/`props:`/`attrs:` function, `@Scope`/`@Computed`/`@ComputedAsync` getter, or `@Watch` callback — not inside `on:` event handlers.
|
|
1493
1528
|
12. **`Injector` is not publicly exported.** Use `@Inject` and `this.Injector` on components.
|
|
1494
1529
|
13. **StoreAsync data must be JSON-serialisable** and `keyFunc` must be self-contained (no closed-over variables). Always `await` StoreAsync writes before reading.
|
|
1495
1530
|
14. **Two-way binding needs reactive props** (`props: () => ({ value })`). A static `props: { value }` object causes input focus loss.
|
|
1496
|
-
15. **Reading a scope at the top of `Template()` subscribes the whole component
|
|
1531
|
+
15. **The Destructuring Trap.** Reading a scope or `this.Data` at the top of `Template()` subscribes the whole component — the React instinct to hoist state reads is backwards here. Read scopes inside children functions or `data:` bindings for fine-grained updates. See Mental Model.
|
|
1497
1532
|
16. **`scope()`/`gate()`/`peek()` ID collisions are per-scope.** Multiple calls to the same helper in one watch context without IDs silently resolve to the first scope. Provide distinct IDs when calling the same helper more than once in a single ObservableScope definition.
|
|
1498
1533
|
17. **`IsAsync` only detects the `async` keyword.** A function that *returns* a Promise but is not declared `async` (e.g. `() => fetch(...)`) is treated as synchronous — the scope stores the Promise as its value instead of resolving it. Always write `async () => ...` for async scopes.
|
|
1499
1534
|
18. **`fragment()` has no DOM node.** It cannot be attached directly (wrap it in a real element) and a falsy `data:` value renders *nothing* — no empty wrapper box. Its children reconcile into the nearest real ancestor.
|
|
1500
|
-
19. **`@State`/`ObservableNode` only deep-tracks plain objects and arrays.**
|
|
1535
|
+
19. **`@State`/`ObservableNode` only deep-tracks plain objects and arrays.** Class instances, `Date`, `Map`, `Set`, and other non-plain objects are treated as opaque primitives — nested mutations won't be tracked. Use plain objects/arrays for reactive state.
|
|
1536
|
+
20. **`ObservableScope.Create` with no reactive deps yields a static scope.** If the valueFunction reads no `@Value`/`@State`/other scope, `Create` returns a static scope that silently ignores `ObservableScope.Update` — reactivity breaks with no error. Use `ObservableScope.Basic` for manually-updatable scopes whose value doesn't derive from reactive state.
|
|
1537
|
+
21. **No error boundaries.** An exception thrown in a children function, `props:`/`attrs:` function, or getter propagates uncaught — there's no per-subtree isolation. Guard risky logic with your own `try`/`catch`.
|
|
1538
|
+
22. **Children functions can't return `null`/`undefined`.** For conditional presence, use the element's own `data:` (falsy → no children) or `fragment({ data: () => condition }, ...)` for no wrapper. A ternary's "else" branch must return a vNode (`text(() => "")`), not `null`.
|
|
1501
1539
|
|
|
1502
1540
|
---
|
|
1503
1541
|
|
|
@@ -1512,7 +1550,7 @@ These are the subtle behaviors that cause the most bugs. Read this before writin
|
|
|
1512
1550
|
| Single `@Scope` for multiple independent UI regions | One `@Scope` per region |
|
|
1513
1551
|
| `gate()` wrapping `@Scope` getter | Use `@Computed` or remove `gate()` |
|
|
1514
1552
|
| `data:` binding inside helper function called from `Template()` | Inline in `Template()` with `@Scope` data source |
|
|
1515
|
-
|
|
|
1553
|
+
| Destructuring/hoisting a scope read at top of `Template()` | Read inside children function or `data:` binding — see Mental Model |
|
|
1516
1554
|
| `this.Data` read at top of `Template()` | Read inside children function, `props:` function, or `data:` binding |
|
|
1517
1555
|
| Render callback for items needing state/events | Use dedicated component |
|
|
1518
1556
|
| Child `@Value` not synced with parent `Data` | Use `@Watch((self) => self.Data.prop)` |
|
|
@@ -1522,6 +1560,7 @@ These are the subtle behaviors that cause the most bugs. Read this before writin
|
|
|
1522
1560
|
| Assuming framework diffs vNode trees | It doesn't — optimize by minimizing scope emission frequency |
|
|
1523
1561
|
| `@State()` on class instances / `Date` / `Map` / `Set` | Use plain objects/arrays — non-plain objects are treated as primitives (no deep reactivity) |
|
|
1524
1562
|
| Promise-returning arrow without `async` keyword in an async scope | Use `async () => ...` — `IsAsync` only detects `async` functions |
|
|
1563
|
+
| Children function returning `null`/`undefined` | Return `text(() => "")`, or move the condition into `data:`/`fragment()` |
|
|
1525
1564
|
|
|
1526
1565
|
---
|
|
1527
1566
|
|
|
@@ -1535,7 +1574,7 @@ These are the subtle behaviors that cause the most bugs. Read this before writin
|
|
|
1535
1574
|
| `@Watch` never fires | Missing `super.Bound()` | Call in `Bound()` method |
|
|
1536
1575
|
| Memory leak | Missing cleanup | `@Destroy()` + `super.Destroy()` |
|
|
1537
1576
|
| Input loses focus | Static `props` object | Use `props: () => ({ value })` |
|
|
1538
|
-
| Entire Template re-runs on small change |
|
|
1577
|
+
| Entire Template re-runs on small change | Destructuring Trap — scope read at top of `Template()` | Read scope inside children function or `data:` binding |
|
|
1539
1578
|
| Entire section re-renders on small change | Single `@Scope` feeds multiple regions | Split into per-region `@Scope` getters |
|
|
1540
1579
|
| `gate()` doesn't prevent re-renders | Wrapping `@Scope` getter (always new ref) | Read `@Scope` directly or use `@Computed` |
|
|
1541
1580
|
| `data:` binding re-renders every time | Element created in helper, not `Template()` | Inline element in `Template()` |
|
|
@@ -1543,19 +1582,7 @@ These are the subtle behaviors that cause the most bugs. Read this before writin
|
|
|
1543
1582
|
| Conditional re-renders when sibling list updates | Condition and list share same children function scope | Isolate condition into nested children function, `data:` boolean, or `gate()` |
|
|
1544
1583
|
| Expensive Template re-runs on every change | Large vNode subtree subscribed to frequently-changing scope | Split into smaller scopes to reduce rebuild surface |
|
|
1545
1584
|
| Async scope resolves to a Promise instead of a value | Callback not declared `async` | Use `async () => ...` so `IsAsync` detects it |
|
|
1546
|
-
|
|
1547
|
-
### Internal Mechanics
|
|
1548
|
-
|
|
1549
|
-
- **No vNode Diffing:** The framework does not diff vNode trees. When a scope emits, the children function re-runs, producing new vNodes. The DOM is patched from old to new. Per-item scopes are reused when the same data object reference reappears (identity-based, not key-based).
|
|
1550
|
-
- **DOM-node reconciliation by reference:** `reconcileChildren` reuses a DOM node only when the vNode object reference is identical (per-item reuse); a new vNode creates a new DOM node. Text nodes are reused and updated in place (`setText`) rather than replaced. There is no keyed or positional vNode matching.
|
|
1551
|
-
- **Fragments reconcile into the ancestor:** `fragment()` has no DOM node; its children are patched into the nearest real ancestor via `reconcileRange` (a range-based sibling reconciliation). Nested fragments flatten into that same ancestor.
|
|
1552
|
-
- **Centralized scheduling:** all async callbacks (`requestAnimationFrame`, `queueMicrotask`, `setTimeout`, `requestIdleCallback`) route through `src/Utils/scheduling.ts`. Setting `SYNC_SCHEDULING=true` makes every callback run synchronously — the default vitest project uses this for deterministic tests, while the `*-test-async.ts` project runs without it.
|
|
1553
|
-
- **Object Identity:** `@Computed` uses `ApplyDiff` to merge changes into existing references, preventing DOM subtree recreation.
|
|
1554
|
-
- **StoreAsync Constraints:** Uses Web Workers for diffing; data must be JSON-serializable (no methods or circular references).
|
|
1555
|
-
- **`gate()` as Circuit Breaker:** Prevents reactivity propagation when result is unchanged (`===`). Ineffective with `@Scope` (always new ref).
|
|
1556
|
-
- **Scope Types:** `static` (fixed, zero overhead), `basic` (used by `@Value`), `dynamic` (tracks deps, caches, emits on change), `greedy` (batched via microtask queue — used for watch callbacks, async).
|
|
1557
|
-
- **Lazy Initialization:** Scopes created on first access, not construction.
|
|
1558
|
-
- **Memory Management:** All scopes tracked via WeakMap. `Destroy()` calls `ObservableScope.DestroyAll()` + `@Destroy` properties auto-cleaned.
|
|
1585
|
+
| Uncaught exception crashes a render | No error boundaries exist | Add `try`/`catch` inside the risky function |
|
|
1559
1586
|
|
|
1560
1587
|
---
|
|
1561
1588
|
|
|
@@ -1566,11 +1593,13 @@ These are the subtle behaviors that cause the most bugs. Read this before writin
|
|
|
1566
1593
|
| **vNode** | Virtual node — the internal representation of a DOM element or text node |
|
|
1567
1594
|
| **Scope** | A reactive unit that tracks dependencies, caches a value, and emits on change |
|
|
1568
1595
|
| **Static scope** | A scope with a fixed value — no dependency tracking, zero overhead |
|
|
1569
|
-
| **Basic scope** | A lightweight scope used by `@Value` —
|
|
1596
|
+
| **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 |
|
|
1570
1597
|
| **Dynamic scope** | A scope with a getter function — tracks dependencies, re-evaluates on change |
|
|
1571
1598
|
| **Greedy scope** | A dynamic scope that batches updates via microtask queue (used for `@Watch`, async) |
|
|
1572
1599
|
| **Children function** | The function passed as the second argument to a DOM function (e.g., `div({}, () => ...)`) |
|
|
1573
|
-
| **
|
|
1600
|
+
| **Watch context** | Code executed during evaluation of an `ObservableScope` — children functions, `data:`/`props:`/`attrs:` functions, `@Scope`/`@Computed`/`@ComputedAsync` getters, `@Watch` callbacks. Excludes `on:` handlers. |
|
|
1601
|
+
| **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. |
|
|
1602
|
+
| **ApplyDiff** | Deep merge that preserves object identity — used internally by `@Computed` to update existing references in-place |
|
|
1574
1603
|
| **ObservableNode** | A reactive proxy wrapper around objects/arrays that tracks property-level mutations |
|
|
1575
1604
|
| **Injector** | Scoped dependency injection container with parent-chain resolution (not publicly exported) |
|
|
1576
1605
|
| **keyFunc** | A function passed to Store that extracts an ID from objects for automatic flattening |
|
|
@@ -1579,7 +1608,7 @@ These are the subtle behaviors that cause the most bugs. Read this before writin
|
|
|
1579
1608
|
|
|
1580
1609
|
## References
|
|
1581
1610
|
|
|
1582
|
-
- **Source of truth:** `src/` (this primer documents `j-templates` v7.0.
|
|
1611
|
+
- **Source of truth:** `src/` (this primer documents `j-templates` v7.0.98).
|
|
1583
1612
|
- **Pattern guides:** `docs/patterns/01-components.md`, `docs/patterns/02-reactivity.md`, `docs/patterns/03-templates-and-data.md`, `docs/patterns/04-dependency-injection.md`.
|
|
1584
1613
|
- **Tutorials:** `docs/tutorials/` (01-getting-started through 08-building-complete-app).
|
|
1585
1614
|
- **Worked example:** `examples/smart-tasks/src/` (the Smart Tasks app used above).
|
|
@@ -8,6 +8,28 @@ export declare namespace ObservableNode {
|
|
|
8
8
|
* @returns The unwrapped raw value without proxy wrappers.
|
|
9
9
|
*/
|
|
10
10
|
function Unwrap<T>(value: T): T;
|
|
11
|
+
/**
|
|
12
|
+
* Produces a plain, non-reactive version of a value by deep-cloning any observable nodes it
|
|
13
|
+
* contains into plain objects/arrays. Used internally by `@Computed` to strip proxies from a
|
|
14
|
+
* computed value before it is written to the store.
|
|
15
|
+
*
|
|
16
|
+
* Behavior depends on the input:
|
|
17
|
+
* - If `value` is an observable node, returns a NEW plain deep copy (top-level identity is not
|
|
18
|
+
* preserved; nested nodes become plain objects/arrays).
|
|
19
|
+
* - If `value` is a plain object/array, it is MUTATED in place — nested observable nodes are
|
|
20
|
+
* replaced with plain copies — and the SAME reference is returned (not a copy).
|
|
21
|
+
* - Primitives and non-plain objects (Date, Map, Set, class instances) are passed through by
|
|
22
|
+
* reference, unchanged.
|
|
23
|
+
*
|
|
24
|
+
* **Dependency tracking side effect:** Cloning an observable node reads every property through
|
|
25
|
+
* the proxy's getters, so any reactive scope that contains the clone call (e.g. the `@Computed`
|
|
26
|
+
* getter scope) registers a dependency on each nested property. This is what lets `@Computed`
|
|
27
|
+
* observe and react to modifications of nested properties, not just top-level reassignment.
|
|
28
|
+
*
|
|
29
|
+
* @template T The type of value to clone.
|
|
30
|
+
* @param value The observable node or value containing observable nodes to convert to plain data.
|
|
31
|
+
* @returns A plain, non-reactive version of the value.
|
|
32
|
+
*/
|
|
11
33
|
function Clone<T>(value: T): T;
|
|
12
34
|
/**
|
|
13
35
|
* Creates an observable node from a plain value.
|
|
@@ -23,8 +45,26 @@ export declare namespace ObservableNode {
|
|
|
23
45
|
* @param value The observable node to touch.
|
|
24
46
|
* @param prop Optional property name or index to touch a specific nested property.
|
|
25
47
|
*/
|
|
26
|
-
function Update(value:
|
|
27
|
-
|
|
48
|
+
function Update<T>(value: T, prop?: keyof T): void;
|
|
49
|
+
/**
|
|
50
|
+
* Merges a new value into an observable node in-place, preserving the node's object identity.
|
|
51
|
+
* Computes the diff between the node's current value and the provided value, then applies only
|
|
52
|
+
* the changed paths to the existing node. This is the public alternative to `ApplyDiff` for
|
|
53
|
+
* cases where you want to update an observable node while keeping the same reference (so
|
|
54
|
+
* downstream `===` comparisons and DOM reuse remain stable).
|
|
55
|
+
*
|
|
56
|
+
* Unlike assigning a property directly on an observable node (which does not generate a diff),
|
|
57
|
+
* `Apply` reconciles the full value: properties present in `value` are updated, and properties
|
|
58
|
+
* missing from `value` are removed from the target object.
|
|
59
|
+
*
|
|
60
|
+
* @param rootNode The observable node to update in-place.
|
|
61
|
+
* @param value The full replacement value to merge into the node. Properties missing from this
|
|
62
|
+
* value are removed from the target object.
|
|
63
|
+
* @throws If the JSON type of `value` differs from the node's current type (e.g. object → array,
|
|
64
|
+
* or a primitive root), the node's type cannot be changed.
|
|
65
|
+
* @remarks No-op when `value` deep-equals the node's current value (empty diff).
|
|
66
|
+
*/
|
|
67
|
+
function Apply(rootNode: any, value: any): void;
|
|
28
68
|
/**
|
|
29
69
|
* Applies a JSON diff result to an observable node, efficiently updating only changed properties.
|
|
30
70
|
* Optimizes nested object updates by computing paths incrementally and touching modified properties.
|
|
@@ -42,13 +42,6 @@ function ownKeys(value) {
|
|
|
42
42
|
function ownKeysArray(value) {
|
|
43
43
|
return Object.keys(value[NODE_VALUE]);
|
|
44
44
|
}
|
|
45
|
-
function TouchValue(value, prop = OBJECT_SCOPE) {
|
|
46
|
-
const wrapper = wrapperCache.get(value);
|
|
47
|
-
if (wrapper) {
|
|
48
|
-
const scope = wrapper[prop] ?? wrapper[OBJECT_SCOPE];
|
|
49
|
-
observableScope_1.ObservableScope.Touch(scope);
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
45
|
function UnwrapProxy(value, type = (0, json_2.JsonType)(value)) {
|
|
53
46
|
if (type === "value")
|
|
54
47
|
return value;
|
|
@@ -294,6 +287,28 @@ var ObservableNode;
|
|
|
294
287
|
return UnwrapProxy(value);
|
|
295
288
|
}
|
|
296
289
|
ObservableNode.Unwrap = Unwrap;
|
|
290
|
+
/**
|
|
291
|
+
* Produces a plain, non-reactive version of a value by deep-cloning any observable nodes it
|
|
292
|
+
* contains into plain objects/arrays. Used internally by `@Computed` to strip proxies from a
|
|
293
|
+
* computed value before it is written to the store.
|
|
294
|
+
*
|
|
295
|
+
* Behavior depends on the input:
|
|
296
|
+
* - If `value` is an observable node, returns a NEW plain deep copy (top-level identity is not
|
|
297
|
+
* preserved; nested nodes become plain objects/arrays).
|
|
298
|
+
* - If `value` is a plain object/array, it is MUTATED in place — nested observable nodes are
|
|
299
|
+
* replaced with plain copies — and the SAME reference is returned (not a copy).
|
|
300
|
+
* - Primitives and non-plain objects (Date, Map, Set, class instances) are passed through by
|
|
301
|
+
* reference, unchanged.
|
|
302
|
+
*
|
|
303
|
+
* **Dependency tracking side effect:** Cloning an observable node reads every property through
|
|
304
|
+
* the proxy's getters, so any reactive scope that contains the clone call (e.g. the `@Computed`
|
|
305
|
+
* getter scope) registers a dependency on each nested property. This is what lets `@Computed`
|
|
306
|
+
* observe and react to modifications of nested properties, not just top-level reassignment.
|
|
307
|
+
*
|
|
308
|
+
* @template T The type of value to clone.
|
|
309
|
+
* @param value The observable node or value containing observable nodes to convert to plain data.
|
|
310
|
+
* @returns A plain, non-reactive version of the value.
|
|
311
|
+
*/
|
|
297
312
|
function Clone(value) {
|
|
298
313
|
return CloneProxy(value);
|
|
299
314
|
}
|
|
@@ -323,9 +338,27 @@ var ObservableNode;
|
|
|
323
338
|
}
|
|
324
339
|
}
|
|
325
340
|
ObservableNode.Update = Update;
|
|
326
|
-
|
|
341
|
+
/**
|
|
342
|
+
* Merges a new value into an observable node in-place, preserving the node's object identity.
|
|
343
|
+
* Computes the diff between the node's current value and the provided value, then applies only
|
|
344
|
+
* the changed paths to the existing node. This is the public alternative to `ApplyDiff` for
|
|
345
|
+
* cases where you want to update an observable node while keeping the same reference (so
|
|
346
|
+
* downstream `===` comparisons and DOM reuse remain stable).
|
|
347
|
+
*
|
|
348
|
+
* Unlike assigning a property directly on an observable node (which does not generate a diff),
|
|
349
|
+
* `Apply` reconciles the full value: properties present in `value` are updated, and properties
|
|
350
|
+
* missing from `value` are removed from the target object.
|
|
351
|
+
*
|
|
352
|
+
* @param rootNode The observable node to update in-place.
|
|
353
|
+
* @param value The full replacement value to merge into the node. Properties missing from this
|
|
354
|
+
* value are removed from the target object.
|
|
355
|
+
* @throws If the JSON type of `value` differs from the node's current type (e.g. object → array,
|
|
356
|
+
* or a primitive root), the node's type cannot be changed.
|
|
357
|
+
* @remarks No-op when `value` deep-equals the node's current value (empty diff).
|
|
358
|
+
*/
|
|
359
|
+
function Apply(rootNode, value) {
|
|
327
360
|
const root = rootNode[NODE_VALUE];
|
|
328
|
-
const diff = (0, json_1.JsonDiff)(
|
|
361
|
+
const diff = (0, json_1.JsonDiff)(value, root);
|
|
329
362
|
ApplyDiff(rootNode, diff);
|
|
330
363
|
}
|
|
331
364
|
ObservableNode.Apply = Apply;
|
|
@@ -115,6 +115,9 @@ export declare function PeekScope<T>(callback: () => T | Promise<T>, idOverride?
|
|
|
115
115
|
export declare namespace ObservableScope {
|
|
116
116
|
/**
|
|
117
117
|
* Creates a new observable scope from a value function.
|
|
118
|
+
* The scope auto-tracks the reactive dependencies the valueFunction reads. If it reads no
|
|
119
|
+
* reactive dependencies, `Create` returns a static scope that does not emit on `Update` — use
|
|
120
|
+
* `Basic` for a manually-updatable scope whose value doesn't derive from reactive state.
|
|
118
121
|
* @template T The type of value returned by the function.
|
|
119
122
|
* @param valueFunction Function that returns the scope's value. Can be async.
|
|
120
123
|
* @returns A new observable scope.
|
|
@@ -122,9 +125,26 @@ export declare namespace ObservableScope {
|
|
|
122
125
|
function Create<T>(valueFunction: {
|
|
123
126
|
(): T | Promise<T>;
|
|
124
127
|
}): IObservableScope<T>;
|
|
128
|
+
/**
|
|
129
|
+
* Creates a greedy observable scope that batches updates via the microtask queue.
|
|
130
|
+
* Unlike `Create`, a greedy scope does not emit immediately on change — updates are coalesced
|
|
131
|
+
* and emitted on the next microtask. Used by `@Watch` for batched side effects.
|
|
132
|
+
* @template T The type of value returned by the function.
|
|
133
|
+
* @param valueFunction Function that returns the scope's value. Can be async.
|
|
134
|
+
* @returns A new greedy observable scope.
|
|
135
|
+
*/
|
|
125
136
|
function Gated<T>(valueFunction: {
|
|
126
137
|
(): T | Promise<T>;
|
|
127
138
|
}): IObservableScope<T>;
|
|
139
|
+
/**
|
|
140
|
+
* Creates a lightweight basic scope that stores a value directly without a proxy.
|
|
141
|
+
* Used by `@Value` for primitives. Basic scopes do not automatically track dependencies —
|
|
142
|
+
* after creation, `ObservableScope.Update` must be called for the scope to emit. They also do
|
|
143
|
+
* not cache a value internally: the valueFunction is invoked on every read.
|
|
144
|
+
* @template T The type of value stored in the scope.
|
|
145
|
+
* @param valueFunction Function that returns the scope's value.
|
|
146
|
+
* @returns A new basic observable scope.
|
|
147
|
+
*/
|
|
128
148
|
function Basic<T>(valueFunction: {
|
|
129
149
|
(): T;
|
|
130
150
|
}): IBasicObservableScope<T>;
|
|
@@ -167,6 +187,13 @@ export declare namespace ObservableScope {
|
|
|
167
187
|
* @param callback The callback function to remove.
|
|
168
188
|
*/
|
|
169
189
|
function Unwatch<T>(scope: IObservableScope<T>, callback: (scope: IObservableScope<T>) => void): void;
|
|
190
|
+
/**
|
|
191
|
+
* Registers a callback to be invoked when the scope's value is updated.
|
|
192
|
+
* Only applies to dynamic scopes; no-op for static scopes.
|
|
193
|
+
* @template T The type of value stored in the scope.
|
|
194
|
+
* @param scope The scope to monitor for updates.
|
|
195
|
+
* @param callback Function invoked with the last value and the scope on each update.
|
|
196
|
+
*/
|
|
170
197
|
function OnUpdated<T>(scope: IObservableScope<T>, callback: {
|
|
171
198
|
(lastValue: T, scope: IObservableScope<T>): void;
|
|
172
199
|
}): void;
|
|
@@ -180,6 +207,8 @@ export declare namespace ObservableScope {
|
|
|
180
207
|
}): void;
|
|
181
208
|
/**
|
|
182
209
|
* Marks a scope as dirty, triggering recomputation on next access or batch.
|
|
210
|
+
* No-op for static scopes (created when a `Create` valueFunction reads no reactive
|
|
211
|
+
* dependencies) — they never emit. Use `Basic` for scopes you drive manually with `Update`.
|
|
183
212
|
* @param scope The scope to mark for update.
|
|
184
213
|
*/
|
|
185
214
|
function Update(scope: IObservableScope<any> | IBasicObservableScope<any>): void;
|
|
@@ -538,6 +538,9 @@ var ObservableScope;
|
|
|
538
538
|
(function (ObservableScope) {
|
|
539
539
|
/**
|
|
540
540
|
* Creates a new observable scope from a value function.
|
|
541
|
+
* The scope auto-tracks the reactive dependencies the valueFunction reads. If it reads no
|
|
542
|
+
* reactive dependencies, `Create` returns a static scope that does not emit on `Update` — use
|
|
543
|
+
* `Basic` for a manually-updatable scope whose value doesn't derive from reactive state.
|
|
541
544
|
* @template T The type of value returned by the function.
|
|
542
545
|
* @param valueFunction Function that returns the scope's value. Can be async.
|
|
543
546
|
* @returns A new observable scope.
|
|
@@ -546,10 +549,27 @@ var ObservableScope;
|
|
|
546
549
|
return ExecuteFunction(valueFunction, false);
|
|
547
550
|
}
|
|
548
551
|
ObservableScope.Create = Create;
|
|
552
|
+
/**
|
|
553
|
+
* Creates a greedy observable scope that batches updates via the microtask queue.
|
|
554
|
+
* Unlike `Create`, a greedy scope does not emit immediately on change — updates are coalesced
|
|
555
|
+
* and emitted on the next microtask. Used by `@Watch` for batched side effects.
|
|
556
|
+
* @template T The type of value returned by the function.
|
|
557
|
+
* @param valueFunction Function that returns the scope's value. Can be async.
|
|
558
|
+
* @returns A new greedy observable scope.
|
|
559
|
+
*/
|
|
549
560
|
function Gated(valueFunction) {
|
|
550
561
|
return ExecuteFunction(valueFunction, true);
|
|
551
562
|
}
|
|
552
563
|
ObservableScope.Gated = Gated;
|
|
564
|
+
/**
|
|
565
|
+
* Creates a lightweight basic scope that stores a value directly without a proxy.
|
|
566
|
+
* Used by `@Value` for primitives. Basic scopes do not automatically track dependencies —
|
|
567
|
+
* after creation, `ObservableScope.Update` must be called for the scope to emit. They also do
|
|
568
|
+
* not cache a value internally: the valueFunction is invoked on every read.
|
|
569
|
+
* @template T The type of value stored in the scope.
|
|
570
|
+
* @param valueFunction Function that returns the scope's value.
|
|
571
|
+
* @returns A new basic observable scope.
|
|
572
|
+
*/
|
|
553
573
|
function Basic(valueFunction) {
|
|
554
574
|
return CreateBasicScope(valueFunction);
|
|
555
575
|
}
|
|
@@ -620,6 +640,13 @@ var ObservableScope;
|
|
|
620
640
|
emitter_1.Emitter.Remove(scope.emitter, callback);
|
|
621
641
|
}
|
|
622
642
|
ObservableScope.Unwatch = Unwatch;
|
|
643
|
+
/**
|
|
644
|
+
* Registers a callback to be invoked when the scope's value is updated.
|
|
645
|
+
* Only applies to dynamic scopes; no-op for static scopes.
|
|
646
|
+
* @template T The type of value stored in the scope.
|
|
647
|
+
* @param scope The scope to monitor for updates.
|
|
648
|
+
* @param callback Function invoked with the last value and the scope on each update.
|
|
649
|
+
*/
|
|
623
650
|
function OnUpdated(scope, callback) {
|
|
624
651
|
if (scope.type !== "dynamic")
|
|
625
652
|
return;
|
|
@@ -639,6 +666,8 @@ var ObservableScope;
|
|
|
639
666
|
ObservableScope.OnDestroyed = OnDestroyed;
|
|
640
667
|
/**
|
|
641
668
|
* Marks a scope as dirty, triggering recomputation on next access or batch.
|
|
669
|
+
* No-op for static scopes (created when a `Create` valueFunction reads no reactive
|
|
670
|
+
* dependencies) — they never emit. Use `Basic` for scopes you drive manually with `Update`.
|
|
642
671
|
* @param scope The scope to mark for update.
|
|
643
672
|
*/
|
|
644
673
|
function Update(scope) {
|