@signaltree/core 9.2.2 → 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 +42 -27
- package/dist/enhancers/devtools/devtools.js +11 -5
- package/dist/enhancers/time-travel/time-travel.js +11 -5
- package/dist/index.js +2 -0
- 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 +3 -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
|
@@ -305,7 +305,7 @@ console.log(tree.$.message()); // 'Hello World'
|
|
|
305
305
|
// tree.$.message.set('Updated!');
|
|
306
306
|
// tree.$.count.update((n) => n + 1);
|
|
307
307
|
// See: https://github.com/JBorgia/signaltree/blob/main/packages/callable-syntax/README.md
|
|
308
|
-
tree.$.count(5);
|
|
308
|
+
tree.$.count(5); // requires @signaltree/callable-syntax transform
|
|
309
309
|
tree.$.message('Updated!'); // requires @signaltree/callable-syntax transform
|
|
310
310
|
|
|
311
311
|
// Use in an Angular component
|
|
@@ -343,6 +343,10 @@ const tree = signalTree({
|
|
|
343
343
|
});
|
|
344
344
|
|
|
345
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);
|
|
346
350
|
tree.$.user.name('Jane Doe');
|
|
347
351
|
tree.$.user.preferences.theme('light');
|
|
348
352
|
tree.$.ui.loading(true);
|
|
@@ -500,6 +504,8 @@ const tree = signalTree<AppState>({
|
|
|
500
504
|
});
|
|
501
505
|
|
|
502
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.
|
|
503
509
|
tree((state) => ({
|
|
504
510
|
auth: {
|
|
505
511
|
...state.auth,
|
|
@@ -671,16 +677,15 @@ All enhancers are exported directly from `@signaltree/core`:
|
|
|
671
677
|
```typescript
|
|
672
678
|
import { signalTree, effects } from '@signaltree/core';
|
|
673
679
|
|
|
674
|
-
const tree = signalTree({ count: 0, user: { name: 'Alice' } })
|
|
675
|
-
.with(effects());
|
|
680
|
+
const tree = signalTree({ count: 0, user: { name: 'Alice' } }).with(effects());
|
|
676
681
|
|
|
677
682
|
// Subscribe with automatic cleanup on destroy
|
|
678
|
-
const unsub = tree.subscribe(state => {
|
|
683
|
+
const unsub = tree.subscribe((state) => {
|
|
679
684
|
console.log('State changed:', state.count);
|
|
680
685
|
});
|
|
681
686
|
|
|
682
687
|
// Effect with cleanup callback
|
|
683
|
-
const cleanup = tree.effect(state => {
|
|
688
|
+
const cleanup = tree.effect((state) => {
|
|
684
689
|
console.log('Count:', state.count);
|
|
685
690
|
return () => console.log('Previous effect cleaned up');
|
|
686
691
|
});
|
|
@@ -708,7 +713,7 @@ import { signalTree, batching, devTools } from '@signaltree/core';
|
|
|
708
713
|
|
|
709
714
|
// Apply enhancers by chaining — each .with() takes a single enhancer
|
|
710
715
|
const tree = signalTree({ count: 0 })
|
|
711
|
-
.with(batching())
|
|
716
|
+
.with(batching()) // Performance optimization
|
|
712
717
|
.with(devTools()); // Development tools
|
|
713
718
|
```
|
|
714
719
|
|
|
@@ -721,8 +726,7 @@ import { signalTree, batching } from '@signaltree/core';
|
|
|
721
726
|
const tree = signalTree({
|
|
722
727
|
products: entityMap<Product>(),
|
|
723
728
|
ui: { loading: false },
|
|
724
|
-
})
|
|
725
|
-
.with(batching()); // Batch updates for optimal rendering
|
|
729
|
+
}).with(batching()); // Batch updates for optimal rendering
|
|
726
730
|
|
|
727
731
|
// Entity CRUD operations
|
|
728
732
|
tree.$.products.addOne(newProduct);
|
|
@@ -741,11 +745,14 @@ const tree = signalTree({
|
|
|
741
745
|
user: null as User | null,
|
|
742
746
|
preferences: { theme: 'light' },
|
|
743
747
|
})
|
|
744
|
-
.with(
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
748
|
+
.with(
|
|
749
|
+
serialization({
|
|
750
|
+
// Auto-save to localStorage
|
|
751
|
+
autoSave: true,
|
|
752
|
+
storage: 'localStorage',
|
|
753
|
+
})
|
|
754
|
+
)
|
|
755
|
+
.with(timeTravel()); // Undo/redo support
|
|
749
756
|
|
|
750
757
|
// For async operations, use manual async or async helpers
|
|
751
758
|
async function fetchUser(id: string) {
|
|
@@ -776,8 +783,8 @@ Enhancers can declare metadata for automatic dependency resolution:
|
|
|
776
783
|
```typescript
|
|
777
784
|
// Chain enhancers — each .with() takes a single enhancer
|
|
778
785
|
const tree = signalTree(state)
|
|
779
|
-
.with(batching())
|
|
780
|
-
.with(devTools());
|
|
786
|
+
.with(batching()) // Requires: core, provides: batching
|
|
787
|
+
.with(devTools()); // Requires: core, provides: debugging
|
|
781
788
|
```
|
|
782
789
|
|
|
783
790
|
#### Core Stubs
|
|
@@ -1159,6 +1166,8 @@ LoadingState.Error; // 'error'
|
|
|
1159
1166
|
|
|
1160
1167
|
Auto-syncs state to localStorage with versioning and migration support.
|
|
1161
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
|
+
|
|
1162
1171
|
```typescript
|
|
1163
1172
|
import { signalTree, stored, createStorageKeys, clearStoragePrefix } from '@signaltree/core';
|
|
1164
1173
|
|
|
@@ -1653,18 +1662,24 @@ const tree = signalTree({
|
|
|
1653
1662
|
data: { users: [], posts: [] },
|
|
1654
1663
|
},
|
|
1655
1664
|
})
|
|
1656
|
-
.with(batching())
|
|
1657
|
-
.with(
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1665
|
+
.with(batching()) // Performance
|
|
1666
|
+
.with(
|
|
1667
|
+
serialization({
|
|
1668
|
+
// State persistence
|
|
1669
|
+
autoSave: true,
|
|
1670
|
+
storage: 'localStorage',
|
|
1671
|
+
})
|
|
1672
|
+
)
|
|
1661
1673
|
.with(timeTravel({ maxHistorySize: 50 })) // Undo/redo
|
|
1662
|
-
.with(
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
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
|
+
);
|
|
1668
1683
|
|
|
1669
1684
|
// Rich feature set available
|
|
1670
1685
|
async function fetchUser(id: string) {
|
|
@@ -2159,7 +2174,7 @@ Consider separate packages when you need:
|
|
|
2159
2174
|
|
|
2160
2175
|
### Case Study
|
|
2161
2176
|
|
|
2162
|
-
|
|
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.
|
|
2163
2178
|
|
|
2164
2179
|
| Metric | NgRx | SignalTree | Change |
|
|
2165
2180
|
| --- | --- | --- | --- |
|
|
@@ -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();
|
|
@@ -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';
|
|
@@ -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, EffectsMethods, } 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';
|
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;
|