@signaltree/core 6.2.4 → 6.4.0

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/README.md CHANGED
@@ -148,6 +148,46 @@ $.users.setAll(usersFromApi); // Replace all
148
148
  const user = entityMap()[123]; // Requires intermediate object
149
149
  ```
150
150
 
151
+ ### Notification Batching
152
+
153
+ SignalTree automatically batches _notification delivery_ to subscribers and change detection to the end of the current microtask. This prevents render thrashing when multiple values are updated together and preserves immediate read-after-write semantics (values update synchronously, notifications are deferred).
154
+
155
+ **Example**
156
+
157
+ ```typescript
158
+ // Multiple updates in the same microtask are coalesced into a single notification
159
+ tree.$.form.name.set('Alice');
160
+ tree.$.form.email.set('alice@example.com');
161
+ tree.$.form.submitted.set(true);
162
+ // → Subscribers are notified once at the end of the microtask with final values
163
+ ```
164
+
165
+ **Testing**
166
+
167
+ When tests need synchronous notification delivery, use `flushSync()`:
168
+
169
+ ```typescript
170
+ import { getPathNotifier } from '@signaltree/core';
171
+
172
+ it('updates state', () => {
173
+ tree.$.count.set(5);
174
+ getPathNotifier().flushSync();
175
+ expect(subscriber).toHaveBeenCalledWith(5, 0);
176
+ });
177
+ ```
178
+
179
+ Alternatively, await a microtask (`await Promise.resolve()`) to allow the automatic flush to occur.
180
+
181
+ **Opting out**
182
+
183
+ To disable automatic microtask batching for a specific tree instance:
184
+
185
+ ```typescript
186
+ const tree = signalTree(initialState, { batching: false });
187
+ ```
188
+
189
+ Use this only for rare cases that truly require synchronous notifications (most apps should keep batching enabled).
190
+
151
191
  ## Quick start
152
192
 
153
193
  ### Installation
@@ -1,5 +1,5 @@
1
1
  import { snapshotState } from '../../lib/utils.js';
2
- import { deepClone, deepEqual } from './utils.js';
2
+ import { deepEqual, deepClone } from './utils.js';
3
3
 
4
4
  class TimeTravelManager {
5
5
  tree;
@@ -24,7 +24,7 @@ class TimeTravelManager {
24
24
  };
25
25
  this.addEntry('INIT', this.tree());
26
26
  }
27
- addEntry(action, state, payload) {
27
+ addEntry(action, state, payload, provisional = false) {
28
28
  if (this.currentIndex < this.history.length - 1) {
29
29
  this.history = this.history.slice(0, this.currentIndex + 1);
30
30
  }
@@ -43,6 +43,12 @@ class TimeTravelManager {
43
43
  payload
44
44
  })
45
45
  };
46
+ if (provisional) entry.__provisional = true;
47
+ const last = this.history[this.history.length - 1];
48
+ if (last && deepEqual(last.state, entry.state)) {
49
+ if (last.__provisional) delete last.__provisional;
50
+ return;
51
+ }
46
52
  this.history.push(entry);
47
53
  this.currentIndex = this.history.length - 1;
48
54
  if (this.history.length > this.maxHistorySize) {
@@ -59,6 +65,20 @@ class TimeTravelManager {
59
65
  this.restoreState(entry.state);
60
66
  return true;
61
67
  }
68
+ finalizeProvisional(state) {
69
+ const last = this.history[this.history.length - 1];
70
+ if (last && last.__provisional) {
71
+ if (deepEqual(last.state, state)) {
72
+ delete last.__provisional;
73
+ return;
74
+ }
75
+ last.state = deepClone(state);
76
+ last.timestamp = Date.now();
77
+ delete last.__provisional;
78
+ return;
79
+ }
80
+ this.addEntry('update', state);
81
+ }
62
82
  redo() {
63
83
  if (!this.canRedo()) {
64
84
  return false;
@@ -143,6 +163,22 @@ function timeTravel(config = {}) {
143
163
  isRestoring = false;
144
164
  }
145
165
  });
166
+ try {
167
+ const req = globalThis['require'];
168
+ if (typeof req === 'function') {
169
+ const {
170
+ getPathNotifier
171
+ } = req('../../lib/path-notifier');
172
+ const notifier = getPathNotifier();
173
+ if (notifier && typeof notifier.onFlush === 'function') {
174
+ notifier.onFlush(() => {
175
+ if (isRestoring) return;
176
+ const afterState = originalTreeCall();
177
+ timeTravelManager.addEntry('batch', afterState);
178
+ });
179
+ }
180
+ }
181
+ } catch {}
146
182
  const enhancedTree = function (...args) {
147
183
  if (args.length === 0) {
148
184
  return originalTreeCall();
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  export { signalTree } from './lib/signal-tree.js';
2
2
  export { ENHANCER_META, entityMap } from './lib/types.js';
3
+ export { derived, isDerivedMarker } from './lib/markers/derived.js';
3
4
  export { composeEnhancers, createLazySignalTree, isAnySignal, isNodeAccessor, toWritableSignal } from './lib/utils.js';
4
5
  export { createEditSession } from './lib/edit-session.js';
5
6
  export { getPathNotifier } from './lib/path-notifier.js';
@@ -42,6 +42,8 @@ function createEntitySignal(config, pathNotifier, basePath) {
42
42
  }
43
43
  return node;
44
44
  }
45
+ const whereCache = new WeakMap();
46
+ const findCache = new WeakMap();
45
47
  const api = {
46
48
  byId(id) {
47
49
  const entity = storage.get(id);
@@ -74,10 +76,18 @@ function createEntitySignal(config, pathNotifier, basePath) {
74
76
  return computed(() => countSignal() === 0);
75
77
  },
76
78
  where(predicate) {
77
- return computed(() => allSignal().filter(predicate));
79
+ const cached = whereCache.get(predicate);
80
+ if (cached) return cached;
81
+ const s = computed(() => allSignal().filter(predicate));
82
+ whereCache.set(predicate, s);
83
+ return s;
78
84
  },
79
85
  find(predicate) {
80
- return computed(() => allSignal().find(predicate));
86
+ const cached = findCache.get(predicate);
87
+ if (cached) return cached;
88
+ const s = computed(() => allSignal().find(predicate));
89
+ findCache.set(predicate, s);
90
+ return s;
81
91
  },
82
92
  addOne(entity, opts) {
83
93
  const id = opts?.selectId?.(entity) ?? selectId(entity);
@@ -0,0 +1,59 @@
1
+ import { computed, isSignal } from '@angular/core';
2
+ import { isDerivedMarker } from '../markers/derived.js';
3
+
4
+ function isSignalLike(value) {
5
+ return isSignal(value);
6
+ }
7
+ function ensurePathAndGetTarget($, path) {
8
+ if (!path) return $;
9
+ const parts = path.split('.');
10
+ let current = $;
11
+ for (const part of parts) {
12
+ if (!(part in current)) {
13
+ current[part] = {};
14
+ }
15
+ current = current[part];
16
+ }
17
+ return current;
18
+ }
19
+ function mergeDerivedState($, derivedDef, path = '') {
20
+ if (!derivedDef || typeof derivedDef !== 'object') {
21
+ return;
22
+ }
23
+ for (const [key, value] of Object.entries(derivedDef)) {
24
+ const currentPath = path ? `${path}.${key}` : key;
25
+ if (isDerivedMarker(value)) {
26
+ const target = ensurePathAndGetTarget($, path);
27
+ if (key in target && isSignalLike(target[key])) {
28
+ if (typeof ngDevMode === 'undefined' || ngDevMode) {
29
+ console.warn(`SignalTree: Derived "${currentPath}" overwrites source signal. ` + `Consider using a different key to avoid confusion.`);
30
+ }
31
+ }
32
+ target[key] = computed(value.factory);
33
+ } else if (isSignalLike(value)) {
34
+ const target = ensurePathAndGetTarget($, path);
35
+ if (key in target && isSignalLike(target[key])) {
36
+ if (typeof ngDevMode === 'undefined' || ngDevMode) {
37
+ console.warn(`SignalTree: Derived signal "${currentPath}" overwrites source signal. ` + `Consider using a different key to avoid confusion.`);
38
+ }
39
+ }
40
+ target[key] = value;
41
+ } else if (typeof value === 'object' && value !== null) {
42
+ const target = ensurePathAndGetTarget($, path);
43
+ if (!(key in target)) {
44
+ target[key] = {};
45
+ } else if (isSignalLike(target[key])) {
46
+ throw new Error(`SignalTree: Cannot merge derived object into "${currentPath}" ` + `because source is a signal. Either make source an object or use a different key.`);
47
+ }
48
+ mergeDerivedState($, value, currentPath);
49
+ }
50
+ }
51
+ }
52
+ function applyDerivedFactories($, factories) {
53
+ for (const factory of factories) {
54
+ const derivedDef = factory($);
55
+ mergeDerivedState($, derivedDef);
56
+ }
57
+ }
58
+
59
+ export { applyDerivedFactories, mergeDerivedState };
@@ -0,0 +1,12 @@
1
+ const DERIVED_MARKER = Symbol.for('signaltree:derived');
2
+ function derived(factory) {
3
+ return {
4
+ [DERIVED_MARKER]: true,
5
+ factory
6
+ };
7
+ }
8
+ function isDerivedMarker(value) {
9
+ return value !== null && typeof value === 'object' && DERIVED_MARKER in value && value[DERIVED_MARKER] === true;
10
+ }
11
+
12
+ export { derived, isDerivedMarker };
@@ -1,6 +1,20 @@
1
1
  class PathNotifier {
2
2
  subscribers = new Map();
3
3
  interceptors = new Map();
4
+ batchingEnabled = true;
5
+ pendingFlush = false;
6
+ pending = new Map();
7
+ firstValues = new Map();
8
+ flushCallbacks = new Set();
9
+ constructor(options) {
10
+ if (options && options.batching === false) this.batchingEnabled = false;
11
+ }
12
+ setBatchingEnabled(enabled) {
13
+ this.batchingEnabled = enabled;
14
+ }
15
+ isBatchingEnabled() {
16
+ return this.batchingEnabled;
17
+ }
4
18
  subscribe(pattern, handler) {
5
19
  if (!this.subscribers.has(pattern)) {
6
20
  this.subscribers.set(pattern, new Set());
@@ -34,6 +48,26 @@ class PathNotifier {
34
48
  };
35
49
  }
36
50
  notify(path, value, prev) {
51
+ if (!this.batchingEnabled) {
52
+ return this._runNotify(path, value, prev);
53
+ }
54
+ if (!this.pending.has(path)) {
55
+ this.firstValues.set(path, prev);
56
+ }
57
+ this.pending.set(path, {
58
+ newValue: value,
59
+ oldValue: this.firstValues.get(path)
60
+ });
61
+ if (!this.pendingFlush) {
62
+ this.pendingFlush = true;
63
+ queueMicrotask(() => this.flush());
64
+ }
65
+ return {
66
+ blocked: false,
67
+ value
68
+ };
69
+ }
70
+ _runNotify(path, value, prev) {
37
71
  let blocked = false;
38
72
  let transformed = value;
39
73
  for (const [pattern, interceptorSet] of this.interceptors) {
@@ -67,6 +101,41 @@ class PathNotifier {
67
101
  value: transformed
68
102
  };
69
103
  }
104
+ flush() {
105
+ const toNotify = new Map(this.pending);
106
+ this.pending.clear();
107
+ this.firstValues.clear();
108
+ this.pendingFlush = false;
109
+ for (const [path, {
110
+ newValue,
111
+ oldValue
112
+ }] of toNotify) {
113
+ if (newValue === oldValue) continue;
114
+ const res = this._runNotify(path, newValue, oldValue);
115
+ if (res.blocked) ;
116
+ }
117
+ for (const cb of Array.from(this.flushCallbacks)) {
118
+ try {
119
+ cb();
120
+ } catch {}
121
+ }
122
+ }
123
+ flushSync() {
124
+ while (this.pending.size > 0 || this.pendingFlush) {
125
+ if (this.pendingFlush && this.pending.size === 0) {
126
+ this.pendingFlush = false;
127
+ break;
128
+ }
129
+ this.flush();
130
+ }
131
+ }
132
+ onFlush(callback) {
133
+ this.flushCallbacks.add(callback);
134
+ return () => this.flushCallbacks.delete(callback);
135
+ }
136
+ hasPending() {
137
+ return this.pending.size > 0;
138
+ }
70
139
  matches(pattern, path) {
71
140
  if (pattern === '**') return true;
72
141
  if (pattern === path) return true;
@@ -79,6 +148,9 @@ class PathNotifier {
79
148
  clear() {
80
149
  this.subscribers.clear();
81
150
  this.interceptors.clear();
151
+ this.pending.clear();
152
+ this.firstValues.clear();
153
+ this.pendingFlush = false;
82
154
  }
83
155
  getSubscriberCount() {
84
156
  let count = 0;
@@ -1,6 +1,8 @@
1
1
  import { signal, isSignal } from '@angular/core';
2
2
  import { SIGNAL_TREE_MESSAGES, SIGNAL_TREE_CONSTANTS } from './constants.js';
3
+ import { applyDerivedFactories } from './internals/merge-derived.js';
3
4
  import { SignalMemoryManager } from './memory/memory-manager.js';
5
+ import { getPathNotifier } from './path-notifier.js';
4
6
  import { SecurityValidator } from './security/security-validator.js';
5
7
  import { createLazySignalTree, unwrap } from './utils.js';
6
8
  import { deepEqual } from '../deep-equal.js';
@@ -172,6 +174,9 @@ function create(initialState, config) {
172
174
  const useLazy = shouldUseLazy(initialState, config, estimatedSize);
173
175
  let signalState;
174
176
  let memoryManager;
177
+ try {
178
+ getPathNotifier().setBatchingEnabled(Boolean(config.batchUpdates !== false));
179
+ } catch {}
175
180
  if (useLazy && typeof initialState === 'object') {
176
181
  try {
177
182
  memoryManager = new SignalMemoryManager();
@@ -271,8 +276,112 @@ function create(initialState, config) {
271
276
  }
272
277
  return tree;
273
278
  }
274
- function signalTree(initialState, config = {}) {
275
- return create(initialState, config);
279
+ function signalTree(initialState, configOrDerived) {
280
+ const isFactory = typeof configOrDerived === 'function';
281
+ const config = isFactory ? {} : configOrDerived ?? {};
282
+ const baseTree = create(initialState, config);
283
+ const builder = createBuilder(baseTree);
284
+ if (isFactory) {
285
+ return builder.derived(configOrDerived);
286
+ }
287
+ return builder;
288
+ }
289
+ function createBuilder(baseTree) {
290
+ const derivedQueue = [];
291
+ let isFinalized = false;
292
+ const finalize = () => {
293
+ if (isFinalized) return;
294
+ isFinalized = true;
295
+ if (derivedQueue.length > 0) {
296
+ applyDerivedFactories(baseTree.$, derivedQueue);
297
+ }
298
+ };
299
+ const builder = function (arg) {
300
+ if (arguments.length === 0) {
301
+ return baseTree();
302
+ }
303
+ return baseTree(arg);
304
+ };
305
+ builder[NODE_ACCESSOR_SYMBOL] = true;
306
+ Object.defineProperty(builder, 'state', {
307
+ get() {
308
+ finalize();
309
+ return baseTree.state;
310
+ },
311
+ enumerable: false,
312
+ configurable: true
313
+ });
314
+ Object.defineProperty(builder, '$', {
315
+ get() {
316
+ finalize();
317
+ return baseTree.$;
318
+ },
319
+ enumerable: false,
320
+ configurable: true
321
+ });
322
+ Object.defineProperty(builder, 'with', {
323
+ value: function (enhancer) {
324
+ const enhanced = baseTree.with(enhancer);
325
+ const newBuilder = createBuilder(enhanced);
326
+ for (const key of Object.keys(enhanced)) {
327
+ if (key !== '$' && key !== 'state' && key !== 'with' && key !== 'bind' && key !== 'destroy' && key !== 'derived') {
328
+ try {
329
+ newBuilder[key] = enhanced[key];
330
+ } catch {}
331
+ }
332
+ }
333
+ for (const factory of derivedQueue) {
334
+ newBuilder.derived(factory);
335
+ }
336
+ return newBuilder;
337
+ },
338
+ enumerable: false,
339
+ writable: false,
340
+ configurable: true
341
+ });
342
+ if (typeof baseTree.bind === 'function') {
343
+ Object.defineProperty(builder, 'bind', {
344
+ value: baseTree.bind.bind(baseTree),
345
+ enumerable: false,
346
+ writable: false,
347
+ configurable: true
348
+ });
349
+ } else {
350
+ Object.defineProperty(builder, 'bind', {
351
+ value: () => builder,
352
+ enumerable: false,
353
+ writable: false,
354
+ configurable: true
355
+ });
356
+ }
357
+ if (typeof baseTree.destroy === 'function') {
358
+ Object.defineProperty(builder, 'destroy', {
359
+ value: baseTree.destroy.bind(baseTree),
360
+ enumerable: false,
361
+ writable: true,
362
+ configurable: true
363
+ });
364
+ } else {
365
+ Object.defineProperty(builder, 'destroy', {
366
+ value: () => {},
367
+ enumerable: false,
368
+ writable: true,
369
+ configurable: true
370
+ });
371
+ }
372
+ Object.defineProperty(builder, 'derived', {
373
+ value: function (factory) {
374
+ if (isFinalized) {
375
+ throw new Error('SignalTree: Cannot add derived() after tree.$ has been accessed. ' + 'Chain all .derived() calls before accessing $.');
376
+ }
377
+ derivedQueue.push(factory);
378
+ return builder;
379
+ },
380
+ enumerable: false,
381
+ writable: false,
382
+ configurable: true
383
+ });
384
+ return builder;
276
385
  }
277
386
 
278
387
  export { isNodeAccessor, signalTree };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@signaltree/core",
3
- "version": "6.2.4",
3
+ "version": "6.4.0",
4
4
  "description": "Lightweight, type-safe signal-based state management for Angular. Core package providing hierarchical signal trees, basic entity management, and async actions.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
package/src/index.d.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  export { signalTree } from './lib/signal-tree';
2
2
  export type { ISignalTree, SignalTree, SignalTreeBase, FullSignalTree, ProdSignalTree, TreeNode, CallableWritableSignal, AccessibleNode, NodeAccessor, Primitive, NotFn, TreeConfig, TreePreset, Enhancer, EnhancerMeta, EnhancerWithMeta, EntitySignal, EntityMapMarker, EntityConfig, MutationOptions, AddOptions, AddManyOptions, TimeTravelEntry, TimeTravelMethods, } from './lib/types';
3
3
  export { entityMap } from './lib/types';
4
+ export type { ProcessDerived, DeepMergeTree, DerivedFactory, } from './lib/internals/derived-types';
5
+ export type { SignalTreeBuilder } from './lib/internals/builder-types';
6
+ export { derived, isDerivedMarker, type DerivedMarker, type DerivedType, } from './lib/markers/derived';
4
7
  export { equal, deepEqual, isNodeAccessor, isAnySignal, toWritableSignal, parsePath, composeEnhancers, isBuiltInObject, createLazySignalTree, } from './lib/utils';
5
8
  export { createEditSession, type EditSession, type UndoRedoHistory, } from './lib/edit-session';
6
9
  export { getPathNotifier } from './lib/path-notifier';
@@ -0,0 +1,13 @@
1
+ import type { ProcessDerived } from './derived-types';
2
+ import type { ISignalTree, TreeNode } from '../types';
3
+ export interface SignalTreeBuilder<TSource, TAccum = TreeNode<TSource>> {
4
+ (): TSource;
5
+ (value: TSource): void;
6
+ (updater: (current: TSource) => TSource): void;
7
+ readonly $: TAccum;
8
+ readonly state: TAccum;
9
+ with<TAdded>(enhancer: (tree: ISignalTree<TSource>) => ISignalTree<TSource> & TAdded): SignalTreeBuilder<TSource, TAccum> & TAdded;
10
+ bind(thisArg?: unknown): (value?: TSource) => TSource | void;
11
+ destroy(): void;
12
+ derived<TDerived extends object>(factory: ($: TAccum) => TDerived): SignalTreeBuilder<TSource, TAccum & ProcessDerived<TDerived>>;
13
+ }
@@ -0,0 +1,10 @@
1
+ import { Signal } from '@angular/core';
2
+ import type { DerivedMarker } from '../markers/derived';
3
+ import type { TreeNode } from '../types';
4
+ export type ProcessDerived<T> = T extends DerivedMarker<infer R> ? Signal<R> : T extends Signal<infer S> ? Signal<S> : T extends object ? {
5
+ [P in keyof T]: ProcessDerived<T[P]>;
6
+ } : never;
7
+ export type DeepMergeTree<TSource, TDerived> = {
8
+ [K in keyof TSource | keyof TDerived]: K extends keyof TSource ? K extends keyof TDerived ? TSource[K] extends object ? TDerived[K] extends object ? TDerived[K] extends DerivedMarker<infer R> ? Signal<R> : TSource[K] & DeepMergeTree<TSource[K], ProcessDerived<TDerived[K]>> : TSource[K] : ProcessDerived<TDerived[K]> : TSource[K] : K extends keyof TDerived ? ProcessDerived<TDerived[K]> : never;
9
+ };
10
+ export type DerivedFactory<TSource, TDerived> = ($: TreeNode<TSource>) => TDerived;
@@ -0,0 +1,4 @@
1
+ type AnyRecord = Record<string, unknown>;
2
+ export declare function mergeDerivedState($: AnyRecord, derivedDef: unknown, path?: string): void;
3
+ export declare function applyDerivedFactories($: AnyRecord, factories: Array<($: AnyRecord) => object>): void;
4
+ export {};
@@ -0,0 +1,10 @@
1
+ declare const DERIVED_MARKER: unique symbol;
2
+ export interface DerivedMarker<T> {
3
+ readonly [DERIVED_MARKER]: true;
4
+ readonly factory: () => T;
5
+ readonly __type?: T;
6
+ }
7
+ export type DerivedType<T> = T extends DerivedMarker<infer R> ? R : never;
8
+ export declare function derived<T>(factory: () => T): DerivedMarker<T>;
9
+ export declare function isDerivedMarker(value: unknown): value is DerivedMarker<unknown>;
10
+ export {};
@@ -0,0 +1 @@
1
+ export { derived, isDerivedMarker, getDerivedMarkerSymbol, type DerivedMarker, type DerivedType, } from './derived';
@@ -1,3 +1,6 @@
1
- import type { TreeConfig, NodeAccessor, ISignalTree } from './types';
1
+ import { SignalTreeBuilder } from './internals/builder-types';
2
+ import { ProcessDerived } from './internals/derived-types';
3
+ import type { TreeNode, TreeConfig, NodeAccessor } from './types';
2
4
  export declare function isNodeAccessor(value: unknown): value is NodeAccessor<unknown>;
3
- export declare function signalTree<T extends object>(initialState: T, config?: TreeConfig): ISignalTree<T>;
5
+ export declare function signalTree<T extends object, TDerived extends object>(initialState: T, derivedFactory: ($: TreeNode<T>) => TDerived): SignalTreeBuilder<T, TreeNode<T> & ProcessDerived<TDerived>>;
6
+ export declare function signalTree<T extends object>(initialState: T, config?: TreeConfig): SignalTreeBuilder<T, TreeNode<T>>;