@signaltree/core 9.2.1 → 9.3.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
@@ -125,7 +125,7 @@ Follow these principles for idiomatic SignalTree code:
125
125
  ### 1. Expose signals directly (no computed wrappers)
126
126
 
127
127
  ```typescript
128
- const tree = signalTree(initialState); // No .with(entities()) needed in v7+ (deprecated in v6, removed in v7)
128
+ const tree = signalTree(initialState);
129
129
  const $ = tree.$; // Shorthand for state access
130
130
 
131
131
  // ✅ SignalTree-first: Direct signal exposure
@@ -218,7 +218,7 @@ Alternatively, await a microtask (`await Promise.resolve()`) to allow the automa
218
218
  To disable automatic microtask batching for a specific tree instance:
219
219
 
220
220
  ```typescript
221
- const tree = signalTree(initialState, { batching: false });
221
+ const tree = signalTree(initialState, { batchUpdates: false });
222
222
  ```
223
223
 
224
224
  Use this only for rare cases that truly require synchronous notifications (most apps should keep batching enabled).
@@ -293,13 +293,20 @@ const tree = signalTree({
293
293
  message: 'Hello World',
294
294
  });
295
295
 
296
- // Read values (these are Angular signals)
296
+ // Read values (these are Angular signals — always works)
297
297
  console.log(tree.$.count()); // 0
298
298
  console.log(tree.$.message()); // 'Hello World'
299
299
 
300
- // Update values
301
- tree.$.count(5);
302
- tree.$.message('Updated!');
300
+ // ⚠️ CALLABLE SYNTAX REQUIRES BUILD TRANSFORM
301
+ // Lines below use tree.$.count(5) setter syntax. This requires the
302
+ // @signaltree/callable-syntax build-time transform (separate dev dependency).
303
+ // WITHOUT the transform, use .set()/.update() instead — these always work:
304
+ // tree.$.count.set(5);
305
+ // tree.$.message.set('Updated!');
306
+ // tree.$.count.update((n) => n + 1);
307
+ // See: https://github.com/JBorgia/signaltree/blob/main/packages/callable-syntax/README.md
308
+ tree.$.count(5); // requires @signaltree/callable-syntax transform
309
+ tree.$.message('Updated!'); // requires @signaltree/callable-syntax transform
303
310
 
304
311
  // Use in an Angular component
305
312
  @Component({
@@ -336,6 +343,10 @@ const tree = signalTree({
336
343
  });
337
344
 
338
345
  // Access nested signals with full type safety
346
+ // Requires @signaltree/callable-syntax. Without the transform, use:
347
+ // tree.$.user.name.set('Jane Doe');
348
+ // tree.$.user.preferences.theme.set('light');
349
+ // tree.$.ui.loading.set(true);
339
350
  tree.$.user.name('Jane Doe');
340
351
  tree.$.user.preferences.theme('light');
341
352
  tree.$.ui.loading(true);
@@ -493,6 +504,8 @@ const tree = signalTree<AppState>({
493
504
  });
494
505
 
495
506
  // Complex updates with type safety
507
+ // Requires @signaltree/callable-syntax. Without the transform, use
508
+ // tree.set((state) => ({ ... })) or leaf .set() / .update() calls instead.
496
509
  tree((state) => ({
497
510
  auth: {
498
511
  ...state.auth,
@@ -652,16 +665,36 @@ All enhancers are exported directly from `@signaltree/core`:
652
665
 
653
666
  **Data Management:**
654
667
 
655
- - `entities()` - Advanced CRUD operations for collections
656
668
  - `createAsyncOperation()` - Async operation management with loading/error states
657
669
  - `trackAsync()` - Track async operations in your state
658
670
  - `serialization()` - State persistence and SSR support
659
671
  - `persistence()` - Auto-save to localStorage/IndexedDB
660
672
 
673
+ **Reactive Side Effects:**
674
+
675
+ - `effects()` - Angular `effect()`-based subscriptions on tree state with cleanup
676
+
677
+ ```typescript
678
+ import { signalTree, effects } from '@signaltree/core';
679
+
680
+ const tree = signalTree({ count: 0, user: { name: 'Alice' } }).with(effects());
681
+
682
+ // Subscribe with automatic cleanup on destroy
683
+ const unsub = tree.subscribe((state) => {
684
+ console.log('State changed:', state.count);
685
+ });
686
+
687
+ // Effect with cleanup callback
688
+ const cleanup = tree.effect((state) => {
689
+ console.log('Count:', state.count);
690
+ return () => console.log('Previous effect cleaned up');
691
+ });
692
+ ```
693
+
661
694
  **Development Tools:**
662
695
 
663
696
  - `devTools()` - Redux DevTools auto-connect, path actions, and time-travel dispatch
664
- - `withTimeTravel()` - Undo/redo functionality
697
+ - `timeTravel()` - Undo/redo functionality
665
698
 
666
699
  #### Additional Packages
667
700
 
@@ -678,24 +711,22 @@ These are the **only** separate packages in the SignalTree ecosystem:
678
711
  ```typescript
679
712
  import { signalTree, batching, devTools } from '@signaltree/core';
680
713
 
681
- // Apply enhancers in order
682
- const tree = signalTree({ count: 0 }).with(
683
- batching(), // Performance optimization
684
- devTools() // Development tools
685
- );
714
+ // Apply enhancers by chaining — each .with() takes a single enhancer
715
+ const tree = signalTree({ count: 0 })
716
+ .with(batching()) // Performance optimization
717
+ .with(devTools()); // Development tools
686
718
  ```
687
719
 
688
720
  **Performance-Focused Stack:**
689
721
 
690
722
  ```typescript
691
- import { signalTree, batching, entities } from '@signaltree/core';
723
+ import { signalTree, batching } from '@signaltree/core';
692
724
 
725
+ // entityMap() markers self-register — no entities() enhancer needed
693
726
  const tree = signalTree({
694
727
  products: entityMap<Product>(),
695
728
  ui: { loading: false },
696
- })
697
- .with(entities()) // Efficient CRUD operations (auto-detects entityMap)
698
- .with(batching()); // Batch updates for optimal rendering
729
+ }).with(batching()); // Batch updates for optimal rendering
699
730
 
700
731
  // Entity CRUD operations
701
732
  tree.$.products.addOne(newProduct);
@@ -708,20 +739,20 @@ const electronics = tree.$.products.all.filter((p) => p.category === 'electronic
708
739
  **Full-Stack Application:**
709
740
 
710
741
  ```typescript
711
- import { signalTree, serialization, withTimeTravel } from '@signaltree/core';
742
+ import { signalTree, serialization, timeTravel } from '@signaltree/core';
712
743
 
713
744
  const tree = signalTree({
714
745
  user: null as User | null,
715
746
  preferences: { theme: 'light' },
716
- }).with(
717
- // withAsync removed — API integration patterns are now covered by async helpers
718
- serialization({
719
- // Auto-save to localStorage
720
- autoSave: true,
721
- storage: 'localStorage',
722
- }),
723
- withTimeTravel() // Undo/redo support
724
- );
747
+ })
748
+ .with(
749
+ serialization({
750
+ // Auto-save to localStorage
751
+ autoSave: true,
752
+ storage: 'localStorage',
753
+ })
754
+ )
755
+ .with(timeTravel()); // Undo/redo support
725
756
 
726
757
  // For async operations, use manual async or async helpers
727
758
  async function fetchUser(id: string) {
@@ -750,12 +781,10 @@ Derived computed signals are preserved across `.with()` chaining, so enhancer co
750
781
  Enhancers can declare metadata for automatic dependency resolution:
751
782
 
752
783
  ```typescript
753
- // Enhancers are automatically ordered based on requirements
754
- const tree = signalTree(state).with(
755
- devTools(), // Requires: core, provides: debugging
756
- batching() // Requires: core, provides: batching
757
- );
758
- // Automatically ordered: batching -> devtools
784
+ // Chain enhancers each .with() takes a single enhancer
785
+ const tree = signalTree(state)
786
+ .with(batching()) // Requires: core, provides: batching
787
+ .with(devTools()); // Requires: core, provides: debugging
759
788
  ```
760
789
 
761
790
  #### Core Stubs
@@ -769,10 +798,10 @@ import { signalTree, entityMap, entities } from '@signaltree/core';
769
798
  const basic = signalTree({ users: [] as User[] });
770
799
  basic.$.users.update((users) => [...users, newUser]);
771
800
 
772
- // With entityMap + entities - use entity helpers
801
+ // With entityMap entity helpers are automatically available (no enhancer needed)
773
802
  const enhanced = signalTree({
774
803
  users: entityMap<User>(),
775
- }).with(entities());
804
+ });
776
805
 
777
806
  enhanced.$.users.addOne(newUser); // ✅ Advanced CRUD operations
778
807
  enhanced.$.users.byId(123)(); // ✅ O(1) lookups
@@ -805,11 +834,8 @@ const tree2 = signalTree(
805
834
  }
806
835
  );
807
836
 
808
- // Structural sharing for memory efficiency
809
- tree.update((state) => ({
810
- ...state, // Reuses unchanged parts
811
- newField: 'value',
812
- }));
837
+ // Structural sharing for memory efficiency — update individual leaves directly
838
+ tree.$.newField.set('value'); // Only the changed leaf re-emits; siblings are unaffected
813
839
  ```
814
840
 
815
841
  ### 7) Extensibility: Custom Markers & Enhancers
@@ -1037,15 +1063,33 @@ const tree = signalTree({
1037
1063
  });
1038
1064
 
1039
1065
  // EntitySignal API
1040
- tree.$.products.setMany([
1066
+ tree.$.products.setAll([
1041
1067
  { id: 1, name: 'Laptop', category: 'electronics', price: 999, inStock: true },
1042
1068
  { id: 2, name: 'Chair', category: 'furniture', price: 199, inStock: false },
1043
1069
  ]);
1044
1070
 
1045
- tree.$.products.all(); // Signal<Product[]> - all entities
1046
- tree.$.products.byId(1); // Signal<Product> | undefined
1047
- tree.$.products.ids(); // Signal<number[]>
1048
- tree.$.products.count(); // Signal<number>
1071
+ tree.$.products.all; // Signal<Product[]> getter, call as .all() to read
1072
+ tree.$.products.all(); // Product[] current value
1073
+ tree.$.products.byId(1); // EntityNode<Product> | undefined — cursor; call () to get value
1074
+ tree.$.products.byId(1)?.(); // Product | undefined — unwrap the cursor
1075
+
1076
+ // Field-level reads and writes via EntityNode
1077
+ // Field properties are computed signals: isSignal() returns true, toObservable() works
1078
+ tree.$.products.byId(1)?.name(); // string — read field reactively
1079
+ tree.$.products.byId(1)?.name.set('New'); // update single field (interceptors fire)
1080
+ tree.$.products.byId(1)?.name.update(n => n.toUpperCase()); // updater
1081
+ tree.$.products.byId(1)?.name.asReadonly(); // Signal<string> — read-only view
1082
+
1083
+ // Entity-level write via callable (replaces entire entity)
1084
+ const node = tree.$.products.byId(1);
1085
+ node?.({ id: 1, name: 'Updated', category: 'electronics', price: 899, inStock: true });
1086
+
1087
+ // Note: writes on a stale node (entity removed) throw "Entity with id X not found"
1088
+ // This is consistent with updateOne() and the rest of the mutation API.
1089
+ tree.$.products.ids; // Signal<number[]> — getter
1090
+ tree.$.products.ids(); // number[] — current value
1091
+ tree.$.products.count; // Signal<number> — getter
1092
+ tree.$.products.count(); // number — current value
1049
1093
 
1050
1094
  // Computed slices (reactive, type-safe)
1051
1095
  tree.$.products.electronics(); // Signal<Product[]> - auto-updates
@@ -1122,6 +1166,8 @@ LoadingState.Error; // 'error'
1122
1166
 
1123
1167
  Auto-syncs state to localStorage with versioning and migration support.
1124
1168
 
1169
+ > ⚠️ **Read first:** [Persistence and Security](../../docs/guides/persistence-and-security.md) covers the threat model and what `stored()` is — and isn't — appropriate for. Short version: fine for UI prefs, never for tokens, secrets, or PII.
1170
+
1125
1171
  ```typescript
1126
1172
  import { signalTree, stored, createStorageKeys, clearStoragePrefix } from '@signaltree/core';
1127
1173
 
@@ -1473,30 +1519,27 @@ const tree = signalTree({
1473
1519
  validationErrors: [] as string[],
1474
1520
  });
1475
1521
 
1476
- // Safe update with validation
1522
+ // Safe update with validation — read current state, transform, write back to leaves
1477
1523
  function safeUpdateItem(id: string, updates: Partial<Item>) {
1478
1524
  try {
1479
- tree.update((state) => {
1480
- const itemIndex = state.items.findIndex((item) => item.id === id);
1481
- if (itemIndex === -1) {
1482
- throw new Error(`Item with id ${id} not found`);
1483
- }
1525
+ const currentItems = tree.$.items();
1526
+ const itemIndex = currentItems.findIndex((item) => item.id === id);
1527
+ if (itemIndex === -1) {
1528
+ throw new Error(`Item with id ${id} not found`);
1529
+ }
1484
1530
 
1485
- const updatedItem = { ...state.items[itemIndex], ...updates };
1531
+ const updatedItem = { ...currentItems[itemIndex], ...updates };
1486
1532
 
1487
- // Validation
1488
- if (!updatedItem.name?.trim()) {
1489
- throw new Error('Item name is required');
1490
- }
1533
+ // Validation
1534
+ if (!updatedItem.name?.trim()) {
1535
+ throw new Error('Item name is required');
1536
+ }
1491
1537
 
1492
- const newItems = [...state.items];
1493
- newItems[itemIndex] = updatedItem;
1538
+ const newItems = [...currentItems];
1539
+ newItems[itemIndex] = updatedItem;
1494
1540
 
1495
- return {
1496
- items: newItems,
1497
- validationErrors: [], // Clear errors on success
1498
- };
1499
- });
1541
+ tree.$.items.set(newItems);
1542
+ tree.$.validationErrors.set([]); // Clear errors on success
1500
1543
  } catch (error) {
1501
1544
  tree.$.validationErrors.update((errors) => [...errors, error instanceof Error ? error.message : 'Unknown error']);
1502
1545
  }
@@ -1521,7 +1564,7 @@ const tree = signalTree({
1521
1564
  // Basic operations included in core
1522
1565
  tree.$.users.set([...users, newUser]);
1523
1566
  tree.$.ui.loading.set(true);
1524
- tree.effect(() => console.log('State changed'));
1567
+ // For reactive effects use Angular's built-in effect(): effect(() => console.log(tree.$.count()))
1525
1568
  ```
1526
1569
 
1527
1570
  ### Performance-Enhanced Composition
@@ -1556,16 +1599,16 @@ const filteredProducts = computed(() => {
1556
1599
  ```typescript
1557
1600
  import { signalTree, entityMap, entities } from '@signaltree/core';
1558
1601
 
1559
- // Add data management capabilities (+2.77KB total)
1602
+ // entityMap() markers self-register entity operations available immediately
1560
1603
  const tree = signalTree({
1561
1604
  users: entityMap<User>(),
1562
1605
  posts: entityMap<Post>(),
1563
1606
  ui: { loading: false, error: null as string | null },
1564
- }).with(entities());
1607
+ });
1565
1608
 
1566
1609
  // Advanced entity operations via tree.$ accessor
1567
1610
  tree.$.users.addOne(newUser);
1568
- tree.$.users.selectBy((u) => u.active);
1611
+ tree.$.users.where((u) => u.active);
1569
1612
  tree.$.users.updateMany([{ id: '1', changes: { status: 'active' } }]);
1570
1613
 
1571
1614
  // Entity helpers work with nested structures
@@ -1583,13 +1626,13 @@ const appTree = signalTree({
1583
1626
  reports: entityMap<Report>(),
1584
1627
  },
1585
1628
  },
1586
- }).with(entities());
1629
+ });
1587
1630
 
1588
1631
  // Access nested entities using tree.$ accessor
1589
- appTree.$.app.data.users.selectBy((u) => u.isAdmin); // Filtered signal
1590
- appTree.$.app.data.products.selectTotal(); // Count signal
1632
+ appTree.$.app.data.users.where((u) => u.isAdmin); // Filtered signal
1633
+ appTree.$.app.data.products.count(); // Count signal
1591
1634
  appTree.$.admin.data.logs.all; // All items as array
1592
- appTree.$.admin.data.reports.selectIds(); // ID array signal
1635
+ appTree.$.admin.data.reports.ids(); // ID array signal
1593
1636
 
1594
1637
  // For async operations, use manual async or async helpers
1595
1638
  async function fetchUsers() {
@@ -1608,36 +1651,35 @@ async function fetchUsers() {
1608
1651
  ### Full-Featured Development Composition
1609
1652
 
1610
1653
  ```typescript
1611
- import { signalTree, batching, entities, serialization, withTimeTravel, devTools } from '@signaltree/core';
1654
+ import { signalTree, batching, serialization, timeTravel, devTools } from '@signaltree/core';
1612
1655
 
1613
- // Full development stack (example)
1656
+ // Full development stack — chain each enhancer with a separate .with() call
1657
+ // entityMap() markers self-register; no entities() enhancer needed
1614
1658
  const tree = signalTree({
1615
1659
  app: {
1616
1660
  user: null as User | null,
1617
1661
  preferences: { theme: 'light' },
1618
1662
  data: { users: [], posts: [] },
1619
1663
  },
1620
- }).with(
1621
- batching(), // Performance
1622
- entities(), // Data management
1623
- // withAsync removed — use async helpers for API integration
1624
- serialization({
1625
- // State persistence
1626
- autoSave: true,
1627
- storage: 'localStorage',
1628
- }),
1629
- withTimeTravel({
1630
- // Undo/redo
1631
- maxHistory: 50,
1632
- }),
1633
- devTools({
1634
- // Debug tools (dev only)
1635
- name: 'MyApp',
1636
- enableTimeTravel: true,
1637
- includePaths: ['app.*', 'ui.*'],
1638
- formatPath: (path) => path.replace(/\.(\d+)/g, '[$1]'),
1639
- })
1640
- );
1664
+ })
1665
+ .with(batching()) // Performance
1666
+ .with(
1667
+ serialization({
1668
+ // State persistence
1669
+ autoSave: true,
1670
+ storage: 'localStorage',
1671
+ })
1672
+ )
1673
+ .with(timeTravel({ maxHistorySize: 50 })) // Undo/redo
1674
+ .with(
1675
+ devTools({
1676
+ // Debug tools (dev only)
1677
+ name: 'MyApp',
1678
+ enableTimeTravel: true,
1679
+ includePaths: ['app.*', 'ui.*'],
1680
+ formatPath: (path) => path.replace(/\.(\d+)/g, '[$1]'),
1681
+ })
1682
+ );
1641
1683
 
1642
1684
  // Rich feature set available
1643
1685
  async function fetchUser(id: string) {
@@ -1710,17 +1752,13 @@ In Redux DevTools you will see a single instance named `"MyApp SignalTree"` with
1710
1752
  import { signalTree, batching, entities, serialization } from '@signaltree/core';
1711
1753
 
1712
1754
  // Production build (no dev tools)
1713
- const tree = signalTree(initialState).with(
1714
- batching(), // Performance optimization
1715
- entities(), // Data management
1716
- // withAsync removed — use async helpers for API integration
1717
- serialization({
1718
- // User preferences
1755
+ const tree = signalTree(initialState)
1756
+ .with(batching()) // Performance optimization
1757
+ .with(serialization({ // User preferences
1719
1758
  autoSave: true,
1720
1759
  storage: 'localStorage',
1721
1760
  key: 'app-v1.2.3',
1722
- })
1723
- );
1761
+ }));
1724
1762
 
1725
1763
  // Clean, efficient, production-ready
1726
1764
  ```
@@ -1728,28 +1766,22 @@ const tree = signalTree(initialState).with(
1728
1766
  ### Conditional Enhancement
1729
1767
 
1730
1768
  ```typescript
1731
- import { signalTree, batching, entities, devTools, withTimeTravel } from '@signaltree/core';
1769
+ import { signalTree, batching, devTools, timeTravel } from '@signaltree/core';
1732
1770
 
1733
1771
  const isDevelopment = process.env['NODE_ENV'] === 'development';
1734
1772
 
1735
1773
  // Conditional enhancement based on environment
1736
- const tree = signalTree(state).with(
1737
- batching(), // Always include performance
1738
- entities(), // Always include data management
1739
- ...(isDevelopment
1740
- ? [
1741
- // Development-only features
1742
- devTools(),
1743
- withTimeTravel(),
1744
- ]
1745
- : [])
1746
- );
1774
+ // entityMap() markers self-register; chain each enhancer with .with()
1775
+ let tree = signalTree(state).with(batching()); // Always include performance
1776
+ if (isDevelopment) {
1777
+ tree = tree.with(devTools()).with(timeTravel()); // Development-only features
1778
+ }
1747
1779
  ```
1748
1780
 
1749
1781
  ### Preset-Based Composition
1750
1782
 
1751
1783
  ```typescript
1752
- import { signalTree, batching, devTools, withTimeTravel } from '@signaltree/core';
1784
+ import { signalTree, batching, devTools, timeTravel } from '@signaltree/core';
1753
1785
 
1754
1786
  // Compose the enhancers you actually need
1755
1787
  const devTree = signalTree({
@@ -1759,7 +1791,7 @@ const devTree = signalTree({
1759
1791
  })
1760
1792
  .with(batching())
1761
1793
  .with(devTools())
1762
- .with(withTimeTravel());
1794
+ .with(timeTravel());
1763
1795
  ```
1764
1796
 
1765
1797
  > **9.0.1 note:** Preset factories (`createDevTree`, `TREE_PRESETS`, etc.) were removed. Compose enhancers directly with `.with()`.
@@ -1779,10 +1811,7 @@ const tree = signalTree(state);
1779
1811
  // Phase 2: Add performance when needed
1780
1812
  const tree2 = tree.with(batching());
1781
1813
 
1782
- // Phase 3: Add data management for collections
1783
- const tree3 = tree2.with(entities());
1784
-
1785
- // Phase 4: Add async for API integration
1814
+ // Phase 3: Add async for API integration
1786
1815
  // withAsync removed — no explicit async enhancer; use async helpers instead
1787
1816
 
1788
1817
  // Each phase is fully functional and production-ready
@@ -1801,7 +1830,7 @@ if (needsPerformance) {
1801
1830
  }
1802
1831
 
1803
1832
  if (needsTimeTravel) {
1804
- tree = tree.with(withTimeTravel());
1833
+ tree = tree.with(timeTravel());
1805
1834
  }
1806
1835
  ```
1807
1836
 
@@ -1858,7 +1887,7 @@ For fair, reproducible measurements that reflect your app and hardware, use the
1858
1887
  {{ userTree.$.error() }}
1859
1888
  <button (click)="loadUsers()">Retry</button>
1860
1889
  </div>
1861
- } @else { @for (user of users.selectAll()(); track user.id) {
1890
+ } @else { @for (user of users.all(); track user.id) {
1862
1891
  <div class="user-card">
1863
1892
  <h3>{{ user.name }}</h3>
1864
1893
  <p>{{ user.email }}</p>
@@ -1954,18 +1983,6 @@ class UserManagerComponent implements OnInit {
1954
1983
  }
1955
1984
  ```
1956
1985
 
1957
- ]
1958
-
1959
- }
1960
- }));
1961
-
1962
- // Get entire state as plain object
1963
- const currentState = tree.unwrap();
1964
- console.log('Current app state:', currentState);
1965
-
1966
- ```
1967
- });
1968
- ```
1969
1986
 
1970
1987
  ## Core features
1971
1988
 
@@ -2034,18 +2051,33 @@ async function handleLoadUsers() {
2034
2051
 
2035
2052
  ### Reactive effects
2036
2053
 
2054
+ Use Angular's built-in `effect()` to react to signal changes, or the `effects()` enhancer for tree-level subscriptions with managed cleanup:
2055
+
2037
2056
  ```typescript
2038
- // Create reactive effects
2039
- tree.effect((state) => {
2040
- console.log(`User: ${state.user.name}, Theme: ${state.settings.theme}`);
2041
- });
2057
+ import { effect } from '@angular/core';
2042
2058
 
2043
- // Manual subscriptions
2044
- const unsubscribe = tree.subscribe((state) => {
2045
- // Handle state changes
2059
+ // React to any signal in the tree — reads are tracked automatically
2060
+ effect(() => {
2061
+ console.log(`User: ${tree.$.user.name()}, Theme: ${tree.$.settings.theme()}`);
2046
2062
  });
2047
2063
  ```
2048
2064
 
2065
+ Or use the `effects()` enhancer for whole-tree subscriptions:
2066
+
2067
+ ```typescript
2068
+ import { signalTree, effects } from '@signaltree/core';
2069
+
2070
+ const tree = signalTree({ count: 0 }).with(effects());
2071
+
2072
+ // Returns cleanup function
2073
+ const stop = tree.subscribe(state => console.log(state.count));
2074
+ // tree.destroy() also cleans up all registered effects
2075
+ ```
2076
+
2077
+ > **Note:** `isSignal(tree.$.users.byId(id)?.name)` returns `true` (v9.3+). Entity field
2078
+ > properties are computed signals — they work with `toObservable()`, Angular DevTools, and
2079
+ > any Angular API that accepts a `Signal<T>`.
2080
+
2049
2081
  ## Core API reference
2050
2082
 
2051
2083
  ### signalTree()
@@ -2059,21 +2091,21 @@ const tree = signalTree(initialState, config?);
2059
2091
  ```typescript
2060
2092
  // State access
2061
2093
  tree.$.property(); // Read signal value
2062
- tree.$.property.set(value); // Update signal
2063
- tree.unwrap(); // Get plain object
2094
+ tree.$.property.set(value); // Set signal
2095
+ tree.$.property.update(fn); // Update signal with function
2064
2096
 
2065
2097
  // Tree operations
2066
- tree.update(updater); // Update entire tree
2067
2098
  tree.updateAndReport(updater); // Update + return changed leaf paths (9.1+)
2068
- tree.effect(fn); // Create reactive effects
2069
- tree.subscribe(fn); // Manual subscriptions
2070
2099
  tree.destroy(); // Cleanup resources
2071
2100
 
2072
- // Entity helpers (when using entityMap + entities)
2073
- // tree.$.users.addOne(user); // Add single entity
2074
- // tree.$.users.byId(id)(); // O(1) lookup by ID
2075
- // tree.$.users.all; // Get all as array
2076
- // tree.$.users.selectBy(pred); // Filtered signal
2101
+ // Entity helpers (entityMap() self-registers no enhancer needed)
2102
+ tree.$.users.addOne(user); // Add single entity
2103
+ tree.$.users.byId(id)(); // O(1) lookup by ID (read whole entity)
2104
+ tree.$.users.byId(id)?.name(); // Read single field (computed signal)
2105
+ tree.$.users.byId(id)?.name.set('Bob'); // Write single field (throws if entity removed)
2106
+ tree.$.users.byId(id)?.name.update(fn); // Updater on single field
2107
+ tree.$.users.all; // Signal<E[]> — all entities
2108
+ tree.$.users.where(pred); // Filtered signal
2077
2109
  ```
2078
2110
 
2079
2111
  ### updateAndReport (9.1+)
@@ -2112,9 +2144,8 @@ const tree = signalTree(initialState).with(batching()).with(timeTravel());
2112
2144
  All enhancers are included in `@signaltree/core`:
2113
2145
 
2114
2146
  - **batching()** - Batch multiple updates for better performance
2115
- - **entities()** - Advanced entity management & CRUD operations
2116
2147
  - **devTools()** - Redux DevTools integration for debugging
2117
- - **withTimeTravel()** - Undo/redo functionality & state history
2148
+ - **timeTravel()** - Undo/redo functionality & state history
2118
2149
  - **serialization()** - State persistence & SSR support
2119
2150
 
2120
2151
  ## When to use core only
@@ -2130,7 +2161,7 @@ Perfect for:
2130
2161
  Consider enhancers when you need:
2131
2162
 
2132
2163
  - ⚡ Performance optimization (batching)
2133
- - 🐛 Advanced debugging (devTools, withTimeTravel)
2164
+ - 🐛 Advanced debugging (devTools, timeTravel)
2134
2165
  - 📦 Entity management (entities)
2135
2166
 
2136
2167
  Consider separate packages when you need:
@@ -2143,7 +2174,7 @@ Consider separate packages when you need:
2143
2174
 
2144
2175
  ### Case Study
2145
2176
 
2146
- Measured from a production Angular mobile application migrating from NgRx Signal Store to SignalTree. Results reflect one team's experience; your mileage will vary depending on app complexity and existing architecture.
2177
+ Snapshot from one production Angular mobile app's NgRx Signal Store SignalTree migration. Original migration measured ~11,700 → ~2,800 lines of state code (~76%) and ~50KB → ~27KB gzipped state bundle (~46%). Both codebases have continued to evolve; re-measuring today the same scope yields a 60–70% reduction depending on definition (apps-only vs apps+libs, narrow vs broad import filter). The directional finding is reproducible — the exact percentages are not. **YMMV** — your migration's reduction depends on app complexity, prior architecture, and how heavily the original code leaned on custom `withX` helpers. The single biggest driver of the savings is cross-cutting concerns (devtools, error banners, telemetry, refresh handling) moving from per-store composition to tree-level enhancers.
2147
2178
 
2148
2179
  | Metric | NgRx | SignalTree | Change |
2149
2180
  | --- | --- | --- | --- |
@@ -2164,7 +2195,7 @@ node_modules/@signaltree/core/skills/using-signaltree/reference/migration-from-n
2164
2195
 
2165
2196
  It covers:
2166
2197
 
2167
- - A mechanical concept-map table: `signalStore` → tree slice + `Ops`, `withState` → initial state, `withMethods` → `Ops` methods, `withComputed` → `computed()` or `.derived()`, `withHooks` → factory body, `rxMethod` → plain method returning `Observable<void>`, `withEntities` → `entityMap()` marker, `patchState` → tree update, `getState` → `unwrap()`, etc.
2198
+ - A mechanical concept-map table: `signalStore` → tree slice + `Ops`, `withState` → initial state, `withMethods` → `Ops` methods, `withComputed` → `computed()` or `.derived()`, `withHooks` → factory body, `rxMethod` → plain method returning `Observable<void>`, `withEntities` → `entityMap()` marker, `patchState` → tree update, `getState` → `tree()` (no-arg call returns current state snapshot`, etc.
2168
2199
  - **Three migration strategies** with explicit decision criteria:
2169
2200
  - **Big-bang** (1–2 stores, single team): one PR, delete legacy in same commit.
2170
2201
  - **Incremental per-domain** (≥3 stores): one PR per store. Includes a **Phase 0** recipe (foundation-only first PR), a sequencing rule (consumers before aggregator removal), and a root-injected `Ops` side-effect hazard warning.
@@ -2297,14 +2328,10 @@ All enhancers are now consolidated in the core package. The following features a
2297
2328
 
2298
2329
  - **batching()** (+1.27KB gzipped) - Batch multiple updates for better performance
2299
2330
 
2300
- ### Advanced Features
2301
-
2302
- - **entities()** (+0.97KB gzipped) - Enhanced CRUD operations & entity management
2303
-
2304
2331
  ### Development Tools
2305
2332
 
2306
2333
  - **devTools()** (+2.49KB gzipped) - Development tools & Redux DevTools integration
2307
- - **withTimeTravel()** (+1.75KB gzipped) - Undo/redo functionality & state history
2334
+ - **timeTravel()** (+1.75KB gzipped) - Undo/redo functionality & state history
2308
2335
 
2309
2336
  ### Integration & Convenience
2310
2337
 
@@ -2324,7 +2351,7 @@ import {
2324
2351
  batching,
2325
2352
  entities,
2326
2353
  devTools,
2327
- withTimeTravel,
2354
+ timeTravel,
2328
2355
  serialization
2329
2356
  } from '@signaltree/core';
2330
2357
  ```
@@ -3,6 +3,7 @@ import { copyTreeProperties } from '../utils/copy-tree-properties.js';
3
3
  import { applyState, snapshotState } from '../../lib/utils.js';
4
4
  import { interceptLeafSignals } from '../../lib/internals/intercept-leaf-signals.js';
5
5
  import { getPathNotifier } from '../../lib/path-notifier.js';
6
+ import { withWriteContext } from '../../lib/write-context.js';
6
7
  import { ENHANCER_META } from '../../lib/types.js';
7
8
 
8
9
  function createActivityTracker(options) {
@@ -904,11 +905,16 @@ function devTools(config = {}) {
904
905
  if (state === undefined || state === null) return;
905
906
  isApplyingExternalState = true;
906
907
  try {
907
- if ('$' in tree) {
908
- applyState(tree.$, state);
909
- } else {
910
- originalTreeCall(state);
911
- }
908
+ withWriteContext({
909
+ intent: 'system',
910
+ source: 'time-travel'
911
+ }, () => {
912
+ if ('$' in tree) {
913
+ applyState(tree.$, state);
914
+ } else {
915
+ originalTreeCall(state);
916
+ }
917
+ });
912
918
  } finally {
913
919
  isApplyingExternalState = false;
914
920
  lastSnapshot = readSnapshot();
@@ -0,0 +1,74 @@
1
+ import { effect, untracked } from '@angular/core';
2
+ import { ENHANCER_META } from '../../lib/types.js';
3
+
4
+ function effects(config = {}) {
5
+ const {
6
+ enabled = true
7
+ } = config;
8
+ const enhancerFn = tree => {
9
+ const cleanupFns = [];
10
+ const methods = {
11
+ effect(effectFn) {
12
+ if (!enabled) {
13
+ return () => {};
14
+ }
15
+ let innerCleanup;
16
+ const effectRef = effect(() => {
17
+ const state = tree();
18
+ if (innerCleanup) {
19
+ untracked(() => innerCleanup());
20
+ }
21
+ innerCleanup = untracked(() => effectFn(state));
22
+ });
23
+ const cleanup = () => {
24
+ if (innerCleanup) {
25
+ innerCleanup();
26
+ }
27
+ effectRef.destroy();
28
+ };
29
+ cleanupFns.push(cleanup);
30
+ return cleanup;
31
+ },
32
+ subscribe(fn) {
33
+ if (!enabled) {
34
+ return () => {};
35
+ }
36
+ const effectRef = effect(() => {
37
+ const state = tree();
38
+ untracked(() => fn(state));
39
+ });
40
+ const cleanup = () => {
41
+ effectRef.destroy();
42
+ };
43
+ cleanupFns.push(cleanup);
44
+ return cleanup;
45
+ }
46
+ };
47
+ const originalDestroy = tree.destroy?.bind(tree);
48
+ tree.destroy = () => {
49
+ cleanupFns.forEach(fn => fn());
50
+ cleanupFns.length = 0;
51
+ if (originalDestroy) {
52
+ originalDestroy();
53
+ }
54
+ };
55
+ return Object.assign(tree, methods);
56
+ };
57
+ const meta = {
58
+ name: 'effects',
59
+ provides: ['effects']
60
+ };
61
+ enhancerFn.metadata = meta;
62
+ enhancerFn[ENHANCER_META] = meta;
63
+ return enhancerFn;
64
+ }
65
+ function enableEffects() {
66
+ return effects({
67
+ enabled: true
68
+ });
69
+ }
70
+ Object.assign((config = {}) => effects(config), {
71
+ enable: enableEffects
72
+ });
73
+
74
+ export { effects, enableEffects };
@@ -1,6 +1,7 @@
1
1
  import { snapshotState } from '../../lib/utils.js';
2
2
  import { interceptLeafSignals } from '../../lib/internals/intercept-leaf-signals.js';
3
3
  import { getPathNotifier } from '../../lib/path-notifier.js';
4
+ import { withWriteContext } from '../../lib/write-context.js';
4
5
  import { deepEqual, deepClone } from './utils.js';
5
6
  import { ENHANCER_META } from '../../lib/types.js';
6
7
 
@@ -122,11 +123,16 @@ class TimeTravelManager {
122
123
  return this.currentIndex < this.history.length - 1;
123
124
  }
124
125
  restoreState(state) {
125
- if (this.restoreStateFn) {
126
- this.restoreStateFn(state);
127
- } else {
128
- this.tree(state);
129
- }
126
+ withWriteContext({
127
+ intent: 'system',
128
+ source: 'time-travel'
129
+ }, () => {
130
+ if (this.restoreStateFn) {
131
+ this.restoreStateFn(state);
132
+ } else {
133
+ this.tree(state);
134
+ }
135
+ });
130
136
  }
131
137
  }
132
138
  function timeTravel(config = {}) {
package/dist/index.js CHANGED
@@ -1,4 +1,6 @@
1
1
  export { signalTree } from './lib/signal-tree.js';
2
+ export { getActiveWriteContext, withWriteContext } from './lib/write-context.js';
3
+ export { interceptLeafSignals } from './lib/internals/intercept-leaf-signals.js';
2
4
  export { ENHANCER_META } from './lib/types.js';
3
5
  export { derivedFrom, externalDerived } from './lib/internals/derived-types.js';
4
6
  export { isDerivedMarker } from './lib/markers/derived.js';
@@ -10,6 +12,7 @@ export { composeEnhancers, isAnySignal, isNodeAccessor, toWritableSignal } from
10
12
  export { getPathNotifier } from './lib/path-notifier.js';
11
13
  export { createEnhancer, resolveEnhancerOrder } from './enhancers/index.js';
12
14
  export { batching } from './enhancers/batching/batching.js';
15
+ export { effects } from './enhancers/effects/effects.js';
13
16
  export { timeTravel } from './enhancers/time-travel/time-travel.js';
14
17
  export { persistence, serialization } from './enhancers/serialization/serialization.js';
15
18
  export { devTools } from './enhancers/devtools/devtools.js';
@@ -41,14 +41,7 @@ const DEV_MESSAGES = {
41
41
  EFFECT_NO_CONTEXT: 'no angular context',
42
42
  SUBSCRIBE_NO_CONTEXT: 'no angular context'
43
43
  };
44
- const PROD_MESSAGES = (() => {
45
- const out = {};
46
- let i = 0;
47
- for (const k of Object.keys(DEV_MESSAGES)) {
48
- out[k] = String(i++);
49
- }
50
- return out;
51
- })();
44
+ const PROD_MESSAGES = DEV_MESSAGES;
52
45
  const _isProdByEnv = Boolean(typeof globalThis === 'object' && globalThis !== null && 'process' in globalThis && typeof globalThis.process === 'object' && 'env' in globalThis.process && globalThis.process.env.NODE_ENV === 'production');
53
46
  const _isDev = typeof ngDevMode !== 'undefined' ? Boolean(ngDevMode) : !_isProdByEnv;
54
47
  const isDev = _isDev;
@@ -20,12 +20,39 @@ function createEntitySignal(config, pathNotifier, basePath) {
20
20
  mapSignal.set(map);
21
21
  }
22
22
  function createEntityNode(id, entity) {
23
- const node = () => mapSignal().get(id);
23
+ const node = valueOrUpdater => {
24
+ if (valueOrUpdater === undefined) {
25
+ return mapSignal().get(id);
26
+ }
27
+ const current = storage.get(id);
28
+ if (current === undefined) {
29
+ throw new Error(`Entity with id ${String(id)} not found`);
30
+ }
31
+ if (typeof valueOrUpdater === 'function') {
32
+ api.updateOne(id, valueOrUpdater(current));
33
+ } else {
34
+ api.updateOne(id, valueOrUpdater);
35
+ }
36
+ return undefined;
37
+ };
24
38
  for (const key of Object.keys(entity)) {
25
- Object.defineProperty(node, key, {
26
- get: () => {
27
- return () => mapSignal().get(id)?.[key];
39
+ const fieldKey = key;
40
+ const fieldSignal = computed(() => mapSignal().get(id)?.[fieldKey]);
41
+ Object.assign(fieldSignal, {
42
+ set: value => {
43
+ api.updateOne(id, {
44
+ [fieldKey]: value
45
+ });
46
+ },
47
+ update: fn => {
48
+ api.updateOne(id, {
49
+ [fieldKey]: fn(mapSignal().get(id)?.[fieldKey])
50
+ });
28
51
  },
52
+ asReadonly: () => fieldSignal
53
+ });
54
+ Object.defineProperty(node, key, {
55
+ get: () => fieldSignal,
29
56
  enumerable: true,
30
57
  configurable: true
31
58
  });
@@ -117,18 +144,29 @@ function createEntitySignal(config, pathNotifier, basePath) {
117
144
  return id;
118
145
  },
119
146
  addMany(entities, opts) {
120
- const idsToAdd = [];
147
+ const mode = opts?.mode ?? 'strict';
148
+ const toProcess = [];
121
149
  for (const entity of entities) {
122
150
  const id = opts?.selectId?.(entity) ?? selectId(entity);
123
151
  if (storage.has(id)) {
124
- throw new Error(`Entity with id ${String(id)} already exists`);
152
+ if (mode === 'strict') {
153
+ throw new Error(`Entity with id ${String(id)} already exists`);
154
+ } else if (mode === 'skip') {
155
+ continue;
156
+ }
125
157
  }
126
- idsToAdd.push(id);
158
+ toProcess.push({
159
+ entity,
160
+ id
161
+ });
127
162
  }
163
+ if (toProcess.length === 0) return [];
164
+ const processedIds = [];
128
165
  const addedEntities = [];
129
- for (let i = 0; i < entities.length; i++) {
130
- const entity = entities[i];
131
- const id = idsToAdd[i];
166
+ for (const {
167
+ entity,
168
+ id
169
+ } of toProcess) {
132
170
  let transformedEntity = entity;
133
171
  for (const handler of interceptHandlers) {
134
172
  const ctx = {
@@ -145,6 +183,7 @@ function createEntitySignal(config, pathNotifier, basePath) {
145
183
  }
146
184
  storage.set(id, transformedEntity);
147
185
  nodeCache.delete(id);
186
+ processedIds.push(id);
148
187
  addedEntities.push({
149
188
  id,
150
189
  entity: transformedEntity
@@ -165,7 +204,7 @@ function createEntitySignal(config, pathNotifier, basePath) {
165
204
  handler.onAdd?.(entity, id);
166
205
  }
167
206
  }
168
- return idsToAdd;
207
+ return processedIds;
169
208
  },
170
209
  updateOne(id, changes) {
171
210
  const entity = storage.get(id);
@@ -1,3 +1,5 @@
1
+ import { getActiveWriteContext } from '../write-context.js';
2
+
1
3
  function interceptLeafSignals(root, onWrite, options = {}) {
2
4
  const restorers = [];
3
5
  const seen = new WeakSet();
@@ -38,13 +40,13 @@ function interceptLeafSignals(root, onWrite, options = {}) {
38
40
  const prev = original();
39
41
  originalSet(value);
40
42
  const next = original();
41
- if (next !== prev) onWrite(childPath, next, prev);
43
+ if (next !== prev) onWrite(childPath, next, prev, getActiveWriteContext());
42
44
  };
43
45
  original.update = updater => {
44
46
  const prev = original();
45
47
  originalUpdate(updater);
46
48
  const next = original();
47
- if (next !== prev) onWrite(childPath, next, prev);
49
+ if (next !== prev) onWrite(childPath, next, prev, getActiveWriteContext());
48
50
  };
49
51
  continue;
50
52
  }
@@ -0,0 +1,15 @@
1
+ let activeContext;
2
+ function withWriteContext(meta, fn) {
3
+ const previous = activeContext;
4
+ activeContext = meta;
5
+ try {
6
+ return fn();
7
+ } finally {
8
+ activeContext = previous;
9
+ }
10
+ }
11
+ function getActiveWriteContext() {
12
+ return activeContext;
13
+ }
14
+
15
+ export { getActiveWriteContext, withWriteContext };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@signaltree/core",
3
- "version": "9.2.1",
3
+ "version": "9.3.0",
4
4
  "description": "Reactive JSON for Angular. JSON branches, reactive leaves. No actions. No reducers. No selectors.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/index.d.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  export { signalTree } from './lib/signal-tree';
2
- export type { ISignalTree, SignalTree, SignalTreeBase, TreeNode, CallableWritableSignal, AccessibleNode, NodeAccessor, Primitive, NotFn, TreeConfig, Enhancer, EnhancerMeta, EnhancerWithMeta, EntitySignal, EntityMapMarker, EntityConfig, MutationOptions, AddOptions, AddManyOptions, TimeTravelEntry, TimeTravelMethods, EnhancerCleanup, } from './lib/types';
2
+ export type { ISignalTree, SignalTree, SignalTreeBase, TreeNode, CallableWritableSignal, AccessibleNode, NodeAccessor, Primitive, NotFn, TreeConfig, Enhancer, EnhancerMeta, EnhancerWithMeta, EntitySignal, EntityMapMarker, EntityConfig, MutationOptions, AddOptions, AddManyOptions, TimeTravelEntry, TimeTravelMethods, EnhancerCleanup, EffectsMethods, UpdateMetadata, } from './lib/types';
3
+ export { withWriteContext, getActiveWriteContext } from './lib/write-context';
4
+ export { interceptLeafSignals } from './lib/internals/intercept-leaf-signals';
3
5
  export { entityMap } from './lib/types';
4
6
  export type { ProcessDerived, DeepMergeTree, DerivedFactory, WithDerived, } from './lib/internals/derived-types';
5
7
  export { derivedFrom, externalDerived } from './lib/internals/derived-types';
@@ -15,6 +17,8 @@ export { createEnhancer, resolveEnhancerOrder } from './enhancers/index';
15
17
  export { ENHANCER_META } from './lib/types';
16
18
  export { batching } from './enhancers/batching/batching';
17
19
  export type { BatchingConfig, BatchingMethods } from './lib/types';
20
+ export { effects } from './enhancers/effects/effects';
21
+ export type { EffectsConfig } from './enhancers/effects/effects';
18
22
  export { timeTravel } from './enhancers/time-travel/time-travel';
19
23
  export { serialization, persistence, } from './enhancers/serialization/serialization';
20
24
  export { devTools } from './enhancers/devtools/devtools';
@@ -1 +1,4 @@
1
- export {};
1
+ import type { UpdateMetadata } from '../types';
2
+ export declare function interceptLeafSignals(root: unknown, onWrite: (path: string, next: unknown, prev: unknown, meta?: UpdateMetadata) => void, options?: {
3
+ maxDepth?: number;
4
+ }): () => void;
@@ -3,6 +3,14 @@ import { FormMarker, FormSignal } from './markers/form';
3
3
  import { StatusMarker, StatusSignal } from './markers/status';
4
4
  import { StoredMarker, StoredSignal } from './markers/stored';
5
5
  import { SecurityValidatorConfig } from './security/security-validator';
6
+ export interface UpdateMetadata {
7
+ intent?: 'hydrate' | 'reset' | 'bulk' | 'migration' | 'user' | 'system';
8
+ source?: 'serialization' | 'time-travel' | 'devtools' | 'user' | 'system';
9
+ suppressGuardrails?: boolean;
10
+ correlationId?: string;
11
+ timestamp?: number;
12
+ [key: string]: unknown;
13
+ }
6
14
  export interface TimeTravelConfig {
7
15
  enabled?: boolean;
8
16
  maxHistorySize?: number;
@@ -0,0 +1,3 @@
1
+ import type { UpdateMetadata } from './types';
2
+ export declare function withWriteContext<R>(meta: UpdateMetadata, fn: () => R): R;
3
+ export declare function getActiveWriteContext(): UpdateMetadata | undefined;