j-templates 8.0.20 → 8.0.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/SYNTAX_PRIMER.md +18 -11
- 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
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
Oct 3, 2026 · @Jay Landrum
|
|
4
4
|
|
|
5
|
-
Reference for building apps with j-templates 8.0.
|
|
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
|
-
- **Version:** j-templates 8.0.
|
|
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
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
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.
|
|
14
14
|
|
|
@@ -324,8 +324,10 @@ Tracked array methods: `push`, `pop`, `shift`, `unshift`, `splice`, `sort`, `rev
|
|
|
324
324
|
```
|
|
325
325
|
|
|
326
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; each result is copied with `ObservableNode.Clone` and merged into an internal `ObservableNode` with `ObservableNode.Apply
|
|
328
|
-
-
|
|
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).
|
|
329
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.
|
|
330
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).
|
|
331
333
|
- No arguments. Prefer `@Scope` or a plain getter for cheap values.
|
|
@@ -368,24 +370,26 @@ syncFilter(value: FilterType) { this.localFilter = value; }
|
|
|
368
370
|
|
|
369
371
|
## Inline scopes: scope, gate, peek, mapped
|
|
370
372
|
|
|
371
|
-
|
|
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.
|
|
372
374
|
|
|
373
375
|
| Function | Parent re-runs when the value changes | Equality check | Use for |
|
|
374
376
|
| --- | --- | --- | --- |
|
|
375
377
|
| `scope(fn, id?)` | yes | none: parent re-runs on every emit | composing a value, especially async |
|
|
376
378
|
| `gate(fn, id?)` | yes | `!==` only | booleans and other primitives that change less often than their inputs |
|
|
377
|
-
| `peek(fn
|
|
379
|
+
| `peek(fn)` | no | — | reading without subscribing; works anywhere |
|
|
378
380
|
| `mapped(data, fn, onUpdated?, onDestroyed?)` | yes | none | a per-item scope for one value (what `data:` does internally) |
|
|
379
381
|
|
|
380
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.
|
|
381
383
|
|
|
382
|
-
**Where they can be called.**
|
|
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).
|
|
385
|
+
|
|
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.
|
|
383
387
|
|
|
384
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.
|
|
385
389
|
|
|
386
390
|
### How memoization matches calls
|
|
387
391
|
|
|
388
|
-
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:
|
|
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:
|
|
389
393
|
|
|
390
394
|
1. **Unconditional duplicates are safe.** Two `gate()` calls without ids, always made in the same order, stay independent across re-runs (verified).
|
|
391
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:
|
|
@@ -890,11 +894,11 @@ Component.Attach(document.getElementById("app")!, app({}));
|
|
|
890
894
|
8. **`@Value` does not see in-place mutation.** Reassign, or use `@State`.
|
|
891
895
|
9. **`@State` deep-tracks plain objects and arrays only**, not class instances, `Map` or `Set`.
|
|
892
896
|
10. **`@Scope` has no equality check**; consumers re-run on every change of its inputs. `gate()` it if it returns a primitive.
|
|
893
|
-
11. **`@Computed` returns a copy**, not the object the getter returned
|
|
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.
|
|
894
898
|
12. **`Bound()` runs before `Template()`**, with the host detached and empty. Defer DOM access with `requestAnimationFrame`. Forgetting `super.Bound()` silences `@Watch`.
|
|
895
899
|
13. **`@Watch` fires immediately** with the initial value, and coalesces synchronous changes into one call.
|
|
896
|
-
14.
|
|
897
|
-
15. **Conditional `scope()`/`gate()
|
|
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.
|
|
898
902
|
16. **Async scopes need the `async` keyword**, track only reads before the first `await`, and are `null` until resolved.
|
|
899
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.
|
|
900
904
|
18. **Native DOM listeners belong on elements you render**, not the host.
|
|
@@ -905,6 +909,7 @@ Component.Attach(document.getElementById("app")!, app({}));
|
|
|
905
909
|
|
|
906
910
|
23) **Equal-value writes still notify.** Assigning the current value again re-runs every reader. Guard repeated writes or read through `gate()`.
|
|
907
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.
|
|
908
913
|
|
|
909
914
|
## Debugging
|
|
910
915
|
|
|
@@ -928,6 +933,8 @@ Component.Attach(document.getElementById("app")!, app({}));
|
|
|
928
933
|
| `ReferenceError: self is not defined` | importing `defaultDiffTreeWorker` on the main thread | write your own worker entry file |
|
|
929
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() |
|
|
930
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 |
|
|
931
938
|
|
|
932
939
|
## Corrections from the v5 primer
|
|
933
940
|
|
|
@@ -92,26 +92,24 @@ export declare function MappedScope<D, T>(data: D, callback: (data: D) => T | Pr
|
|
|
92
92
|
*/
|
|
93
93
|
export declare function GateScope<T>(callback: () => T | Promise<T>, idOverride?: string): T;
|
|
94
94
|
/**
|
|
95
|
-
*
|
|
95
|
+
* Invokes a callback with dependency tracking suspended.
|
|
96
96
|
* Use this function to read reactive data without subscribing to changes
|
|
97
97
|
* to that data.
|
|
98
98
|
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
99
|
+
* No scope is created: the callback runs immediately on every call and nothing
|
|
100
|
+
* is memoized. Reactive values read inside the callback are not registered with
|
|
101
|
+
* the current watch context, so changes to them will not trigger recomputation
|
|
102
|
+
* of the parent scope. The previous watch context is restored afterwards, even
|
|
103
|
+
* if the callback throws.
|
|
103
104
|
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
* @
|
|
109
|
-
* @
|
|
110
|
-
* @param idOverride Optional custom ID for memoization when using multiple peek calls.
|
|
111
|
-
* @returns The computed value, reusing existing scope if available.
|
|
112
|
-
* @throws Error if called outside a watch context.
|
|
105
|
+
* Can be called anywhere, inside or outside a watch context. Because tracking is
|
|
106
|
+
* suspended, InlineScope, GateScope and MappedScope throw if called from within
|
|
107
|
+
* the callback. Async callbacks are not awaited; the Promise is returned as-is.
|
|
108
|
+
* @template T The type of value returned by the callback.
|
|
109
|
+
* @param callback The function to invoke without dependency tracking.
|
|
110
|
+
* @returns The value returned by the callback.
|
|
113
111
|
*/
|
|
114
|
-
export declare function
|
|
112
|
+
export declare function PeekCallback<T>(callback: () => T): T;
|
|
115
113
|
export declare namespace ObservableScope {
|
|
116
114
|
/**
|
|
117
115
|
* Creates a new observable scope from a value function.
|
|
@@ -178,6 +176,8 @@ export declare namespace ObservableScope {
|
|
|
178
176
|
/**
|
|
179
177
|
* Subscribes to changes on a dynamic scope.
|
|
180
178
|
* @template T The type of value stored in the scope.
|
|
179
|
+
* The callback must not cause the same scope to emit again (for example by calling
|
|
180
|
+
* `Update(scope)`): re-entrant emits throw.
|
|
181
181
|
* @param scope The scope to watch for changes.
|
|
182
182
|
* @param callback Function to invoke when the scope's value changes.
|
|
183
183
|
*/
|
|
@@ -268,11 +268,7 @@ function ExecuteScope(scope) {
|
|
|
268
268
|
}
|
|
269
269
|
}
|
|
270
270
|
function ExecuteBasicScope(scope) {
|
|
271
|
-
|
|
272
|
-
watchState = null;
|
|
273
|
-
const result = scope.getFunction();
|
|
274
|
-
watchState = parent;
|
|
275
|
-
return result;
|
|
271
|
+
return PeekCallback(scope.getFunction);
|
|
276
272
|
}
|
|
277
273
|
/**
|
|
278
274
|
* Creates a scope from a function, choosing between static and dynamic based on dependencies.
|
|
@@ -355,7 +351,6 @@ function MappedScopeHelper(callback, data, onUpdated, onDestroyed) {
|
|
|
355
351
|
}
|
|
356
352
|
const SCOPE_DEFAULT = Symbol("SCOPE_DEFAULT");
|
|
357
353
|
const GATE_DEFAULT = Symbol("GATE_DEFAULT");
|
|
358
|
-
const PEEK_DEFAULT = Symbol("PEEK_DEFAULT");
|
|
359
354
|
/**
|
|
360
355
|
* Creates an inline computed scope registered as a dependency of the parent.
|
|
361
356
|
* The scope is memoized by ID and reused across reads within the same parent evaluation.
|
|
@@ -417,32 +412,32 @@ export function GateScope(callback, idOverride) {
|
|
|
417
412
|
return GetScopeValue(scope);
|
|
418
413
|
}
|
|
419
414
|
/**
|
|
420
|
-
*
|
|
415
|
+
* Invokes a callback with dependency tracking suspended.
|
|
421
416
|
* Use this function to read reactive data without subscribing to changes
|
|
422
417
|
* to that data.
|
|
423
418
|
*
|
|
424
|
-
*
|
|
425
|
-
*
|
|
426
|
-
*
|
|
427
|
-
*
|
|
419
|
+
* No scope is created: the callback runs immediately on every call and nothing
|
|
420
|
+
* is memoized. Reactive values read inside the callback are not registered with
|
|
421
|
+
* the current watch context, so changes to them will not trigger recomputation
|
|
422
|
+
* of the parent scope. The previous watch context is restored afterwards, even
|
|
423
|
+
* if the callback throws.
|
|
428
424
|
*
|
|
429
|
-
*
|
|
430
|
-
*
|
|
431
|
-
*
|
|
432
|
-
*
|
|
433
|
-
* @
|
|
434
|
-
* @
|
|
435
|
-
* @param idOverride Optional custom ID for memoization when using multiple peek calls.
|
|
436
|
-
* @returns The computed value, reusing existing scope if available.
|
|
437
|
-
* @throws Error if called outside a watch context.
|
|
425
|
+
* Can be called anywhere, inside or outside a watch context. Because tracking is
|
|
426
|
+
* suspended, InlineScope, GateScope and MappedScope throw if called from within
|
|
427
|
+
* the callback. Async callbacks are not awaited; the Promise is returned as-is.
|
|
428
|
+
* @template T The type of value returned by the callback.
|
|
429
|
+
* @param callback The function to invoke without dependency tracking.
|
|
430
|
+
* @returns The value returned by the callback.
|
|
438
431
|
*/
|
|
439
|
-
export function
|
|
440
|
-
|
|
441
|
-
|
|
432
|
+
export function PeekCallback(callback) {
|
|
433
|
+
const parent = watchState;
|
|
434
|
+
watchState = null;
|
|
435
|
+
try {
|
|
436
|
+
return callback();
|
|
437
|
+
}
|
|
438
|
+
finally {
|
|
439
|
+
watchState = parent;
|
|
442
440
|
}
|
|
443
|
-
const id = idOverride ?? PEEK_DEFAULT;
|
|
444
|
-
const scope = ScopeHelper(callback, id, false);
|
|
445
|
-
return GetScopeValue(scope);
|
|
446
441
|
}
|
|
447
442
|
/**
|
|
448
443
|
* Updates a scope's dependency emitters using efficient diffing.
|
|
@@ -621,6 +616,8 @@ export var ObservableScope;
|
|
|
621
616
|
/**
|
|
622
617
|
* Subscribes to changes on a dynamic scope.
|
|
623
618
|
* @template T The type of value stored in the scope.
|
|
619
|
+
* The callback must not cause the same scope to emit again (for example by calling
|
|
620
|
+
* `Update(scope)`): re-entrant emits throw.
|
|
624
621
|
* @param scope The scope to watch for changes.
|
|
625
622
|
* @param callback Function to invoke when the scope's value changes.
|
|
626
623
|
*/
|
package/Utils/decorators.d.ts
CHANGED
|
@@ -39,13 +39,13 @@
|
|
|
39
39
|
* | Aspect | @Computed | @Scope |
|
|
40
40
|
* |--------|-----------|--------|
|
|
41
41
|
* | Caching | ✅ Yes | ✅ Yes |
|
|
42
|
-
* | Object identity preserved | ✅ Yes (
|
|
42
|
+
* | Object identity preserved | ✅ Yes (Apply) | ❌ No (new object) |
|
|
43
43
|
* | Overhead | Store + diff | Single scope |
|
|
44
44
|
* | Best for | Complex objects | Primitives/simple values |
|
|
45
45
|
*
|
|
46
46
|
* Object Identity (Key Difference):
|
|
47
47
|
* ---------------------------------
|
|
48
|
-
* @Computed: Same object reference, in-place updates via ObservableNode.
|
|
48
|
+
* @Computed: Same object reference, in-place updates via ObservableNode.Apply
|
|
49
49
|
* @Scope: New object reference on every update
|
|
50
50
|
*
|
|
51
51
|
* Example:
|
|
@@ -104,13 +104,16 @@ import { Injector } from "./injector";
|
|
|
104
104
|
* @remarks
|
|
105
105
|
* The @Computed decorator uses a Gated getter scope driving a persistent observable node:
|
|
106
106
|
* 1. Getter scope (Gated): Re-evaluates the getter when dependencies change, batched via
|
|
107
|
-
* the microtask queue
|
|
107
|
+
* the microtask queue, and returns a clone of the result. A watcher on this scope
|
|
108
|
+
* applies each new clone to the node.
|
|
108
109
|
* 2. ObservableNode.Apply: Diffs the fresh value against the node's current root and applies
|
|
109
|
-
* only the changed paths in-place, preserving the node's object identity.
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
* emits, notifying top-level subscribers
|
|
110
|
+
* only the changed paths in-place, preserving the node's object identity. If the result
|
|
111
|
+
* deep-equals the previous value, nothing is changed and nothing is emitted.
|
|
112
|
+
* 3. Downstream notification: Per-property emitters fire for each changed path, so scopes
|
|
113
|
+
* reading sub-properties update directly. The property itself is backed by a separate
|
|
114
|
+
* scope that reads the node's root; it emits, notifying top-level subscribers, only when
|
|
115
|
+
* the diff collapses to a full root replacement (type change or removed keys) and the
|
|
116
|
+
* root value gets a new identity.
|
|
114
117
|
*
|
|
115
118
|
* **Initialization**: @Computed uses lazy initialization - the scopes are created on first access:
|
|
116
119
|
* ```typescript
|
|
@@ -127,7 +130,7 @@ import { Injector } from "./injector";
|
|
|
127
130
|
* dependencies change. The key difference is what happens during re-evaluation:
|
|
128
131
|
*
|
|
129
132
|
* **Key benefit - Object Reuse**: The returned object maintains its identity across updates.
|
|
130
|
-
* Only the changed properties are modified in-place using ObservableNode.
|
|
133
|
+
* Only the changed properties are modified in-place using ObservableNode.Apply. This enables:
|
|
131
134
|
* - DOM reference preservation (no unnecessary re-renders)
|
|
132
135
|
* - Existing proxy reuse (no proxy recreation overhead)
|
|
133
136
|
* - Identity checks (===) remain stable across updates
|
|
@@ -156,7 +159,7 @@ import { Injector } from "./injector";
|
|
|
156
159
|
* @see {@link Scope} for simple getter-based reactive properties (caches but new reference)
|
|
157
160
|
* @see {@link ComputedAsync} for async computed properties
|
|
158
161
|
* @see {@link ObservableNode.Apply} for how diffs are applied in-place to maintain object identity
|
|
159
|
-
* @see {@link ObservableScope} for the Gated scope mechanism (batched
|
|
162
|
+
* @see {@link ObservableScope} for the Gated scope mechanism (batched updates)
|
|
160
163
|
* @see {@link StoreSync} for the store-backed variant used by @ComputedAsync
|
|
161
164
|
*/
|
|
162
165
|
export declare function Computed<T extends WeakKey, K extends keyof T, D extends T[K]>(): (target: T, propertyKey: K, descriptor: PropertyDescriptor) => PropertyDescriptor;
|
|
@@ -488,7 +491,7 @@ export declare function Value(): any;
|
|
|
488
491
|
*
|
|
489
492
|
* @see {@link Computed} for cached computed properties with object reuse
|
|
490
493
|
* @see {@link ComputedAsync} for sync getters with StoreAsync backend
|
|
491
|
-
* @see {@link ObservableNode.
|
|
494
|
+
* @see {@link ObservableNode.Apply} for how @Computed maintains object identity
|
|
492
495
|
* @see {@link ObservableScope} for the scope-based reactivity system
|
|
493
496
|
* @see {@link scope} for inline computed scopes within @Scope
|
|
494
497
|
*/
|
package/Utils/decorators.js
CHANGED
|
@@ -39,13 +39,13 @@
|
|
|
39
39
|
* | Aspect | @Computed | @Scope |
|
|
40
40
|
* |--------|-----------|--------|
|
|
41
41
|
* | Caching | ✅ Yes | ✅ Yes |
|
|
42
|
-
* | Object identity preserved | ✅ Yes (
|
|
42
|
+
* | Object identity preserved | ✅ Yes (Apply) | ❌ No (new object) |
|
|
43
43
|
* | Overhead | Store + diff | Single scope |
|
|
44
44
|
* | Best for | Complex objects | Primitives/simple values |
|
|
45
45
|
*
|
|
46
46
|
* Object Identity (Key Difference):
|
|
47
47
|
* ---------------------------------
|
|
48
|
-
* @Computed: Same object reference, in-place updates via ObservableNode.
|
|
48
|
+
* @Computed: Same object reference, in-place updates via ObservableNode.Apply
|
|
49
49
|
* @Scope: New object reference on every update
|
|
50
50
|
*
|
|
51
51
|
* Example:
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
* console.log(list === list2); // @Computed: true, @Scope: false
|
|
57
57
|
* ```
|
|
58
58
|
*/
|
|
59
|
-
import { ObservableScope, } from "../Store/Tree/observableScope";
|
|
59
|
+
import { ObservableScope, PeekCallback, } from "../Store/Tree/observableScope";
|
|
60
60
|
import { ObservableNode } from "../Store/Tree/observableNode";
|
|
61
61
|
import { StoreAsync } from "../Store";
|
|
62
62
|
/**
|
|
@@ -144,29 +144,43 @@ function CreateStoreScope(getter, store, defaultValue) {
|
|
|
144
144
|
return propertyScope;
|
|
145
145
|
}
|
|
146
146
|
/**
|
|
147
|
-
* Creates
|
|
147
|
+
* Creates an observable scope that exposes a getter's value through a persistent
|
|
148
148
|
* observable node, preserving the value's object identity across updates.
|
|
149
149
|
*
|
|
150
|
-
*
|
|
151
|
-
* via ObservableNode.Apply
|
|
150
|
+
* The getter runs in a Gated scope that returns a clone of its value. A watcher on that
|
|
151
|
+
* scope merges each new clone into the node in-place via ObservableNode.Apply, with
|
|
152
|
+
* dependency tracking suspended. The returned scope reads the node's root:
|
|
152
153
|
* - Content changes (same structure, changed properties) fire only the affected
|
|
153
|
-
* per-property
|
|
154
|
+
* per-property emitters - the returned scope does not emit.
|
|
154
155
|
* - Structural changes (type change or removed keys) replace the root value with a new
|
|
155
|
-
* identity
|
|
156
|
-
* - Deep-equal results
|
|
156
|
+
* identity; the root's emitter fires and the returned scope emits.
|
|
157
|
+
* - Deep-equal results change nothing in the node, so nothing downstream is notified.
|
|
158
|
+
*
|
|
159
|
+
* Destroying the returned scope also destroys the Gated getter scope.
|
|
157
160
|
*
|
|
158
161
|
* @param getter Function computing the value. May read reactive dependencies.
|
|
159
|
-
* @returns
|
|
162
|
+
* @returns A scope whose value is the observable node's root.
|
|
160
163
|
*/
|
|
161
164
|
function CreateNodeScope(getter) {
|
|
162
|
-
const observableNode = ObservableNode.Create({ root: null });
|
|
163
165
|
const getterScope = ObservableScope.Gated(function () {
|
|
164
166
|
const value = getter();
|
|
165
167
|
const clonedValue = ObservableNode.Clone(value);
|
|
166
|
-
|
|
168
|
+
return clonedValue;
|
|
169
|
+
});
|
|
170
|
+
const observableNode = ObservableNode.Create({ root: ObservableScope.Peek(getterScope) });
|
|
171
|
+
ObservableScope.Watch(getterScope, function (scope) {
|
|
172
|
+
PeekCallback(function () {
|
|
173
|
+
const value = ObservableScope.Peek(scope);
|
|
174
|
+
ObservableNode.Apply(observableNode, { root: value });
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
const nodeScope = ObservableScope.Create(function () {
|
|
167
178
|
return observableNode.root;
|
|
168
179
|
});
|
|
169
|
-
|
|
180
|
+
ObservableScope.OnDestroyed(nodeScope, function () {
|
|
181
|
+
ObservableScope.Destroy(getterScope);
|
|
182
|
+
});
|
|
183
|
+
return nodeScope;
|
|
170
184
|
}
|
|
171
185
|
/**
|
|
172
186
|
* Computed decorator factory for creating synchronous computed properties with caching and object reuse.
|
|
@@ -214,13 +228,16 @@ function CreateNodeScope(getter) {
|
|
|
214
228
|
* @remarks
|
|
215
229
|
* The @Computed decorator uses a Gated getter scope driving a persistent observable node:
|
|
216
230
|
* 1. Getter scope (Gated): Re-evaluates the getter when dependencies change, batched via
|
|
217
|
-
* the microtask queue
|
|
231
|
+
* the microtask queue, and returns a clone of the result. A watcher on this scope
|
|
232
|
+
* applies each new clone to the node.
|
|
218
233
|
* 2. ObservableNode.Apply: Diffs the fresh value against the node's current root and applies
|
|
219
|
-
* only the changed paths in-place, preserving the node's object identity.
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
* emits, notifying top-level subscribers
|
|
234
|
+
* only the changed paths in-place, preserving the node's object identity. If the result
|
|
235
|
+
* deep-equals the previous value, nothing is changed and nothing is emitted.
|
|
236
|
+
* 3. Downstream notification: Per-property emitters fire for each changed path, so scopes
|
|
237
|
+
* reading sub-properties update directly. The property itself is backed by a separate
|
|
238
|
+
* scope that reads the node's root; it emits, notifying top-level subscribers, only when
|
|
239
|
+
* the diff collapses to a full root replacement (type change or removed keys) and the
|
|
240
|
+
* root value gets a new identity.
|
|
224
241
|
*
|
|
225
242
|
* **Initialization**: @Computed uses lazy initialization - the scopes are created on first access:
|
|
226
243
|
* ```typescript
|
|
@@ -237,7 +254,7 @@ function CreateNodeScope(getter) {
|
|
|
237
254
|
* dependencies change. The key difference is what happens during re-evaluation:
|
|
238
255
|
*
|
|
239
256
|
* **Key benefit - Object Reuse**: The returned object maintains its identity across updates.
|
|
240
|
-
* Only the changed properties are modified in-place using ObservableNode.
|
|
257
|
+
* Only the changed properties are modified in-place using ObservableNode.Apply. This enables:
|
|
241
258
|
* - DOM reference preservation (no unnecessary re-renders)
|
|
242
259
|
* - Existing proxy reuse (no proxy recreation overhead)
|
|
243
260
|
* - Identity checks (===) remain stable across updates
|
|
@@ -266,7 +283,7 @@ function CreateNodeScope(getter) {
|
|
|
266
283
|
* @see {@link Scope} for simple getter-based reactive properties (caches but new reference)
|
|
267
284
|
* @see {@link ComputedAsync} for async computed properties
|
|
268
285
|
* @see {@link ObservableNode.Apply} for how diffs are applied in-place to maintain object identity
|
|
269
|
-
* @see {@link ObservableScope} for the Gated scope mechanism (batched
|
|
286
|
+
* @see {@link ObservableScope} for the Gated scope mechanism (batched updates)
|
|
270
287
|
* @see {@link StoreSync} for the store-backed variant used by @ComputedAsync
|
|
271
288
|
*/
|
|
272
289
|
export function Computed() {
|
|
@@ -733,7 +750,7 @@ function ValueDecorator(target, propertyKey) {
|
|
|
733
750
|
*
|
|
734
751
|
* @see {@link Computed} for cached computed properties with object reuse
|
|
735
752
|
* @see {@link ComputedAsync} for sync getters with StoreAsync backend
|
|
736
|
-
* @see {@link ObservableNode.
|
|
753
|
+
* @see {@link ObservableNode.Apply} for how @Computed maintains object identity
|
|
737
754
|
* @see {@link ObservableScope} for the scope-based reactivity system
|
|
738
755
|
* @see {@link scope} for inline computed scopes within @Scope
|
|
739
756
|
*/
|
package/Utils/emitter.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export type EmitterCallback<T extends readonly any[] = any[]> = (...args: T) => void;
|
|
2
|
-
export type Emitter = [number, ...EmitterCallback[]];
|
|
2
|
+
export type Emitter = [[number, number], ...EmitterCallback[]];
|
|
3
3
|
export declare namespace Emitter {
|
|
4
4
|
function Create(): Emitter;
|
|
5
5
|
function On(emitter: Emitter, callback: EmitterCallback): void;
|
package/Utils/emitter.js
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import { RemoveNulls } from "./array";
|
|
2
1
|
import { _requestIdleCallback } from "./scheduling";
|
|
3
2
|
const pendingCompactEmitters = new Set();
|
|
4
3
|
function Compact(emitter) {
|
|
5
|
-
if (emitter.length === 1 || emitter[0] === 0)
|
|
4
|
+
if (emitter.length === 1 || emitter[0][0] === 0)
|
|
6
5
|
return;
|
|
7
6
|
pendingCompactEmitters.add(emitter);
|
|
8
7
|
ScheduleCompact();
|
|
@@ -22,57 +21,63 @@ function PerformCompact() {
|
|
|
22
21
|
CompactEmitter(compactEmitters[x]);
|
|
23
22
|
}
|
|
24
23
|
function CompactEmitter(emitter) {
|
|
25
|
-
if (emitter[0] === 0)
|
|
24
|
+
if (emitter[0][0] === 0)
|
|
26
25
|
return;
|
|
27
|
-
emitter.splice(1, emitter[0]);
|
|
28
|
-
emitter[0] = 0;
|
|
26
|
+
emitter.splice(1, emitter[0][0]);
|
|
27
|
+
emitter[0][0] = 0;
|
|
29
28
|
}
|
|
30
29
|
export var Emitter;
|
|
31
30
|
(function (Emitter) {
|
|
32
31
|
function Create() {
|
|
33
|
-
return [0];
|
|
32
|
+
return [[0, -1]];
|
|
34
33
|
}
|
|
35
34
|
Emitter.Create = Create;
|
|
36
35
|
function On(emitter, callback) {
|
|
37
36
|
emitter.push(callback);
|
|
38
37
|
}
|
|
39
38
|
Emitter.On = On;
|
|
40
|
-
|
|
41
|
-
const modifiedDuringEmit = new Set();
|
|
39
|
+
// Re-entry during emit is not supported and considered a bug in client code
|
|
42
40
|
function Emit(emitter, ...args) {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
emitter[0] = 0;
|
|
41
|
+
if (emitter[0][1] !== -1)
|
|
42
|
+
throw new Error("Emitter.Emit: re-entry during emit is not supported");
|
|
43
|
+
try {
|
|
44
|
+
for (let x = emitter[0][1] = emitter[0][0] + 1; x < emitter.length; emitter[0][1] = ++x) {
|
|
45
|
+
emitter[x](...args);
|
|
46
|
+
}
|
|
50
47
|
}
|
|
48
|
+
finally {
|
|
49
|
+
emitter[0][1] = -1;
|
|
50
|
+
}
|
|
51
|
+
if (emitter[0][0] !== 0)
|
|
52
|
+
Compact(emitter);
|
|
51
53
|
}
|
|
52
54
|
Emitter.Emit = Emit;
|
|
55
|
+
function RemoveFromEnd(emitter, index) {
|
|
56
|
+
emitter[index] = emitter[emitter.length - 1];
|
|
57
|
+
emitter.pop();
|
|
58
|
+
}
|
|
59
|
+
function RemoveFromBeginning(emitter, index) {
|
|
60
|
+
const tombstoneSize = emitter[0][0];
|
|
61
|
+
emitter[index] = emitter[tombstoneSize + 1];
|
|
62
|
+
emitter[tombstoneSize + 1] = null;
|
|
63
|
+
emitter[0][0]++;
|
|
64
|
+
}
|
|
53
65
|
function Remove(emitter, callback) {
|
|
54
66
|
if (emitter.length === 1)
|
|
55
67
|
return;
|
|
56
|
-
const startIndex = emitter[0] + 1;
|
|
68
|
+
const startIndex = emitter[0][0] + 1;
|
|
57
69
|
const index = emitter.indexOf(callback, startIndex);
|
|
58
70
|
if (index < 1)
|
|
59
71
|
return;
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
emitter
|
|
64
|
-
emitter[startIndex] = null;
|
|
65
|
-
Compact(emitter);
|
|
66
|
-
}
|
|
67
|
-
else {
|
|
68
|
-
emitter[index] = null;
|
|
69
|
-
modifiedDuringEmit.add(emitter);
|
|
70
|
-
}
|
|
72
|
+
if (index <= emitter[0][1])
|
|
73
|
+
RemoveFromBeginning(emitter, index);
|
|
74
|
+
else
|
|
75
|
+
RemoveFromEnd(emitter, index);
|
|
71
76
|
}
|
|
72
77
|
Emitter.Remove = Remove;
|
|
73
78
|
function Clear(emitter) {
|
|
74
79
|
emitter.length > 1 && emitter.splice(1);
|
|
75
|
-
emitter[0] = 0;
|
|
80
|
+
emitter[0][0] = 0;
|
|
76
81
|
}
|
|
77
82
|
Emitter.Clear = Clear;
|
|
78
83
|
})(Emitter || (Emitter = {}));
|
package/index.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
export { Component } from './Node/component';
|
|
2
|
-
export { InlineScope as scope, GateScope as gate,
|
|
2
|
+
export { InlineScope as scope, GateScope as gate, PeekCallback as peek, MappedScope as mapped } from './Store/Tree/observableScope';
|
|
3
3
|
export { vNode } from './Node/vNode.types';
|
package/index.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export { Component } from './Node/component';
|
|
2
|
-
export { InlineScope as scope, GateScope as gate,
|
|
2
|
+
export { InlineScope as scope, GateScope as gate, PeekCallback as peek, MappedScope as mapped } from './Store/Tree/observableScope';
|