j-templates 8.0.20 → 8.0.23

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.
@@ -56,6 +56,9 @@ function getHTMLNode(from, current) {
56
56
  return from;
57
57
  }
58
58
  export const DOMNodeConfig = {
59
+ isConnected(target) {
60
+ return target.isConnected;
61
+ },
59
62
  createNode(type, namespace) {
60
63
  return namespace
61
64
  ? document.createElementNS(namespace, type)
@@ -14,6 +14,11 @@ export declare class Component<D = void, T = void, E = {}> {
14
14
  private scope;
15
15
  private templates;
16
16
  private componentEvents;
17
+ /**
18
+ * Return if the component's vNode is connected
19
+ * to a parent document
20
+ */
21
+ get IsConnected(): boolean;
17
22
  /**
18
23
  * Returns the component's virtual node injector.
19
24
  */
@@ -51,6 +56,7 @@ export declare class Component<D = void, T = void, E = {}> {
51
56
  * Override to perform post‑binding initialization.
52
57
  */
53
58
  Bound(): void;
59
+ Connected(): void;
54
60
  /**
55
61
  * Fires a component event.
56
62
  *
@@ -85,6 +91,6 @@ export declare namespace Component {
85
91
  * @param vnode - The virtual node to be attached.
86
92
  * @returns The result of the attachment operation.
87
93
  */
88
- function Attach(node: any, vnode: vNodeType): vNodeType;
94
+ function Attach(node: any, vnode: vElementNode): vElementNode;
89
95
  }
90
96
  export {};
package/Node/component.js CHANGED
@@ -9,6 +9,13 @@ import { ObservableScope } from "../Store";
9
9
  * @template E - Event map type for component events.
10
10
  */
11
11
  export class Component {
12
+ /**
13
+ * Return if the component's vNode is connected
14
+ * to a parent document
15
+ */
16
+ get IsConnected() {
17
+ return this.vNode.connected;
18
+ }
12
19
  /**
13
20
  * Returns the component's virtual node injector.
14
21
  */
@@ -76,6 +83,7 @@ export class Component {
76
83
  Bound() {
77
84
  Bound.All(this);
78
85
  }
86
+ Connected() { }
79
87
  /**
80
88
  * Fires a component event.
81
89
  *
@@ -1,4 +1,5 @@
1
1
  export interface INodeConfig {
2
+ isConnected(node: any): boolean;
2
3
  createNode(type: any, namespace: string): any;
3
4
  createTextNode(value?: string): any;
4
5
  scheduleUpdate(callback: () => void): void;
package/Node/vNode.d.ts CHANGED
@@ -6,5 +6,5 @@ export declare namespace vNode {
6
6
  function Destroy(vnode: vNodeType): void;
7
7
  function DestroyAll(vnodes: vNodeType[]): void;
8
8
  function ToFunction<P = HTMLElement, E = HTMLElementEventMap>(type: string | typeof TEXT_NODE | typeof FRAGMENT_NODE, namespace?: string): <T>(config: vNodeConfig<P, E, T>, children?: vNodeType[] | vNodeChildrenFunction<T>) => vElementNode;
9
- function Attach(node: any, vnode: vNodeType): vNodeType;
9
+ function Attach(node: any, vnode: vElementNode): vElementNode;
10
10
  }
package/Node/vNode.js CHANGED
@@ -13,6 +13,7 @@ export var vNode;
13
13
  return {
14
14
  definition,
15
15
  type: definition.type,
16
+ connected: false,
16
17
  injector: definition.componentFactory
17
18
  ? new Injector()
18
19
  : (Injector.Current() ?? new Injector()),
@@ -71,8 +72,11 @@ export var vNode;
71
72
  if (vnode.type === FRAGMENT_NODE)
72
73
  throw new Error("Cannot attach a fragment directly. A fragment has no DOM node — " +
73
74
  "wrap it in a real element (e.g. div) before attaching.");
75
+ // Must create a node + attach before Init so Connected can run correctly
76
+ // on a possible Component instance
77
+ const childNode = vnode.definition.node = CreateNode(vnode);
78
+ NodeConfig.addChild(node, childNode);
74
79
  Init(vnode);
75
- NodeConfig.addChild(node, vnode.node);
76
80
  return vnode;
77
81
  }
78
82
  vNode.Attach = Attach;
@@ -80,19 +84,22 @@ export var vNode;
80
84
  function ComponentChildren() {
81
85
  return this.Template();
82
86
  }
83
- function InitNode(vnode, parentNode) {
84
- const { type, namespace, props, attrs, on, data, componentFactory, children, childrenArray, } = vnode.definition;
85
- let node = null;
87
+ function CreateNode(vnode) {
86
88
  switch (vnode.type) {
87
89
  case FRAGMENT_NODE:
88
- break;
90
+ return null;
89
91
  case TEXT_NODE:
90
- vnode.node = node = vnode.definition.node ?? NodeConfig.createTextNode();
91
- break;
92
+ return vnode.definition.node ?? NodeConfig.createTextNode();
92
93
  default:
93
- vnode.node = node = vnode.definition.node ?? NodeConfig.createNode(type, namespace);
94
- break;
94
+ return vnode.definition.node ?? NodeConfig.createNode(vnode.definition.type, vnode.definition.namespace);
95
95
  }
96
+ }
97
+ function InitNode(vnode, parentNode) {
98
+ const {
99
+ /* type,
100
+ namespace, */
101
+ props, attrs, on, data, componentFactory, children, childrenArray, } = vnode.definition;
102
+ const node = vnode.node = CreateNode(vnode);
96
103
  vnode.definition = null;
97
104
  vnode.parentNode = parentNode;
98
105
  if (props) {
@@ -130,7 +137,7 @@ function InitNode(vnode, parentNode) {
130
137
  }
131
138
  if (componentFactory) {
132
139
  vnode.component = Injector.Scope(vnode.injector, componentFactory, vnode);
133
- vnode.component.Bound();
140
+ // vnode.component.Bound();
134
141
  Children(vnode, ComponentChildren.bind(vnode.component));
135
142
  }
136
143
  else if (childrenArray) {
@@ -173,7 +180,7 @@ function CreateChildrenScope(vnode, children, data) {
173
180
  return ObservableScope.Create(DynamicChildrenFunction.bind(null, vnode.injector, children, data));
174
181
  }
175
182
  function ToVNodeType(children) {
176
- return typeof children === "string" ? { type: STRING_NODE, node: children } : children;
183
+ return typeof children === "string" ? { type: STRING_NODE, connected: false, node: children } : children;
177
184
  }
178
185
  function GetNextNodes(injector, children, data) {
179
186
  return ToVNodeType(Injector.Scope(injector, children, data));
@@ -236,14 +243,18 @@ function UpdateChildren(vnode, init = false, skipInit = false) {
236
243
  if (vnode.destroyed || children !== vnode.children)
237
244
  return;
238
245
  if (init) {
239
- if (vnode.node !== null)
246
+ if (vnode.node !== null) {
240
247
  NodeConfig.reconcileChildren(vnode.node, CleanupChildrenArray(vnode.children));
248
+ vnode.component?.Bound();
249
+ FireConnected(vnode, true);
250
+ }
241
251
  }
242
252
  else if (!async) {
243
253
  let reconcileNode = vnode;
244
254
  while (reconcileNode.node === null)
245
255
  reconcileNode = reconcileNode.parentNode;
246
256
  NodeConfig.reconcileChildren(reconcileNode.node, CleanupChildrenArray(reconcileNode.children));
257
+ FireConnected(vnode, true);
247
258
  }
248
259
  else {
249
260
  NodeConfig.scheduleUpdate(function () {
@@ -253,11 +264,47 @@ function UpdateChildren(vnode, init = false, skipInit = false) {
253
264
  while (reconcileNode.node === null)
254
265
  reconcileNode = reconcileNode.parentNode;
255
266
  NodeConfig.reconcileChildren(reconcileNode.node, CleanupChildrenArray(reconcileNode.children));
267
+ FireConnected(vnode, true);
256
268
  });
257
269
  }
258
270
  });
259
271
  });
260
272
  }
273
+ function FireConnected(vnode, root = false) {
274
+ if ((!root && vnode.connected) ||
275
+ (vnode.type !== FRAGMENT_NODE && vnode.node === null))
276
+ return;
277
+ switch (vnode.type) {
278
+ case FRAGMENT_NODE:
279
+ break;
280
+ default: {
281
+ if (!vnode.connected) {
282
+ vnode.connected = NodeConfig.isConnected(vnode.node);
283
+ switch (vnode.type) {
284
+ case STRING_NODE:
285
+ case TEXT_NODE:
286
+ break;
287
+ default:
288
+ vnode.connected && vnode.component?.Connected();
289
+ }
290
+ }
291
+ break;
292
+ }
293
+ }
294
+ if (vnode.type !== FRAGMENT_NODE && !vnode.connected)
295
+ return;
296
+ switch (vnode.type) {
297
+ case STRING_NODE:
298
+ case TEXT_NODE:
299
+ break;
300
+ default: {
301
+ const children = vnode.children;
302
+ const count = children?.length;
303
+ for (let x = 0; x < count; x++)
304
+ FireConnected(children[x]);
305
+ }
306
+ }
307
+ }
261
308
  function ScheduledAssignment(assign) {
262
309
  let scheduled = false;
263
310
  return function (scope) {
@@ -27,10 +27,12 @@ export type vNodeConfig<P = HTMLElement, E = HTMLElementEventMap, T = never> = {
27
27
  };
28
28
  export type vStringNode = {
29
29
  type: typeof STRING_NODE;
30
+ connected: boolean;
30
31
  node: string;
31
32
  };
32
33
  export type vElementNode = {
33
34
  type: string | typeof TEXT_NODE | typeof FRAGMENT_NODE;
35
+ connected: boolean;
34
36
  definition: vNodeDefinition<any, any, any>;
35
37
  injector: Injector;
36
38
  parentNode: vElementNode | null;
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.19. Every behavioral claim was checked by running the library in jsdom; claims taken from type declarations or source alone are marked as such.
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.19 (its runtime code is identical to 8.0.18; only two type declarations changed). Tested in jsdom with TypeScript (`experimentalDecorators`).
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` (from source).
328
- - `this.summary === this.summary` holds across updates, so `gate()` and identity-based item reuse see a stable reference.
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
- These create a memoized child scope inside the scope currently being evaluated and return its value.
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, id?)` | no | — | reading without subscribing |
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.** 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()` / `peek()`; `mapped()` reports `scope()`).
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, and its identity never changes.
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. **Inline helpers outside a scope throw**, including in `on:` handlers.
897
- 15. **Conditional `scope()`/`gate()`/`peek()` calls need ids**, or a later call takes a skipped call's scope. Callbacks are fixed at first run: don't close over locals.
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
- * Creates a computed scope that isn't registered as a dependency with the parent.
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
- * Unlike GateScope, PeekScope does not register itself as a dependency, meaning
100
- * changes to the data accessed within the callback will not trigger recomputation
101
- * of the parent scope. The scope is still memoized by ID within the watch context
102
- * to avoid redundant computation during the same evaluation.
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
- * Supports async callbacks — Promise<T> is resolved and the resolved value is returned.
105
- *
106
- * Only works within a watch context (during another scope's execution).
107
- * Throws if called outside a watch context.
108
- * @template T The type of value returned by the callback (extracted from Promise if async).
109
- * @param callback The function to compute the derived value.
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 PeekScope<T>(callback: () => T | Promise<T>, idOverride?: string): T;
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
- const parent = watchState;
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
- * Creates a computed scope that isn't registered as a dependency with the parent.
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
- * Unlike GateScope, PeekScope does not register itself as a dependency, meaning
425
- * changes to the data accessed within the callback will not trigger recomputation
426
- * of the parent scope. The scope is still memoized by ID within the watch context
427
- * to avoid redundant computation during the same evaluation.
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
- * Supports async callbacks — Promise<T> is resolved and the resolved value is returned.
430
- *
431
- * Only works within a watch context (during another scope's execution).
432
- * Throws if called outside a watch context.
433
- * @template T The type of value returned by the callback (extracted from Promise if async).
434
- * @param callback The function to compute the derived value.
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 PeekScope(callback, idOverride) {
440
- if (watchState === null) {
441
- throw new Error("peek() must be called within a watch context");
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
  */
@@ -39,13 +39,13 @@
39
39
  * | Aspect | @Computed | @Scope |
40
40
  * |--------|-----------|--------|
41
41
  * | Caching | ✅ Yes | ✅ Yes |
42
- * | Object identity preserved | ✅ Yes (ApplyDiff) | ❌ No (new object) |
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.ApplyDiff
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. If the result deep-equals the previous value, no update is emitted.
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
- * 3. Downstream notification: Per-property scopes are fired for each changed path, so scopes
111
- * reading sub-properties update directly. If the diff collapses to a full root replacement
112
- * (type change or removed keys), the root value gets a new identity and the Gated scope
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.ApplyDiff. This enables:
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, ===-gated updates)
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.ApplyDiff} for how @Computed maintains object identity
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
  */
@@ -39,13 +39,13 @@
39
39
  * | Aspect | @Computed | @Scope |
40
40
  * |--------|-----------|--------|
41
41
  * | Caching | ✅ Yes | ✅ Yes |
42
- * | Object identity preserved | ✅ Yes (ApplyDiff) | ❌ No (new object) |
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.ApplyDiff
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 a Gated observable scope that exposes a getter's value through a persistent
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
- * On each re-evaluation the getter's value is cloned and merged into the node in-place
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 scopes - the top-level scope does not emit.
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, which the Gated scope detects and emits for.
156
- * - Deep-equal results are suppressed entirely by the Gated ===-gating.
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 The Gated scope whose value is the observable root node.
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
- ObservableNode.Apply(observableNode, { root: clonedValue });
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
- return getterScope;
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. If the result deep-equals the previous value, no update is emitted.
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
- * 3. Downstream notification: Per-property scopes are fired for each changed path, so scopes
221
- * reading sub-properties update directly. If the diff collapses to a full root replacement
222
- * (type change or removed keys), the root value gets a new identity and the Gated scope
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.ApplyDiff. This enables:
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, ===-gated updates)
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.ApplyDiff} for how @Computed maintains object identity
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
  */
@@ -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
- const emitterStack = [];
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
- emitterStack.push(emitter);
44
- for (let x = emitter[0] + 1; x < emitter.length; x++)
45
- emitter[x]?.(...args);
46
- emitterStack.pop();
47
- if (modifiedDuringEmit.delete(emitter)) {
48
- RemoveNulls(emitter);
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
- const emitterStackIndex = emitterStack.indexOf(emitter);
61
- if (emitterStackIndex < 0) {
62
- emitter[0]++;
63
- emitter[index] = emitter[startIndex];
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, PeekScope as peek, MappedScope as mapped } from './Store/Tree/observableScope';
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, PeekScope as peek, MappedScope as mapped } from './Store/Tree/observableScope';
2
+ export { InlineScope as scope, GateScope as gate, PeekCallback as peek, MappedScope as mapped } from './Store/Tree/observableScope';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "j-templates",
3
- "version": "8.0.20",
3
+ "version": "8.0.23",
4
4
  "description": "j-templates",
5
5
  "license": "MIT",
6
6
  "repository": "https://github.com/TypesInCode/jTemplates",