@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 +189 -162
- package/dist/enhancers/devtools/devtools.js +11 -5
- package/dist/enhancers/effects/effects.js +74 -0
- package/dist/enhancers/time-travel/time-travel.js +11 -5
- package/dist/index.js +3 -0
- package/dist/lib/constants.js +1 -8
- package/dist/lib/entity-signal.js +50 -11
- package/dist/lib/internals/intercept-leaf-signals.js +4 -2
- package/dist/lib/write-context.js +15 -0
- package/package.json +1 -1
- package/src/index.d.ts +5 -1
- package/src/lib/internals/intercept-leaf-signals.d.ts +4 -1
- package/src/lib/types.d.ts +8 -0
- package/src/lib/write-context.d.ts +3 -0
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);
|
|
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, {
|
|
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
|
-
//
|
|
301
|
-
tree.$.count(5)
|
|
302
|
-
|
|
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
|
-
- `
|
|
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
|
|
682
|
-
const tree = signalTree({ count: 0 })
|
|
683
|
-
batching()
|
|
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
|
|
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,
|
|
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
|
-
})
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
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
|
-
//
|
|
754
|
-
const tree = signalTree(state)
|
|
755
|
-
|
|
756
|
-
|
|
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
|
|
801
|
+
// With entityMap — entity helpers are automatically available (no enhancer needed)
|
|
773
802
|
const enhanced = signalTree({
|
|
774
803
|
users: entityMap<User>(),
|
|
775
|
-
})
|
|
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.
|
|
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.
|
|
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
|
|
1046
|
-
tree.$.products.
|
|
1047
|
-
tree.$.products.
|
|
1048
|
-
tree.$.products.
|
|
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
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
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
|
-
|
|
1531
|
+
const updatedItem = { ...currentItems[itemIndex], ...updates };
|
|
1486
1532
|
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1533
|
+
// Validation
|
|
1534
|
+
if (!updatedItem.name?.trim()) {
|
|
1535
|
+
throw new Error('Item name is required');
|
|
1536
|
+
}
|
|
1491
1537
|
|
|
1492
|
-
|
|
1493
|
-
|
|
1538
|
+
const newItems = [...currentItems];
|
|
1539
|
+
newItems[itemIndex] = updatedItem;
|
|
1494
1540
|
|
|
1495
|
-
|
|
1496
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
})
|
|
1607
|
+
});
|
|
1565
1608
|
|
|
1566
1609
|
// Advanced entity operations via tree.$ accessor
|
|
1567
1610
|
tree.$.users.addOne(newUser);
|
|
1568
|
-
tree.$.users.
|
|
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
|
-
})
|
|
1629
|
+
});
|
|
1587
1630
|
|
|
1588
1631
|
// Access nested entities using tree.$ accessor
|
|
1589
|
-
appTree.$.app.data.users.
|
|
1590
|
-
appTree.$.app.data.products.
|
|
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.
|
|
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,
|
|
1654
|
+
import { signalTree, batching, serialization, timeTravel, devTools } from '@signaltree/core';
|
|
1612
1655
|
|
|
1613
|
-
// Full development stack (
|
|
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
|
-
})
|
|
1621
|
-
batching()
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
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)
|
|
1714
|
-
batching()
|
|
1715
|
-
|
|
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,
|
|
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
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
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,
|
|
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(
|
|
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
|
|
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(
|
|
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.
|
|
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
|
-
|
|
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
|
-
//
|
|
2044
|
-
|
|
2045
|
-
|
|
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); //
|
|
2063
|
-
tree.
|
|
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 (
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
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
|
-
- **
|
|
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,
|
|
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
|
-
|
|
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` → `
|
|
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
|
-
- **
|
|
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
|
-
|
|
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
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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';
|
package/dist/lib/constants.js
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
130
|
-
|
|
131
|
-
|
|
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
|
|
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
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';
|
package/src/lib/types.d.ts
CHANGED
|
@@ -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;
|