@signaltree/core 9.2.0 → 9.2.1

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
@@ -2101,10 +2101,10 @@ local state is cheap (`O(changed)` instead of `O(N)`).
2101
2101
  SignalTree Core includes all enhancers built-in:
2102
2102
 
2103
2103
  ```typescript
2104
- import { signalTree, batching, withTimeTravel } from '@signaltree/core';
2104
+ import { signalTree, batching, timeTravel } from '@signaltree/core';
2105
2105
 
2106
- // All enhancers available from @signaltree/core
2107
- const tree = signalTree(initialState).with(batching(), withTimeTravel());
2106
+ // All enhancers available from @signaltree/core — chain `.with()` (one enhancer per call)
2107
+ const tree = signalTree(initialState).with(batching()).with(timeTravel());
2108
2108
  ```
2109
2109
 
2110
2110
  ### Available enhancers
@@ -2154,7 +2154,26 @@ Measured from a production Angular mobile application migrating from NgRx Signal
2154
2154
 
2155
2155
  13 separate stores → 1 unified tree. `entityMap()` replaced a 222-line `withEntityCrud` wrapper. Derived tiers replaced scattered `withComputed` blocks.
2156
2156
 
2157
- ### Migration Steps
2157
+ ### Migrating from `@ngrx/signals` (signalStore / withState / rxMethod)
2158
+
2159
+ The full migration playbook ships with this package as an Agent Skill at:
2160
+
2161
+ ```
2162
+ node_modules/@signaltree/core/skills/using-signaltree/reference/migration-from-ngrx-signals.md
2163
+ ```
2164
+
2165
+ It covers:
2166
+
2167
+ - A mechanical concept-map table: `signalStore` → tree slice + `Ops`, `withState` → initial state, `withMethods` → `Ops` methods, `withComputed` → `computed()` or `.derived()`, `withHooks` → factory body, `rxMethod` → plain method returning `Observable<void>`, `withEntities` → `entityMap()` marker, `patchState` → tree update, `getState` → `unwrap()`, etc.
2168
+ - **Three migration strategies** with explicit decision criteria:
2169
+ - **Big-bang** (1–2 stores, single team): one PR, delete legacy in same commit.
2170
+ - **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.
2171
+ - **Hybrid legacy-facade** (shared `signalStoreFeature` base classes, multi-team cutover, regulated rollback): adapter shim over `AppStore` with a deletion deadline.
2172
+ - 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.
2173
+
2174
+ 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.
2175
+
2176
+ ### Migrating from classic NgRx (`@ngrx/store` / actions / reducers / effects)
2158
2177
 
2159
2178
  ```typescript
2160
2179
  // Step 1: Create parallel tree
@@ -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;
@@ -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
  }
@@ -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.1",
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",
@@ -0,0 +1 @@
1
+ export {};