@signaltree/core 9.2.0 → 9.2.2

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({
@@ -652,16 +659,37 @@ All enhancers are exported directly from `@signaltree/core`:
652
659
 
653
660
  **Data Management:**
654
661
 
655
- - `entities()` - Advanced CRUD operations for collections
656
662
  - `createAsyncOperation()` - Async operation management with loading/error states
657
663
  - `trackAsync()` - Track async operations in your state
658
664
  - `serialization()` - State persistence and SSR support
659
665
  - `persistence()` - Auto-save to localStorage/IndexedDB
660
666
 
667
+ **Reactive Side Effects:**
668
+
669
+ - `effects()` - Angular `effect()`-based subscriptions on tree state with cleanup
670
+
671
+ ```typescript
672
+ import { signalTree, effects } from '@signaltree/core';
673
+
674
+ const tree = signalTree({ count: 0, user: { name: 'Alice' } })
675
+ .with(effects());
676
+
677
+ // Subscribe with automatic cleanup on destroy
678
+ const unsub = tree.subscribe(state => {
679
+ console.log('State changed:', state.count);
680
+ });
681
+
682
+ // Effect with cleanup callback
683
+ const cleanup = tree.effect(state => {
684
+ console.log('Count:', state.count);
685
+ return () => console.log('Previous effect cleaned up');
686
+ });
687
+ ```
688
+
661
689
  **Development Tools:**
662
690
 
663
691
  - `devTools()` - Redux DevTools auto-connect, path actions, and time-travel dispatch
664
- - `withTimeTravel()` - Undo/redo functionality
692
+ - `timeTravel()` - Undo/redo functionality
665
693
 
666
694
  #### Additional Packages
667
695
 
@@ -678,23 +706,22 @@ These are the **only** separate packages in the SignalTree ecosystem:
678
706
  ```typescript
679
707
  import { signalTree, batching, devTools } from '@signaltree/core';
680
708
 
681
- // Apply enhancers in order
682
- const tree = signalTree({ count: 0 }).with(
683
- batching(), // Performance optimization
684
- devTools() // Development tools
685
- );
709
+ // Apply enhancers by chaining — each .with() takes a single enhancer
710
+ const tree = signalTree({ count: 0 })
711
+ .with(batching()) // Performance optimization
712
+ .with(devTools()); // Development tools
686
713
  ```
687
714
 
688
715
  **Performance-Focused Stack:**
689
716
 
690
717
  ```typescript
691
- import { signalTree, batching, entities } from '@signaltree/core';
718
+ import { signalTree, batching } from '@signaltree/core';
692
719
 
720
+ // entityMap() markers self-register — no entities() enhancer needed
693
721
  const tree = signalTree({
694
722
  products: entityMap<Product>(),
695
723
  ui: { loading: false },
696
724
  })
697
- .with(entities()) // Efficient CRUD operations (auto-detects entityMap)
698
725
  .with(batching()); // Batch updates for optimal rendering
699
726
 
700
727
  // Entity CRUD operations
@@ -708,20 +735,17 @@ const electronics = tree.$.products.all.filter((p) => p.category === 'electronic
708
735
  **Full-Stack Application:**
709
736
 
710
737
  ```typescript
711
- import { signalTree, serialization, withTimeTravel } from '@signaltree/core';
738
+ import { signalTree, serialization, timeTravel } from '@signaltree/core';
712
739
 
713
740
  const tree = signalTree({
714
741
  user: null as User | null,
715
742
  preferences: { theme: 'light' },
716
- }).with(
717
- // withAsync removed — API integration patterns are now covered by async helpers
718
- serialization({
719
- // Auto-save to localStorage
743
+ })
744
+ .with(serialization({ // Auto-save to localStorage
720
745
  autoSave: true,
721
746
  storage: 'localStorage',
722
- }),
723
- withTimeTravel() // Undo/redo support
724
- );
747
+ }))
748
+ .with(timeTravel()); // Undo/redo support
725
749
 
726
750
  // For async operations, use manual async or async helpers
727
751
  async function fetchUser(id: string) {
@@ -750,12 +774,10 @@ Derived computed signals are preserved across `.with()` chaining, so enhancer co
750
774
  Enhancers can declare metadata for automatic dependency resolution:
751
775
 
752
776
  ```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
777
+ // Chain enhancers each .with() takes a single enhancer
778
+ const tree = signalTree(state)
779
+ .with(batching()) // Requires: core, provides: batching
780
+ .with(devTools()); // Requires: core, provides: debugging
759
781
  ```
760
782
 
761
783
  #### Core Stubs
@@ -769,10 +791,10 @@ import { signalTree, entityMap, entities } from '@signaltree/core';
769
791
  const basic = signalTree({ users: [] as User[] });
770
792
  basic.$.users.update((users) => [...users, newUser]);
771
793
 
772
- // With entityMap + entities - use entity helpers
794
+ // With entityMap entity helpers are automatically available (no enhancer needed)
773
795
  const enhanced = signalTree({
774
796
  users: entityMap<User>(),
775
- }).with(entities());
797
+ });
776
798
 
777
799
  enhanced.$.users.addOne(newUser); // ✅ Advanced CRUD operations
778
800
  enhanced.$.users.byId(123)(); // ✅ O(1) lookups
@@ -805,11 +827,8 @@ const tree2 = signalTree(
805
827
  }
806
828
  );
807
829
 
808
- // Structural sharing for memory efficiency
809
- tree.update((state) => ({
810
- ...state, // Reuses unchanged parts
811
- newField: 'value',
812
- }));
830
+ // Structural sharing for memory efficiency — update individual leaves directly
831
+ tree.$.newField.set('value'); // Only the changed leaf re-emits; siblings are unaffected
813
832
  ```
814
833
 
815
834
  ### 7) Extensibility: Custom Markers & Enhancers
@@ -1037,15 +1056,33 @@ const tree = signalTree({
1037
1056
  });
1038
1057
 
1039
1058
  // EntitySignal API
1040
- tree.$.products.setMany([
1059
+ tree.$.products.setAll([
1041
1060
  { id: 1, name: 'Laptop', category: 'electronics', price: 999, inStock: true },
1042
1061
  { id: 2, name: 'Chair', category: 'furniture', price: 199, inStock: false },
1043
1062
  ]);
1044
1063
 
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>
1064
+ tree.$.products.all; // Signal<Product[]> getter, call as .all() to read
1065
+ tree.$.products.all(); // Product[] current value
1066
+ tree.$.products.byId(1); // EntityNode<Product> | undefined — cursor; call () to get value
1067
+ tree.$.products.byId(1)?.(); // Product | undefined — unwrap the cursor
1068
+
1069
+ // Field-level reads and writes via EntityNode
1070
+ // Field properties are computed signals: isSignal() returns true, toObservable() works
1071
+ tree.$.products.byId(1)?.name(); // string — read field reactively
1072
+ tree.$.products.byId(1)?.name.set('New'); // update single field (interceptors fire)
1073
+ tree.$.products.byId(1)?.name.update(n => n.toUpperCase()); // updater
1074
+ tree.$.products.byId(1)?.name.asReadonly(); // Signal<string> — read-only view
1075
+
1076
+ // Entity-level write via callable (replaces entire entity)
1077
+ const node = tree.$.products.byId(1);
1078
+ node?.({ id: 1, name: 'Updated', category: 'electronics', price: 899, inStock: true });
1079
+
1080
+ // Note: writes on a stale node (entity removed) throw "Entity with id X not found"
1081
+ // This is consistent with updateOne() and the rest of the mutation API.
1082
+ tree.$.products.ids; // Signal<number[]> — getter
1083
+ tree.$.products.ids(); // number[] — current value
1084
+ tree.$.products.count; // Signal<number> — getter
1085
+ tree.$.products.count(); // number — current value
1049
1086
 
1050
1087
  // Computed slices (reactive, type-safe)
1051
1088
  tree.$.products.electronics(); // Signal<Product[]> - auto-updates
@@ -1473,30 +1510,27 @@ const tree = signalTree({
1473
1510
  validationErrors: [] as string[],
1474
1511
  });
1475
1512
 
1476
- // Safe update with validation
1513
+ // Safe update with validation — read current state, transform, write back to leaves
1477
1514
  function safeUpdateItem(id: string, updates: Partial<Item>) {
1478
1515
  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
- }
1516
+ const currentItems = tree.$.items();
1517
+ const itemIndex = currentItems.findIndex((item) => item.id === id);
1518
+ if (itemIndex === -1) {
1519
+ throw new Error(`Item with id ${id} not found`);
1520
+ }
1484
1521
 
1485
- const updatedItem = { ...state.items[itemIndex], ...updates };
1522
+ const updatedItem = { ...currentItems[itemIndex], ...updates };
1486
1523
 
1487
- // Validation
1488
- if (!updatedItem.name?.trim()) {
1489
- throw new Error('Item name is required');
1490
- }
1524
+ // Validation
1525
+ if (!updatedItem.name?.trim()) {
1526
+ throw new Error('Item name is required');
1527
+ }
1491
1528
 
1492
- const newItems = [...state.items];
1493
- newItems[itemIndex] = updatedItem;
1529
+ const newItems = [...currentItems];
1530
+ newItems[itemIndex] = updatedItem;
1494
1531
 
1495
- return {
1496
- items: newItems,
1497
- validationErrors: [], // Clear errors on success
1498
- };
1499
- });
1532
+ tree.$.items.set(newItems);
1533
+ tree.$.validationErrors.set([]); // Clear errors on success
1500
1534
  } catch (error) {
1501
1535
  tree.$.validationErrors.update((errors) => [...errors, error instanceof Error ? error.message : 'Unknown error']);
1502
1536
  }
@@ -1521,7 +1555,7 @@ const tree = signalTree({
1521
1555
  // Basic operations included in core
1522
1556
  tree.$.users.set([...users, newUser]);
1523
1557
  tree.$.ui.loading.set(true);
1524
- tree.effect(() => console.log('State changed'));
1558
+ // For reactive effects use Angular's built-in effect(): effect(() => console.log(tree.$.count()))
1525
1559
  ```
1526
1560
 
1527
1561
  ### Performance-Enhanced Composition
@@ -1556,16 +1590,16 @@ const filteredProducts = computed(() => {
1556
1590
  ```typescript
1557
1591
  import { signalTree, entityMap, entities } from '@signaltree/core';
1558
1592
 
1559
- // Add data management capabilities (+2.77KB total)
1593
+ // entityMap() markers self-register entity operations available immediately
1560
1594
  const tree = signalTree({
1561
1595
  users: entityMap<User>(),
1562
1596
  posts: entityMap<Post>(),
1563
1597
  ui: { loading: false, error: null as string | null },
1564
- }).with(entities());
1598
+ });
1565
1599
 
1566
1600
  // Advanced entity operations via tree.$ accessor
1567
1601
  tree.$.users.addOne(newUser);
1568
- tree.$.users.selectBy((u) => u.active);
1602
+ tree.$.users.where((u) => u.active);
1569
1603
  tree.$.users.updateMany([{ id: '1', changes: { status: 'active' } }]);
1570
1604
 
1571
1605
  // Entity helpers work with nested structures
@@ -1583,13 +1617,13 @@ const appTree = signalTree({
1583
1617
  reports: entityMap<Report>(),
1584
1618
  },
1585
1619
  },
1586
- }).with(entities());
1620
+ });
1587
1621
 
1588
1622
  // 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
1623
+ appTree.$.app.data.users.where((u) => u.isAdmin); // Filtered signal
1624
+ appTree.$.app.data.products.count(); // Count signal
1591
1625
  appTree.$.admin.data.logs.all; // All items as array
1592
- appTree.$.admin.data.reports.selectIds(); // ID array signal
1626
+ appTree.$.admin.data.reports.ids(); // ID array signal
1593
1627
 
1594
1628
  // For async operations, use manual async or async helpers
1595
1629
  async function fetchUsers() {
@@ -1608,36 +1642,29 @@ async function fetchUsers() {
1608
1642
  ### Full-Featured Development Composition
1609
1643
 
1610
1644
  ```typescript
1611
- import { signalTree, batching, entities, serialization, withTimeTravel, devTools } from '@signaltree/core';
1645
+ import { signalTree, batching, serialization, timeTravel, devTools } from '@signaltree/core';
1612
1646
 
1613
- // Full development stack (example)
1647
+ // Full development stack — chain each enhancer with a separate .with() call
1648
+ // entityMap() markers self-register; no entities() enhancer needed
1614
1649
  const tree = signalTree({
1615
1650
  app: {
1616
1651
  user: null as User | null,
1617
1652
  preferences: { theme: 'light' },
1618
1653
  data: { users: [], posts: [] },
1619
1654
  },
1620
- }).with(
1621
- batching(), // Performance
1622
- entities(), // Data management
1623
- // withAsync removed — use async helpers for API integration
1624
- serialization({
1625
- // State persistence
1655
+ })
1656
+ .with(batching()) // Performance
1657
+ .with(serialization({ // State persistence
1626
1658
  autoSave: true,
1627
1659
  storage: 'localStorage',
1628
- }),
1629
- withTimeTravel({
1630
- // Undo/redo
1631
- maxHistory: 50,
1632
- }),
1633
- devTools({
1634
- // Debug tools (dev only)
1660
+ }))
1661
+ .with(timeTravel({ maxHistorySize: 50 })) // Undo/redo
1662
+ .with(devTools({ // Debug tools (dev only)
1635
1663
  name: 'MyApp',
1636
1664
  enableTimeTravel: true,
1637
1665
  includePaths: ['app.*', 'ui.*'],
1638
1666
  formatPath: (path) => path.replace(/\.(\d+)/g, '[$1]'),
1639
- })
1640
- );
1667
+ }));
1641
1668
 
1642
1669
  // Rich feature set available
1643
1670
  async function fetchUser(id: string) {
@@ -1710,17 +1737,13 @@ In Redux DevTools you will see a single instance named `"MyApp SignalTree"` with
1710
1737
  import { signalTree, batching, entities, serialization } from '@signaltree/core';
1711
1738
 
1712
1739
  // 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
1740
+ const tree = signalTree(initialState)
1741
+ .with(batching()) // Performance optimization
1742
+ .with(serialization({ // User preferences
1719
1743
  autoSave: true,
1720
1744
  storage: 'localStorage',
1721
1745
  key: 'app-v1.2.3',
1722
- })
1723
- );
1746
+ }));
1724
1747
 
1725
1748
  // Clean, efficient, production-ready
1726
1749
  ```
@@ -1728,28 +1751,22 @@ const tree = signalTree(initialState).with(
1728
1751
  ### Conditional Enhancement
1729
1752
 
1730
1753
  ```typescript
1731
- import { signalTree, batching, entities, devTools, withTimeTravel } from '@signaltree/core';
1754
+ import { signalTree, batching, devTools, timeTravel } from '@signaltree/core';
1732
1755
 
1733
1756
  const isDevelopment = process.env['NODE_ENV'] === 'development';
1734
1757
 
1735
1758
  // 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
- );
1759
+ // entityMap() markers self-register; chain each enhancer with .with()
1760
+ let tree = signalTree(state).with(batching()); // Always include performance
1761
+ if (isDevelopment) {
1762
+ tree = tree.with(devTools()).with(timeTravel()); // Development-only features
1763
+ }
1747
1764
  ```
1748
1765
 
1749
1766
  ### Preset-Based Composition
1750
1767
 
1751
1768
  ```typescript
1752
- import { signalTree, batching, devTools, withTimeTravel } from '@signaltree/core';
1769
+ import { signalTree, batching, devTools, timeTravel } from '@signaltree/core';
1753
1770
 
1754
1771
  // Compose the enhancers you actually need
1755
1772
  const devTree = signalTree({
@@ -1759,7 +1776,7 @@ const devTree = signalTree({
1759
1776
  })
1760
1777
  .with(batching())
1761
1778
  .with(devTools())
1762
- .with(withTimeTravel());
1779
+ .with(timeTravel());
1763
1780
  ```
1764
1781
 
1765
1782
  > **9.0.1 note:** Preset factories (`createDevTree`, `TREE_PRESETS`, etc.) were removed. Compose enhancers directly with `.with()`.
@@ -1779,10 +1796,7 @@ const tree = signalTree(state);
1779
1796
  // Phase 2: Add performance when needed
1780
1797
  const tree2 = tree.with(batching());
1781
1798
 
1782
- // Phase 3: Add data management for collections
1783
- const tree3 = tree2.with(entities());
1784
-
1785
- // Phase 4: Add async for API integration
1799
+ // Phase 3: Add async for API integration
1786
1800
  // withAsync removed — no explicit async enhancer; use async helpers instead
1787
1801
 
1788
1802
  // Each phase is fully functional and production-ready
@@ -1801,7 +1815,7 @@ if (needsPerformance) {
1801
1815
  }
1802
1816
 
1803
1817
  if (needsTimeTravel) {
1804
- tree = tree.with(withTimeTravel());
1818
+ tree = tree.with(timeTravel());
1805
1819
  }
1806
1820
  ```
1807
1821
 
@@ -1858,7 +1872,7 @@ For fair, reproducible measurements that reflect your app and hardware, use the
1858
1872
  {{ userTree.$.error() }}
1859
1873
  <button (click)="loadUsers()">Retry</button>
1860
1874
  </div>
1861
- } @else { @for (user of users.selectAll()(); track user.id) {
1875
+ } @else { @for (user of users.all(); track user.id) {
1862
1876
  <div class="user-card">
1863
1877
  <h3>{{ user.name }}</h3>
1864
1878
  <p>{{ user.email }}</p>
@@ -1954,18 +1968,6 @@ class UserManagerComponent implements OnInit {
1954
1968
  }
1955
1969
  ```
1956
1970
 
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
1971
 
1970
1972
  ## Core features
1971
1973
 
@@ -2034,18 +2036,33 @@ async function handleLoadUsers() {
2034
2036
 
2035
2037
  ### Reactive effects
2036
2038
 
2039
+ Use Angular's built-in `effect()` to react to signal changes, or the `effects()` enhancer for tree-level subscriptions with managed cleanup:
2040
+
2037
2041
  ```typescript
2038
- // Create reactive effects
2039
- tree.effect((state) => {
2040
- console.log(`User: ${state.user.name}, Theme: ${state.settings.theme}`);
2041
- });
2042
+ import { effect } from '@angular/core';
2042
2043
 
2043
- // Manual subscriptions
2044
- const unsubscribe = tree.subscribe((state) => {
2045
- // Handle state changes
2044
+ // React to any signal in the tree — reads are tracked automatically
2045
+ effect(() => {
2046
+ console.log(`User: ${tree.$.user.name()}, Theme: ${tree.$.settings.theme()}`);
2046
2047
  });
2047
2048
  ```
2048
2049
 
2050
+ Or use the `effects()` enhancer for whole-tree subscriptions:
2051
+
2052
+ ```typescript
2053
+ import { signalTree, effects } from '@signaltree/core';
2054
+
2055
+ const tree = signalTree({ count: 0 }).with(effects());
2056
+
2057
+ // Returns cleanup function
2058
+ const stop = tree.subscribe(state => console.log(state.count));
2059
+ // tree.destroy() also cleans up all registered effects
2060
+ ```
2061
+
2062
+ > **Note:** `isSignal(tree.$.users.byId(id)?.name)` returns `true` (v9.3+). Entity field
2063
+ > properties are computed signals — they work with `toObservable()`, Angular DevTools, and
2064
+ > any Angular API that accepts a `Signal<T>`.
2065
+
2049
2066
  ## Core API reference
2050
2067
 
2051
2068
  ### signalTree()
@@ -2059,21 +2076,21 @@ const tree = signalTree(initialState, config?);
2059
2076
  ```typescript
2060
2077
  // State access
2061
2078
  tree.$.property(); // Read signal value
2062
- tree.$.property.set(value); // Update signal
2063
- tree.unwrap(); // Get plain object
2079
+ tree.$.property.set(value); // Set signal
2080
+ tree.$.property.update(fn); // Update signal with function
2064
2081
 
2065
2082
  // Tree operations
2066
- tree.update(updater); // Update entire tree
2067
2083
  tree.updateAndReport(updater); // Update + return changed leaf paths (9.1+)
2068
- tree.effect(fn); // Create reactive effects
2069
- tree.subscribe(fn); // Manual subscriptions
2070
2084
  tree.destroy(); // Cleanup resources
2071
2085
 
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
2086
+ // Entity helpers (entityMap() self-registers no enhancer needed)
2087
+ tree.$.users.addOne(user); // Add single entity
2088
+ tree.$.users.byId(id)(); // O(1) lookup by ID (read whole entity)
2089
+ tree.$.users.byId(id)?.name(); // Read single field (computed signal)
2090
+ tree.$.users.byId(id)?.name.set('Bob'); // Write single field (throws if entity removed)
2091
+ tree.$.users.byId(id)?.name.update(fn); // Updater on single field
2092
+ tree.$.users.all; // Signal<E[]> — all entities
2093
+ tree.$.users.where(pred); // Filtered signal
2077
2094
  ```
2078
2095
 
2079
2096
  ### updateAndReport (9.1+)
@@ -2101,10 +2118,10 @@ local state is cheap (`O(changed)` instead of `O(N)`).
2101
2118
  SignalTree Core includes all enhancers built-in:
2102
2119
 
2103
2120
  ```typescript
2104
- import { signalTree, batching, withTimeTravel } from '@signaltree/core';
2121
+ import { signalTree, batching, timeTravel } from '@signaltree/core';
2105
2122
 
2106
- // All enhancers available from @signaltree/core
2107
- const tree = signalTree(initialState).with(batching(), withTimeTravel());
2123
+ // All enhancers available from @signaltree/core — chain `.with()` (one enhancer per call)
2124
+ const tree = signalTree(initialState).with(batching()).with(timeTravel());
2108
2125
  ```
2109
2126
 
2110
2127
  ### Available enhancers
@@ -2112,9 +2129,8 @@ const tree = signalTree(initialState).with(batching(), withTimeTravel());
2112
2129
  All enhancers are included in `@signaltree/core`:
2113
2130
 
2114
2131
  - **batching()** - Batch multiple updates for better performance
2115
- - **entities()** - Advanced entity management & CRUD operations
2116
2132
  - **devTools()** - Redux DevTools integration for debugging
2117
- - **withTimeTravel()** - Undo/redo functionality & state history
2133
+ - **timeTravel()** - Undo/redo functionality & state history
2118
2134
  - **serialization()** - State persistence & SSR support
2119
2135
 
2120
2136
  ## When to use core only
@@ -2130,7 +2146,7 @@ Perfect for:
2130
2146
  Consider enhancers when you need:
2131
2147
 
2132
2148
  - ⚡ Performance optimization (batching)
2133
- - 🐛 Advanced debugging (devTools, withTimeTravel)
2149
+ - 🐛 Advanced debugging (devTools, timeTravel)
2134
2150
  - 📦 Entity management (entities)
2135
2151
 
2136
2152
  Consider separate packages when you need:
@@ -2154,7 +2170,26 @@ Measured from a production Angular mobile application migrating from NgRx Signal
2154
2170
 
2155
2171
  13 separate stores → 1 unified tree. `entityMap()` replaced a 222-line `withEntityCrud` wrapper. Derived tiers replaced scattered `withComputed` blocks.
2156
2172
 
2157
- ### Migration Steps
2173
+ ### Migrating from `@ngrx/signals` (signalStore / withState / rxMethod)
2174
+
2175
+ The full migration playbook ships with this package as an Agent Skill at:
2176
+
2177
+ ```
2178
+ node_modules/@signaltree/core/skills/using-signaltree/reference/migration-from-ngrx-signals.md
2179
+ ```
2180
+
2181
+ It covers:
2182
+
2183
+ - 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.
2184
+ - **Three migration strategies** with explicit decision criteria:
2185
+ - **Big-bang** (1–2 stores, single team): one PR, delete legacy in same commit.
2186
+ - **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.
2187
+ - **Hybrid legacy-facade** (shared `signalStoreFeature` base classes, multi-team cutover, regulated rollback): adapter shim over `AppStore` with a deletion deadline.
2188
+ - A drop-in verifier script (`scripts/verify-signaltree-migration.sh` in the SignalTree repo) that runs `build` + `test` + `lint` and asserts `@ngrx/signals` is gone from source and `package.json`. Package-manager-agnostic, layout-agnostic.
2189
+
2190
+ Point your AI assistant (Cursor, Claude Code, or any `SKILL.md`-aware harness) at the shipped `skills/using-signaltree/` directory and it will follow the same playbook end-to-end. See the root README's [Using SignalTree with AI Agents](../../README.md#using-signaltree-with-ai-agents) section for setup.
2191
+
2192
+ ### Migrating from classic NgRx (`@ngrx/store` / actions / reducers / effects)
2158
2193
 
2159
2194
  ```typescript
2160
2195
  // Step 1: Create parallel tree
@@ -2278,14 +2313,10 @@ All enhancers are now consolidated in the core package. The following features a
2278
2313
 
2279
2314
  - **batching()** (+1.27KB gzipped) - Batch multiple updates for better performance
2280
2315
 
2281
- ### Advanced Features
2282
-
2283
- - **entities()** (+0.97KB gzipped) - Enhanced CRUD operations & entity management
2284
-
2285
2316
  ### Development Tools
2286
2317
 
2287
2318
  - **devTools()** (+2.49KB gzipped) - Development tools & Redux DevTools integration
2288
- - **withTimeTravel()** (+1.75KB gzipped) - Undo/redo functionality & state history
2319
+ - **timeTravel()** (+1.75KB gzipped) - Undo/redo functionality & state history
2289
2320
 
2290
2321
  ### Integration & Convenience
2291
2322
 
@@ -2305,7 +2336,7 @@ import {
2305
2336
  batching,
2306
2337
  entities,
2307
2338
  devTools,
2308
- withTimeTravel,
2339
+ timeTravel,
2309
2340
  serialization
2310
2341
  } from '@signaltree/core';
2311
2342
  ```
@@ -1,6 +1,7 @@
1
1
  import { signal } from '@angular/core';
2
2
  import { copyTreeProperties } from '../utils/copy-tree-properties.js';
3
3
  import { applyState, snapshotState } from '../../lib/utils.js';
4
+ import { interceptLeafSignals } from '../../lib/internals/intercept-leaf-signals.js';
4
5
  import { getPathNotifier } from '../../lib/path-notifier.js';
5
6
  import { ENHANCER_META } from '../../lib/types.js';
6
7
 
@@ -1184,35 +1185,10 @@ function devTools(config = {}) {
1184
1185
  const restoreInterceptors = [];
1185
1186
  try {
1186
1187
  if ('$' in tree) {
1187
- const treeNode = tree.$;
1188
- for (const key of treeTopKeys) {
1189
- const sig = treeNode[key];
1190
- if (typeof sig === 'function' && 'set' in sig && 'update' in sig && typeof sig.set === 'function' && typeof sig.update === 'function' && !('add' in sig) && !('remove' in sig)) {
1191
- const original = sig;
1192
- const originalSet = original.set.bind(original);
1193
- const originalUpdate = original.update.bind(original);
1194
- restoreInterceptors.push(() => {
1195
- original.set = originalSet;
1196
- original.update = originalUpdate;
1197
- });
1198
- original.set = value => {
1199
- const prev = original();
1200
- originalSet(value);
1201
- const next = original();
1202
- if (next !== prev) {
1203
- notifier.notify(key, next, prev);
1204
- }
1205
- };
1206
- original.update = updater => {
1207
- const prev = original();
1208
- originalUpdate(updater);
1209
- const next = original();
1210
- if (next !== prev) {
1211
- notifier.notify(key, next, prev);
1212
- }
1213
- };
1214
- }
1215
- }
1188
+ const restore = interceptLeafSignals(tree.$, (path, next, prev) => {
1189
+ notifier.notify(path, next, prev);
1190
+ });
1191
+ restoreInterceptors.push(restore);
1216
1192
  }
1217
1193
  } catch {}
1218
1194
  let unsubscribePathNotifier = null;
@@ -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,4 +1,6 @@
1
1
  import { snapshotState } from '../../lib/utils.js';
2
+ import { interceptLeafSignals } from '../../lib/internals/intercept-leaf-signals.js';
3
+ import { getPathNotifier } from '../../lib/path-notifier.js';
2
4
  import { deepEqual, deepClone } from './utils.js';
3
5
  import { ENHANCER_META } from '../../lib/types.js';
4
6
 
@@ -164,15 +166,19 @@ function timeTravel(config = {}) {
164
166
  isRestoring = false;
165
167
  }
166
168
  });
169
+ let unsubscribeFlush = null;
170
+ let restoreLeafInterceptors = null;
167
171
  try {
168
- const req = globalThis['require'];
169
- if (typeof req === 'function') {
170
- const {
171
- getPathNotifier
172
- } = req('../../lib/path-notifier');
173
- const notifier = getPathNotifier();
174
- if (notifier && typeof notifier.onFlush === 'function') {
175
- notifier.onFlush(() => {
172
+ const notifier = getPathNotifier();
173
+ if (notifier) {
174
+ if ('$' in tree) {
175
+ restoreLeafInterceptors = interceptLeafSignals(tree.$, (path, next, prev) => {
176
+ if (isRestoring) return;
177
+ notifier.notify(path, next, prev);
178
+ });
179
+ }
180
+ if (typeof notifier.onFlush === 'function') {
181
+ unsubscribeFlush = notifier.onFlush(() => {
176
182
  if (isRestoring) return;
177
183
  const afterState = originalTreeCall();
178
184
  timeTravelManager.addEntry('batch', afterState);
@@ -258,6 +264,14 @@ function timeTravel(config = {}) {
258
264
  enhancedTree['__timeTravel'] = timeTravelManager;
259
265
  if (typeof tree.registerCleanup === 'function') {
260
266
  tree.registerCleanup(() => {
267
+ try {
268
+ unsubscribeFlush?.();
269
+ } catch {}
270
+ try {
271
+ restoreLeafInterceptors?.();
272
+ } catch {}
273
+ unsubscribeFlush = null;
274
+ restoreLeafInterceptors = null;
261
275
  timeTravelManager.resetHistory();
262
276
  });
263
277
  }
package/dist/index.js CHANGED
@@ -10,6 +10,7 @@ export { composeEnhancers, isAnySignal, isNodeAccessor, toWritableSignal } from
10
10
  export { getPathNotifier } from './lib/path-notifier.js';
11
11
  export { createEnhancer, resolveEnhancerOrder } from './enhancers/index.js';
12
12
  export { batching } from './enhancers/batching/batching.js';
13
+ export { effects } from './enhancers/effects/effects.js';
13
14
  export { timeTravel } from './enhancers/time-travel/time-travel.js';
14
15
  export { persistence, serialization } from './enhancers/serialization/serialization.js';
15
16
  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);
@@ -0,0 +1,71 @@
1
+ function interceptLeafSignals(root, onWrite, options = {}) {
2
+ const restorers = [];
3
+ const seen = new WeakSet();
4
+ const maxDepth = options.maxDepth ?? 32;
5
+ const walk = (node, pathPrefix, depth) => {
6
+ if (depth > maxDepth) return;
7
+ if (node === null || node === undefined) return;
8
+ if (typeof node !== 'function' && typeof node !== 'object') return;
9
+ if (seen.has(node)) return;
10
+ seen.add(node);
11
+ let keys;
12
+ try {
13
+ keys = Object.keys(node);
14
+ } catch {
15
+ return;
16
+ }
17
+ for (const key of keys) {
18
+ let child;
19
+ try {
20
+ child = node[key];
21
+ } catch {
22
+ continue;
23
+ }
24
+ if (child === null || child === undefined) continue;
25
+ if (typeof child !== 'function' && typeof child !== 'object') continue;
26
+ const childPath = pathPrefix ? `${pathPrefix}.${key}` : key;
27
+ const isWritableSignal = typeof child === 'function' && 'set' in child && 'update' in child && typeof child.set === 'function' && typeof child.update === 'function';
28
+ const isEntityCollection = isWritableSignal && ('add' in child || 'remove' in child);
29
+ if (isWritableSignal && !isEntityCollection) {
30
+ const original = child;
31
+ const originalSet = original.set.bind(original);
32
+ const originalUpdate = original.update.bind(original);
33
+ restorers.push(() => {
34
+ original.set = originalSet;
35
+ original.update = originalUpdate;
36
+ });
37
+ original.set = value => {
38
+ const prev = original();
39
+ originalSet(value);
40
+ const next = original();
41
+ if (next !== prev) onWrite(childPath, next, prev);
42
+ };
43
+ original.update = updater => {
44
+ const prev = original();
45
+ originalUpdate(updater);
46
+ const next = original();
47
+ if (next !== prev) onWrite(childPath, next, prev);
48
+ };
49
+ continue;
50
+ }
51
+ if (typeof child === 'function' && !isWritableSignal) {
52
+ walk(child, childPath, depth + 1);
53
+ } else if (typeof child === 'object' && !Array.isArray(child) && !(child instanceof Date) && !(child instanceof Map) && !(child instanceof Set)) {
54
+ walk(child, childPath, depth + 1);
55
+ }
56
+ }
57
+ };
58
+ try {
59
+ walk(root, '', 0);
60
+ } catch {}
61
+ return () => {
62
+ for (const restore of restorers) {
63
+ try {
64
+ restore();
65
+ } catch {}
66
+ }
67
+ restorers.length = 0;
68
+ };
69
+ }
70
+
71
+ export { interceptLeafSignals };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@signaltree/core",
3
- "version": "9.2.0",
3
+ "version": "9.2.2",
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,5 @@
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, } from './lib/types';
3
3
  export { entityMap } from './lib/types';
4
4
  export type { ProcessDerived, DeepMergeTree, DerivedFactory, WithDerived, } from './lib/internals/derived-types';
5
5
  export { derivedFrom, externalDerived } from './lib/internals/derived-types';
@@ -15,6 +15,8 @@ export { createEnhancer, resolveEnhancerOrder } from './enhancers/index';
15
15
  export { ENHANCER_META } from './lib/types';
16
16
  export { batching } from './enhancers/batching/batching';
17
17
  export type { BatchingConfig, BatchingMethods } from './lib/types';
18
+ export { effects } from './enhancers/effects/effects';
19
+ export type { EffectsConfig } from './enhancers/effects/effects';
18
20
  export { timeTravel } from './enhancers/time-travel/time-travel';
19
21
  export { serialization, persistence, } from './enhancers/serialization/serialization';
20
22
  export { devTools } from './enhancers/devtools/devtools';
@@ -0,0 +1 @@
1
+ export {};