@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 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); // requires @signaltree/callable-syntax transform
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()) // Performance optimization
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(serialization({ // Auto-save to localStorage
745
- autoSave: true,
746
- storage: 'localStorage',
747
- }))
748
- .with(timeTravel()); // Undo/redo support
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()) // Requires: core, provides: batching
780
- .with(devTools()); // Requires: core, provides: debugging
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()) // Performance
1657
- .with(serialization({ // State persistence
1658
- autoSave: true,
1659
- storage: 'localStorage',
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(devTools({ // Debug tools (dev only)
1663
- name: 'MyApp',
1664
- enableTimeTravel: true,
1665
- includePaths: ['app.*', 'ui.*'],
1666
- formatPath: (path) => path.replace(/\.(\d+)/g, '[$1]'),
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
- Measured from a production Angular mobile application migrating from NgRx Signal Store to SignalTree. Results reflect one team's experience; your mileage will vary depending on app complexity and existing architecture.
2177
+ Snapshot from one production Angular mobile app's NgRx Signal Store SignalTree migration. Original migration measured ~11,700 → ~2,800 lines of state code (~76%) and ~50KB → ~27KB gzipped state bundle (~46%). Both codebases have continued to evolve; re-measuring today the same scope yields a 60–70% reduction depending on definition (apps-only vs apps+libs, narrow vs broad import filter). The directional finding is reproducible — the exact percentages are not. **YMMV** — your migration's reduction depends on app complexity, prior architecture, and how heavily the original code leaned on custom `withX` helpers. The single biggest driver of the savings is cross-cutting concerns (devtools, error banners, telemetry, refresh handling) moving from per-store composition to tree-level enhancers.
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
- if ('$' in tree) {
908
- applyState(tree.$, state);
909
- } else {
910
- originalTreeCall(state);
911
- }
908
+ withWriteContext({
909
+ intent: 'system',
910
+ source: 'time-travel'
911
+ }, () => {
912
+ if ('$' in tree) {
913
+ applyState(tree.$, state);
914
+ } else {
915
+ originalTreeCall(state);
916
+ }
917
+ });
912
918
  } finally {
913
919
  isApplyingExternalState = false;
914
920
  lastSnapshot = readSnapshot();
@@ -1,6 +1,7 @@
1
1
  import { snapshotState } from '../../lib/utils.js';
2
2
  import { interceptLeafSignals } from '../../lib/internals/intercept-leaf-signals.js';
3
3
  import { getPathNotifier } from '../../lib/path-notifier.js';
4
+ import { withWriteContext } from '../../lib/write-context.js';
4
5
  import { deepEqual, deepClone } from './utils.js';
5
6
  import { ENHANCER_META } from '../../lib/types.js';
6
7
 
@@ -122,11 +123,16 @@ class TimeTravelManager {
122
123
  return this.currentIndex < this.history.length - 1;
123
124
  }
124
125
  restoreState(state) {
125
- if (this.restoreStateFn) {
126
- this.restoreStateFn(state);
127
- } else {
128
- this.tree(state);
129
- }
126
+ withWriteContext({
127
+ intent: 'system',
128
+ source: 'time-travel'
129
+ }, () => {
130
+ if (this.restoreStateFn) {
131
+ this.restoreStateFn(state);
132
+ } else {
133
+ this.tree(state);
134
+ }
135
+ });
130
136
  }
131
137
  }
132
138
  function timeTravel(config = {}) {
package/dist/index.js CHANGED
@@ -1,4 +1,6 @@
1
1
  export { signalTree } from './lib/signal-tree.js';
2
+ export { getActiveWriteContext, withWriteContext } from './lib/write-context.js';
3
+ export { interceptLeafSignals } from './lib/internals/intercept-leaf-signals.js';
2
4
  export { ENHANCER_META } from './lib/types.js';
3
5
  export { derivedFrom, externalDerived } from './lib/internals/derived-types.js';
4
6
  export { isDerivedMarker } from './lib/markers/derived.js';
@@ -1,3 +1,5 @@
1
+ import { getActiveWriteContext } from '../write-context.js';
2
+
1
3
  function interceptLeafSignals(root, onWrite, options = {}) {
2
4
  const restorers = [];
3
5
  const seen = new WeakSet();
@@ -38,13 +40,13 @@ function interceptLeafSignals(root, onWrite, options = {}) {
38
40
  const prev = original();
39
41
  originalSet(value);
40
42
  const next = original();
41
- if (next !== prev) onWrite(childPath, next, prev);
43
+ if (next !== prev) onWrite(childPath, next, prev, getActiveWriteContext());
42
44
  };
43
45
  original.update = updater => {
44
46
  const prev = original();
45
47
  originalUpdate(updater);
46
48
  const next = original();
47
- if (next !== prev) onWrite(childPath, next, prev);
49
+ if (next !== prev) onWrite(childPath, next, prev, getActiveWriteContext());
48
50
  };
49
51
  continue;
50
52
  }
@@ -0,0 +1,15 @@
1
+ let activeContext;
2
+ function withWriteContext(meta, fn) {
3
+ const previous = activeContext;
4
+ activeContext = meta;
5
+ try {
6
+ return fn();
7
+ } finally {
8
+ activeContext = previous;
9
+ }
10
+ }
11
+ function getActiveWriteContext() {
12
+ return activeContext;
13
+ }
14
+
15
+ export { getActiveWriteContext, withWriteContext };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@signaltree/core",
3
- "version": "9.2.2",
3
+ "version": "9.3.0",
4
4
  "description": "Reactive JSON for Angular. JSON branches, reactive leaves. No actions. No reducers. No selectors.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/index.d.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  export { signalTree } from './lib/signal-tree';
2
- export type { ISignalTree, SignalTree, SignalTreeBase, TreeNode, CallableWritableSignal, AccessibleNode, NodeAccessor, Primitive, NotFn, TreeConfig, Enhancer, EnhancerMeta, EnhancerWithMeta, EntitySignal, EntityMapMarker, EntityConfig, MutationOptions, AddOptions, AddManyOptions, TimeTravelEntry, TimeTravelMethods, EnhancerCleanup, 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';
@@ -1 +1,4 @@
1
- export {};
1
+ import type { UpdateMetadata } from '../types';
2
+ export declare function interceptLeafSignals(root: unknown, onWrite: (path: string, next: unknown, prev: unknown, meta?: UpdateMetadata) => void, options?: {
3
+ maxDepth?: number;
4
+ }): () => void;
@@ -3,6 +3,14 @@ import { FormMarker, FormSignal } from './markers/form';
3
3
  import { StatusMarker, StatusSignal } from './markers/status';
4
4
  import { StoredMarker, StoredSignal } from './markers/stored';
5
5
  import { SecurityValidatorConfig } from './security/security-validator';
6
+ export interface UpdateMetadata {
7
+ intent?: 'hydrate' | 'reset' | 'bulk' | 'migration' | 'user' | 'system';
8
+ source?: 'serialization' | 'time-travel' | 'devtools' | 'user' | 'system';
9
+ suppressGuardrails?: boolean;
10
+ correlationId?: string;
11
+ timestamp?: number;
12
+ [key: string]: unknown;
13
+ }
6
14
  export interface TimeTravelConfig {
7
15
  enabled?: boolean;
8
16
  maxHistorySize?: number;
@@ -0,0 +1,3 @@
1
+ import type { UpdateMetadata } from './types';
2
+ export declare function withWriteContext<R>(meta: UpdateMetadata, fn: () => R): R;
3
+ export declare function getActiveWriteContext(): UpdateMetadata | undefined;