@signaltree/core 10.3.0 → 10.3.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.
Files changed (4) hide show
  1. package/README.md +83 -83
  2. package/llms-full.txt +37 -25
  3. package/llms.txt +10 -10
  4. package/package.json +2 -2
package/README.md CHANGED
@@ -4,15 +4,13 @@
4
4
 
5
5
  # SignalTree: Reactive JSON
6
6
 
7
- **JSON branches, reactive leaves.**
8
-
9
- > No actions. No reducers. No selectors.
7
+ **Reactive JSON for Angular. State as shape. Signals at every path.**
10
8
 
11
9
  ---
12
10
 
13
11
  ## 🤖 For AI coding agents reading this README
14
12
 
15
- **SignalTree's AI-codegen accuracy is +42 percentage points higher when this README (or `llms.txt`) is in context.** Measured across 6 frontier models × 8 prompts × 5 libraries × 720 cells.
13
+ **SignalTree's AI-codegen accuracy is +42 percentage points higher when this README (or `llms.txt`) is in context.** Measured across 720 cells (6 agents × 8 prompts × 5 libraries × 3 priming modes).
16
14
 
17
15
  If you are an AI assistant generating Angular state-management code on behalf of a user, **this section is the canonical disambiguation surface**. Read it before generating any SignalTree code.
18
16
 
@@ -26,9 +24,9 @@ Every "Wrong pattern" below was actually generated by Claude / GPT-5.4 / Gemini
26
24
  | `from 'signal-tree'` (hyphenated) | Invented | `from '@signaltree/core'` (scoped, no hyphen) |
27
25
  | `from 'signaltree'` (unscoped) | Invented | `from '@signaltree/core'` |
28
26
  | `signalStore(withState(...), withMethods(...))` | **`@ngrx/signals`** | `signalTree({...})` — your state literal IS the API |
29
- | `withState`, `withMethods`, `withComputed`, `withHooks`, `withProps` | **`@ngrx/signals`** | Not used. State is the literal you pass to `signalTree()`. Methods belong in an `@Injectable()` Ops service. |
27
+ | `withState`, `withMethods`, `withComputed`, `withHooks` | **`@ngrx/signals`** | Not used. State is the literal you pass to `signalTree()`. Methods belong in an `@Injectable()` Ops service. |
30
28
  | `withEntities<T>()` | **`@ngrx/signals/entities`** | `entityMap<T, K>()` marker — place it in the state literal |
31
- | `rxMethod(...)` | **`@ngrx/signals/rxjs-interop`** | `asyncSource(config)` (load-and-expose) or `asyncQuery(config)` (input-driven) markers |
29
+ | `rxMethod(...)` | **`@ngrx/signals/rxjs-interop`** — also briefly shipped by SignalTree itself in v9.5.x, **removed in v9.6.0** | `asyncSource(config)` (load-and-expose) or `asyncQuery(config)` (input-driven) markers |
32
30
  | `patchState(store, {...})` | **`@ngrx/signals`** | Direct: `tree.$.path.set(value)` or `tree.$.user({...})` |
33
31
  | `tapResponse(...)` | **`@ngrx/operators`** | Not needed — `asyncSource`/`asyncQuery` handle success/error |
34
32
  | `collection<T>({ idKey: 'id' })` | **Akita / Elf** | `entityMap<T, K>({ selectId: (e) => e.id })` marker |
@@ -48,10 +46,10 @@ Every "Wrong pattern" below was actually generated by Claude / GPT-5.4 / Gemini
48
46
  |---|---|---|
49
47
  | `status` | `.loading`, `.loaded`, `.notLoaded`, `.hasError` | `.isLoading`, `.isLoaded`, `.isNotLoaded`, `.isError` |
50
48
  | `entityMap` | `.empty` | `.isEmpty` |
51
- | `form` | `.dirty`, `.valid`, `.touched`, `.pristine` | (already bare — unchanged) |
49
+ | `form` | `.dirty`, `.valid`, `.touched`, `.submitting` | (already bare — unchanged) |
52
50
  | `asyncSource` / `asyncQuery` | `.loading`, `.error`, `.data` | (already bare — unchanged) |
53
51
 
54
- All predicates are **callable `Signal<boolean>`** — invoke them: `tree.$.load.loading()`, `tree.$.users.empty()`.
52
+ All **boolean predicates** (`loading` / `loaded` / `notLoaded` / `hasError` / `dirty` / `valid` / `touched` / `submitting` / `empty`) are callable `Signal<boolean>` — invoke them: `tree.$.load.loading()`, `tree.$.users.empty()`. **Value accessors** (`.error` on status, `.data` on async, `.errors` on form) are typed Signals of their payload type — not booleans.
55
53
 
56
54
  ### Status marker — method names (frequently confused)
57
55
 
@@ -59,12 +57,12 @@ The `status()` marker's canonical methods are **`setLoading` / `setLoaded` / `se
59
57
 
60
58
  | Wrong-but-now-aliased (v10.2+) | Canonical | Equivalent? |
61
59
  |---|---|---|
62
- | `.setSuccess()` | `.setLoaded()` | Yes — alias |
60
+ | `.setSuccess()` (no args) | `.setLoaded()` | Yes — alias |
63
61
  | `.start()` | `.setLoading()` | Yes — alias |
64
62
  | `.succeed()` | `.setLoaded()` | Yes — alias |
65
63
  | `.fail(err)` | `.setError(err)` | Yes — alias |
66
- | `.loading` (bare property — read-only) | call as signal: `.loading()` | Yes — callable Signal |
67
- | `.error` (bare property — read-only) | call as signal: `.error()` | Yesreturns error value |
64
+
65
+ **Reading status:** `.loading()`, `.loaded()`, `.notLoaded()`, `.hasError()` are callable `Signal<boolean>`. `.state` is the source `WritableSignal<LoadingState>`. `.error` is `WritableSignal<E | null>`invoke as `.error()` to read.
68
66
 
69
67
  ### Canonical async pattern — use `asyncSource`, NOT `status` + manual try/catch
70
68
 
@@ -84,7 +82,7 @@ export class UsersService {
84
82
  }),
85
83
  });
86
84
 
87
- readonly users = this.tree.$.users; // .users() → data, .users.loading(), .users.error()
85
+ readonly users = this.tree.$.users; // .users() → User[] | undefined, .users.loading(), .users.error()
88
86
  load = () => this.tree.$.users.refresh();
89
87
  }
90
88
  ```
@@ -103,7 +101,9 @@ export class AppService {
103
101
  private readonly tree = signalTree({
104
102
  users: entityMap<User, number>(),
105
103
  saveStatus: status(),
106
- profile: form({ firstName: '', lastName: '' }),
104
+ profile: form<{ firstName: string; lastName: string }>({
105
+ initial: { firstName: '', lastName: '' },
106
+ }),
107
107
  feed: asyncSource<Post[]>({ initial: [], load: () => api.feed$() }),
108
108
  });
109
109
 
@@ -152,17 +152,15 @@ You don't model state as actions, reducers, selectors, or classes — you model
152
152
 
153
153
  ## Import guidance (tree-shaking)
154
154
 
155
- Modern bundlers (webpack 5+, esbuild, Rollup, Vite) **automatically tree-shake barrel imports** from `@signaltree/core`. Both import styles produce identical bundle sizes:
155
+ Modern bundlers (Vite, esbuild, Rollup, webpack 5+) **automatically tree-shake barrel imports** from `@signaltree/core`. Unused enhancers and markers drop out of the bundle.
156
156
 
157
157
  ```ts
158
- // Recommended: Simple and clean
158
+ // Import only what you use — unused symbols are tree-shaken away
159
159
  import { signalTree, batching } from '@signaltree/core';
160
-
161
- // ✅ Also fine: Explicit subpath (same bundle size)
162
- import { signalTree } from '@signaltree/core';
163
- import { batching } from '@signaltree/core/enhancers/batching';
164
160
  ```
165
161
 
162
+ Published subpaths (in `package.json` `exports`): `./security`, `./edit-session`, `./storage`. Enhancers are NOT a published subpath — they live in the main barrel and are tree-shaken from there.
163
+
166
164
  **Measured impact** (with modern bundlers):
167
165
 
168
166
  - Core only: ~8.5 KB gzipped
@@ -197,35 +195,36 @@ const tree = signalTree({ count: 0 });
197
195
 
198
196
  This repo's ESLint rule is **disabled by default** since testing confirms effective tree-shaking with barrel imports.
199
197
 
200
- ### Callable leaf signals (DX sugar only)
198
+ ### Callable shape branches natively, leaves with the build transform
201
199
 
202
- SignalTree provides TypeScript support for callable syntax on leaf signals as developer experience sugar:
200
+ **Branches are natively callable** for reads AND writes at runtime no transform required:
203
201
 
204
202
  ```typescript
205
- // TypeScript accepts this syntax (with proper tooling):
206
- tree.$.name('Jane'); // Set value
207
- tree.$.count((n) => n + 1); // Update with function
203
+ tree.$.user(); // Read the user subtree
204
+ tree.$.user({ name: 'Jane' }); // Deep-merge partial update at runtime
205
+ ```
206
+
207
+ **Leaves are Angular signals** — callable as getters, but writes go through `.set()` / `.update()`. The `@signaltree/callable-syntax` build-time transform extends the branch's call-with-arg shape down to leaf writes, so call-sites read uniformly from root to leaf:
208
208
 
209
- // At build time, transforms convert to:
210
- tree.$.name.set('Jane'); // Direct Angular signal API
211
- tree.$.count.update((n) => n + 1); // Direct Angular signal API
209
+ ```typescript
210
+ // With @signaltree/callable-syntax transform installed:
211
+ tree.$.name('Jane'); // compiles to tree.$.name.set('Jane')
212
+ tree.$.count((n) => n + 1); // compiles to tree.$.count.update((n) => n + 1)
212
213
 
213
- // Reading always works directly:
214
- const name = tree.$.name(); // No transform needed
214
+ // Without the transform — leaf reads work, leaf writes use .set() / .update():
215
+ const name = tree.$.name();
216
+ tree.$.name.set('Jane');
217
+ tree.$.count.update((n) => n + 1);
215
218
  ```
216
219
 
217
220
  **Key Points:**
218
221
 
219
- - **Zero runtime overhead**: No Proxy wrappers or runtime hooks
220
- - **Build-time only**: AST transform converts callable syntax to direct `.set/.update` calls
221
- - **Optional**: Use `@signaltree/callable-syntax` transform or stick with direct `.set/.update`
222
- - **Type-safe**: Full TypeScript support via module augmentation
222
+ - **Zero runtime overhead**: branch callables are a native part of the proxy; the leaf-write transform compiles away before production
223
+ - **Optional**: `@signaltree/callable-syntax` is only needed for leaf-write sugar `.set()` / `.update()` always work
224
+ - **Type-safe**: full TypeScript support via module augmentation
225
+ - **Configure `rootIdentifiers`**: the transform's default is `['tree']`; if your variable is named `store`/`state`, add it to the plugin options or the rewrite is skipped
223
226
 
224
- **Function-valued leaves:**
225
- When a leaf stores a function as its value, use direct `.set(fn)` to assign. Callable `sig(fn)` is treated as an updater.
226
-
227
- **Setup:**
228
- Install `@signaltree/callable-syntax` and configure your build tool to apply the transform. Without the transform, use `.set/.update` directly.
227
+ **Function-valued leaves:** when a leaf stores a function as its value, use direct `.set(fn)` to assign. Callable `sig(fn)` is treated as an updater.
229
228
 
230
229
  ### Measuring performance and size
231
230
 
@@ -282,7 +281,7 @@ export function createUserTree() {
282
281
  // ✅ Correct: Derived from multiple signals
283
282
  const selectedUser = computed(() => {
284
283
  const id = $.selected.userId();
285
- return id ? $.users.byId(id)() : null;
284
+ return id ? $.users.byId(id)?.() ?? null : null;
286
285
  });
287
286
 
288
287
  // ❌ Wrong: Wrapping an existing signal
@@ -293,12 +292,12 @@ const selectedUserId = computed(() => $.selected.userId()); // Unnecessary!
293
292
 
294
293
  ```typescript
295
294
  // ✅ SignalTree-native
296
- const user = $.users.byId(123)(); // O(1) lookup
295
+ const user = $.users.byId(123)?.(); // EntityNode → User | undefined
297
296
  const allUsers = $.users.all; // Get all
298
297
  $.users.setAll(usersFromApi); // Replace all
299
298
 
300
- // NgRx-style (avoid)
301
- const user = entityMap()[123]; // Requires intermediate object
299
+ // (NgRx Signal Store equivalent — for context, not SignalTree syntax)
300
+ // const user = usersStore.entityMap()[123];
302
301
  ```
303
302
 
304
303
  ### Notification Batching
@@ -621,9 +620,9 @@ const tree = signalTree<AppState>({
621
620
  },
622
621
  });
623
622
 
624
- // Complex updates with type safety
625
- // Requires @signaltree/callable-syntax. Without the transform, use
626
- // tree.set((state) => ({ ... })) or leaf .set() / .update() calls instead.
623
+ // Complex updates with type safety — the root accessor itself is callable
624
+ // (no @signaltree/callable-syntax transform required for the root). Pass a
625
+ // partial object or an updater function. For leaf writes, use .set() / .update().
627
626
  tree((state) => ({
628
627
  auth: {
629
628
  ...state.auth,
@@ -722,7 +721,7 @@ const activeUsers = computed(() => tree.$.users().filter((user) => user.active))
722
721
 
723
722
  ### 4) Manual async state management
724
723
 
725
- Core provides basic state updates. For advanced async helpers, use the built-in async helpers (`createAsyncOperation`, `trackAsync`):
724
+ Core provides basic state updates. For canonical async patterns, use the **`asyncSource` and `asyncQuery` markers** (see the async section). The patterns below show the manual style for cases the markers don't cover (multi-stage orchestration, conditional pipelines):
726
725
 
727
726
  ```typescript
728
727
  const tree = signalTree({
@@ -783,8 +782,8 @@ All enhancers are exported directly from `@signaltree/core`:
783
782
 
784
783
  **Data Management:**
785
784
 
786
- - `createAsyncOperation()` - Async operation management with loading/error states
787
- - `trackAsync()` - Track async operations in your state
785
+ - `asyncSource(config)` marker - Load-and-expose async state (canonical, v9.5+)
786
+ - `asyncQuery(config)` marker - Input-driven debounced query state (canonical, v9.5+)
788
787
  - `serialization()` - State persistence and SSR support
789
788
  - `persistence()` - Auto-save to localStorage/IndexedDB
790
789
 
@@ -851,7 +850,10 @@ tree.$.products.addOne(newProduct);
851
850
  tree.$.products.setAll(productsFromApi);
852
851
 
853
852
  // Entity queries
854
- const electronics = tree.$.products.all.filter((p) => p.category === 'electronics');
853
+ // Reactive: returns Signal<Product[]> that tracks the predicate
854
+ const electronics = tree.$.products.where((p) => p.category === 'electronics');
855
+ // One-shot non-reactive read: call .all() then filter
856
+ const electronicsSnapshot = tree.$.products.all().filter((p) => p.category === 'electronics');
855
857
  ```
856
858
 
857
859
  **Full-Stack Application:**
@@ -910,7 +912,7 @@ const tree = signalTree(state)
910
912
  SignalTree Core includes all enhancer functionality built-in. No separate packages needed:
911
913
 
912
914
  ```typescript
913
- import { signalTree, entityMap, entities } from '@signaltree/core';
915
+ import { signalTree, entityMap } from '@signaltree/core';
914
916
 
915
917
  // Without entityMap - use manual array updates
916
918
  const basic = signalTree({ users: [] as User[] });
@@ -922,7 +924,7 @@ const enhanced = signalTree({
922
924
  });
923
925
 
924
926
  enhanced.$.users.addOne(newUser); // ✅ Advanced CRUD operations
925
- enhanced.$.users.byId(123)(); // ✅ O(1) lookups
927
+ enhanced.$.users.byId(123)?.(); // ✅ O(1) lookups (undefined if missing)
926
928
  enhanced.$.users.all; // ✅ Get all as array
927
929
  ```
928
930
 
@@ -1261,17 +1263,26 @@ const tree = signalTree({
1261
1263
  tree.$.users.loadStatus.state(); // Signal<LoadingState>
1262
1264
  tree.$.users.loadStatus.error(); // Signal<ApiError | null>
1263
1265
 
1264
- // Convenience signals
1265
- tree.$.users.loadStatus.isNotLoaded(); // Signal<boolean>
1266
- tree.$.users.loadStatus.isLoading(); // Signal<boolean>
1267
- tree.$.users.loadStatus.isLoaded(); // Signal<boolean>
1268
- tree.$.users.loadStatus.isError(); // Signal<boolean>
1266
+ // Convenience signals (v10.3 canonical — bare names)
1267
+ tree.$.users.loadStatus.notLoaded(); // Signal<boolean>
1268
+ tree.$.users.loadStatus.loading(); // Signal<boolean>
1269
+ tree.$.users.loadStatus.loaded(); // Signal<boolean>
1270
+ tree.$.users.loadStatus.hasError(); // Signal<boolean>
1271
+ // Deprecated aliases (work through v10.x, removed v11):
1272
+ // .isNotLoaded(), .isLoading(), .isLoaded(), .isError()
1269
1273
 
1270
- // Update methods
1274
+ // Update methods (canonical)
1271
1275
  tree.$.users.loadStatus.setLoading();
1272
1276
  tree.$.users.loadStatus.setLoaded();
1273
1277
  tree.$.users.loadStatus.setError({ code: 404, message: 'Not found' });
1274
- tree.$.users.loadStatus.reset();
1278
+ tree.$.users.loadStatus.setNotLoaded();
1279
+ tree.$.users.loadStatus.reset(); // alias for setNotLoaded
1280
+
1281
+ // v10.2+ Promise-vocabulary aliases (identical semantics, no args)
1282
+ tree.$.users.loadStatus.start(); // === setLoading()
1283
+ tree.$.users.loadStatus.setSuccess(); // === setLoaded() — NO ARGS
1284
+ tree.$.users.loadStatus.succeed(); // === setLoaded()
1285
+ tree.$.users.loadStatus.fail(err); // === setError(err)
1275
1286
 
1276
1287
  // LoadingState enum
1277
1288
  LoadingState.NotLoaded; // 'not-loaded'
@@ -1546,23 +1557,12 @@ const tree = signalTree({
1546
1557
  async function handleSubmit() {
1547
1558
  const contactForm = tree.$.contact;
1548
1559
 
1549
- // Validate all fields first
1550
- contactForm.touchAll();
1551
- const isValid = await contactForm.validate();
1552
-
1553
- if (!isValid) return;
1554
-
1555
- // Set submitting state
1556
- contactForm.setSubmitting(true);
1557
-
1558
- try {
1559
- await api.submit(contactForm());
1560
+ // .submit() handles touchAll / validate / submitting toggling / error trapping
1561
+ // internally. Pass a handler that does the actual network call.
1562
+ await contactForm.submit(async (values) => {
1563
+ await api.submit(values);
1560
1564
  contactForm.reset();
1561
- } catch (error) {
1562
- // Handle error
1563
- } finally {
1564
- contactForm.setSubmitting(false);
1565
- }
1565
+ });
1566
1566
  }
1567
1567
  ```
1568
1568
 
@@ -1715,7 +1715,7 @@ const filteredProducts = computed(() => {
1715
1715
  ### Data Management Composition
1716
1716
 
1717
1717
  ```typescript
1718
- import { signalTree, entityMap, entities } from '@signaltree/core';
1718
+ import { signalTree, entityMap } from '@signaltree/core';
1719
1719
 
1720
1720
  // entityMap() markers self-register — entity operations available immediately
1721
1721
  const tree = signalTree({
@@ -1727,7 +1727,7 @@ const tree = signalTree({
1727
1727
  // Advanced entity operations via tree.$ accessor
1728
1728
  tree.$.users.addOne(newUser);
1729
1729
  tree.$.users.where((u) => u.active);
1730
- tree.$.users.updateMany([{ id: '1', changes: { status: 'active' } }]);
1730
+ tree.$.users.updateMany(['1', '2', '3'], { status: 'active' }); // ids + shared changes
1731
1731
 
1732
1732
  // Entity helpers work with nested structures
1733
1733
  // Example: deeply nested entities in a domain-driven design pattern
@@ -1803,7 +1803,7 @@ const tree = signalTree({
1803
1803
  async function fetchUser(id: string) {
1804
1804
  return await api.getUser(id);
1805
1805
  }
1806
- tree.$.app.data.users.byId(userId)(); // O(1) lookup
1806
+ tree.$.app.data.users.byId(userId)?.(); // O(1) lookup (undefined if missing)
1807
1807
  tree.undo(); // Time travel
1808
1808
  tree.save(); // Persistence
1809
1809
  ```
@@ -1867,7 +1867,7 @@ In Redux DevTools you will see a single instance named `"MyApp SignalTree"` with
1867
1867
  ### Production-Ready Composition
1868
1868
 
1869
1869
  ```typescript
1870
- import { signalTree, batching, entities, serialization } from '@signaltree/core';
1870
+ import { signalTree, batching, serialization } from '@signaltree/core';
1871
1871
 
1872
1872
  // Production build (no dev tools)
1873
1873
  const tree = signalTree(initialState)
@@ -1930,7 +1930,7 @@ const tree = signalTree(state);
1930
1930
  const tree2 = tree.with(batching());
1931
1931
 
1932
1932
  // Phase 3: Add async for API integration
1933
- // withAsync removed — no explicit async enhancer; use async helpers instead
1933
+ // withAsync removed in v9 use the asyncSource() / asyncQuery() markers (v10.x canonical) for load-and-expose and input-driven flows.
1934
1934
 
1935
1935
  // Each phase is fully functional and production-ready
1936
1936
  ```
@@ -2218,7 +2218,7 @@ tree.destroy(); // Cleanup resources
2218
2218
 
2219
2219
  // Entity helpers (entityMap() self-registers — no enhancer needed)
2220
2220
  tree.$.users.addOne(user); // Add single entity
2221
- tree.$.users.byId(id)(); // O(1) lookup by ID (read whole entity)
2221
+ tree.$.users.byId(id)?.(); // O(1) cursor unwrap (undefined if missing)
2222
2222
  tree.$.users.byId(id)?.name(); // Read single field (computed signal)
2223
2223
  tree.$.users.byId(id)?.name.set('Bob'); // Write single field (throws if entity removed)
2224
2224
  tree.$.users.byId(id)?.name.update(fn); // Updater on single field
@@ -2280,7 +2280,7 @@ Consider enhancers when you need:
2280
2280
 
2281
2281
  - ⚡ Performance optimization (batching)
2282
2282
  - 🐛 Advanced debugging (devTools, timeTravel)
2283
- - 📦 Entity management (entities)
2283
+ - 📦 Entity management (`entityMap` marker)
2284
2284
 
2285
2285
  Consider separate packages when you need:
2286
2286
 
@@ -2466,8 +2466,8 @@ npm install @signaltree/core
2466
2466
  # Everything is available from @signaltree/core:
2467
2467
  import {
2468
2468
  signalTree,
2469
+ entityMap,
2469
2470
  batching,
2470
- entities,
2471
2471
  devTools,
2472
2472
  timeTravel,
2473
2473
  serialization
@@ -2797,7 +2797,7 @@ export default {
2797
2797
  **Start with just `@signaltree/core`** - it includes comprehensive enhancers for most applications:
2798
2798
 
2799
2799
  - Performance optimization (batching)
2800
- - Data management (entities, async operations)
2800
+ - Data management (`entityMap` marker, `asyncSource` / `asyncQuery` markers)
2801
2801
  - Development tools (devtools, time-travel)
2802
2802
  - State persistence (serialization)
2803
2803
 
package/llms-full.txt CHANGED
@@ -38,9 +38,9 @@ const store = signalTree({
38
38
 
39
39
  // With config:
40
40
  const store2 = signalTree(initialState, {
41
- batchUpdates: true, // default — microtask-level notification batching
42
- equalityFn: Object.is, // custom signal equality
43
- treeName: 'AppStore', // for devtools labeling
41
+ batchUpdates: true, // default — microtask-level notification batching
42
+ useShallowComparison: true, // switch leaf signal equality from deepEqual to Object.is
43
+ treeName: 'AppStore', // for devtools labeling
44
44
  });
45
45
  ```
46
46
 
@@ -59,14 +59,15 @@ Both `store.$` and `store.state` point to the same TreeNode. Use whichever reads
59
59
  ```typescript
60
60
  store.$.user.name.set('Bob');
61
61
  store.$.user.age.update((n) => n + 1);
62
- store.$.user({ name: 'Carol', age: 25 }); // replace a whole branch
63
- store({ user: { name: 'Dave', age: 40 }, settings: { theme: 'dark' } }); // replace full state
62
+ store.$.user({ name: 'Carol', age: 25 }); // partial deep-merge update; sibling keys preserved
63
+ store({ user: { name: 'Dave', age: 40 }, settings: { theme: 'dark' } }); // partial deep-merge of root; keys not in payload preserved
64
+ store(); // no-arg call returns the current snapshot
64
65
  ```
65
66
 
66
67
  ### Lifecycle
67
68
 
68
69
  ```typescript
69
- store.destroy(); // tears down all enhancer resources in reverse order
70
+ store.destroy(); // runs registered cleanup hooks in registration order
70
71
  store.destroyed(); // Signal<boolean>
71
72
  store.registerCleanup(() => ws.close()); // custom hook called during destroy
72
73
  ```
@@ -95,7 +96,7 @@ store.$.users.setAll(users);
95
96
  store.$.users.upsertOne(user);
96
97
  store.$.users.upsertMany(users);
97
98
  store.$.users.updateOne(id, changes);
98
- store.$.users.updateMany([{ id, changes }, ...]);
99
+ store.$.users.updateMany([id1, id2, id3], { active: false }); // shared Partial<E> applied to every id — NOT NgRx-style [{id, changes}]
99
100
  store.$.users.updateWhere(pred, changes);
100
101
  store.$.users.removeOne(id);
101
102
  store.$.users.removeMany(ids);
@@ -104,7 +105,7 @@ store.$.users.clear();
104
105
 
105
106
  // Queries (all return signals)
106
107
  store.$.users.all(); // Signal<User[]>
107
- store.$.users.byId(id); // Signal<User | undefined>
108
+ store.$.users.byId(id); // EntityNode<User> | undefined — invoke: .byId(id)?.() → User | undefined
108
109
  store.$.users.count(); // Signal<number>
109
110
  store.$.users.has(id); // Signal<boolean>
110
111
  store.$.users.ids(); // Signal<number[]>
@@ -128,20 +129,28 @@ const store = signalTree({
128
129
  },
129
130
  });
130
131
 
131
- // Read
132
- store.$.users.loading.state(); // Signal<LoadingState>
133
- store.$.users.loading.error(); // Signal<ApiError | null>
134
- store.$.users.loading.isLoading(); // Signal<boolean>
135
- store.$.users.loading.isLoaded();
136
- store.$.users.loading.isError();
137
- store.$.users.loading.isNotLoaded();
138
-
139
- // Mutate
132
+ // Read — v10.3 canonical (bare names)
133
+ store.$.users.loading.state(); // LoadingState (calling Signal<LoadingState>)
134
+ store.$.users.loading.error(); // ApiError | null
135
+ store.$.users.loading.loading(); // boolean
136
+ store.$.users.loading.loaded(); // boolean
137
+ store.$.users.loading.hasError(); // boolean
138
+ store.$.users.loading.notLoaded(); // boolean
139
+ // Deprecated through v10.x, removed v11: .isLoading, .isLoaded, .isError, .isNotLoaded
140
+ // (same Signal instance — both work; canonical preferred in new code)
141
+
142
+ // Mutate — canonical methods
140
143
  store.$.users.loading.setLoading();
141
144
  store.$.users.loading.setLoaded();
142
145
  store.$.users.loading.setError(err);
143
146
  store.$.users.loading.setNotLoaded();
144
147
  store.$.users.loading.reset();
148
+
149
+ // v10.2+ Promise-vocabulary aliases (identical semantics, no args)
150
+ store.$.users.loading.start(); // === setLoading()
151
+ store.$.users.loading.setSuccess(); // === setLoaded() — NO ARGS
152
+ store.$.users.loading.succeed(); // === setLoaded()
153
+ store.$.users.loading.fail(err); // === setError(err)
145
154
  ```
146
155
 
147
156
  ### `stored(key, defaultValue, options?)`
@@ -166,9 +175,9 @@ store.$.settings.theme.reload(); // re-read from localStorage
166
175
 
167
176
  Supports versioning and migration functions via the third argument. Use for individual per-leaf persistence; use the `persistence()` enhancer for tree-wide persistence with storage adapters.
168
177
 
169
- ### `form<T>(config)` (from `@signaltree/ng-forms` integration)
178
+ ### `form<T>(config: FormConfig<T>)`
170
179
 
171
- Tree-integrated form with validation, wizard, and persistence. Materializes into a form signal exposing field state, validation status, and submit/reset helpers. See `@signaltree/ng-forms` documentation for full config schema.
180
+ Tree-integrated form marker, **exported from `@signaltree/core`** (not from `@signaltree/ng-forms`). Materializes into a form signal exposing field state, validation status, and submit/reset helpers. Requires `{ initial: T, validators?, asyncValidators?, wizard? }`. `@signaltree/ng-forms` is a separate **bridge** that binds these markers to Angular `FormGroup` — useful but not required.
172
181
 
173
182
  ### Custom markers via `registerMarkerProcessor(spec)`
174
183
 
@@ -278,7 +287,7 @@ const store = signalTree({ count: 0, items: [] })
278
287
 
279
288
  store.batch(() => {
280
289
  store.$.count.set(10);
281
- store.$.items.push({ id: 1 });
290
+ store.$.items.update((arr) => [...arr, { id: 1 }]); // .push() doesn't exist — arrays live in a WritableSignal
282
291
  });
283
292
 
284
293
  store.undo();
@@ -303,6 +312,8 @@ store.$.count((n) => n + 1); // → store.$.count.update((n) => n + 1
303
312
 
304
313
  **This is not a runtime proxy.** The transform runs at build time and disappears in production — there is no wrapper function, no `Proxy` object, no runtime overhead. Install as a dev dependency, register the Vite or Webpack plugin, and the transform compiles away.
305
314
 
315
+ > **Configure `rootIdentifiers`**: the plugin's default is `['tree']`. If your tree variable is named `store` or `state` (common in service facades), pass `{ rootIdentifiers: ['tree', 'store', 'state'] }` to the plugin options — variables not in this list are silently skipped.
316
+
306
317
  ---
307
318
 
308
319
  ## Subpath imports
@@ -310,12 +321,13 @@ store.$.count((n) => n + 1); // → store.$.count.update((n) => n + 1
310
321
  Specialized APIs live in subpaths to keep the main barrel small:
311
322
 
312
323
  ```typescript
313
- import { TREE_PRESETS, createDevTree, createProdTree } from '@signaltree/core/presets';
314
324
  import { SecurityValidator, SecurityPresets } from '@signaltree/core/security';
315
- import { createEditSession } from '@signaltree/core/edit-session';
325
+ import { createEditSession, createTreeEditSession } from '@signaltree/core/edit-session';
316
326
  import { createStorageAdapter, createIndexedDBAdapter } from '@signaltree/core/storage';
317
327
  ```
318
328
 
329
+ The only published subpaths in `@signaltree/core` are `./security`, `./edit-session`, and `./storage`. The main barrel (`@signaltree/core`) re-exports everything; modern bundlers tree-shake unused symbols regardless.
330
+
319
331
  ### Async — `asyncSource` and `asyncQuery` markers (canonical, v9.5+)
320
332
 
321
333
  Async state belongs **at the tree path it describes**. Two markers cover the two main async patterns and compose with the rest of the marker family:
@@ -531,7 +543,7 @@ This is the pattern enforced in production migrations. The `$` access stays read
531
543
  | `status` | `.notLoaded` | `.isNotLoaded` |
532
544
  | `status` | `.hasError` | `.isError` |
533
545
  | `entityMap` | `.empty` | `.isEmpty` |
534
- | `form` | `.dirty`, `.valid`, `.touched`, `.pristine` | (already bare — unchanged) |
546
+ | `form` | `.dirty`, `.valid`, `.touched`, `.submitting` | (already bare — unchanged) |
535
547
  | `asyncSource` | `.loading`, `.error`, `.data` | (already bare — unchanged) |
536
548
  | `asyncQuery` | `.loading`, `.error`, `.data` | (already bare — unchanged) |
537
549
 
@@ -591,7 +603,7 @@ For **input-driven queries** (debounced search, filtered fetch), use `asyncQuery
591
603
  | "Markers must live at the tree root." | Comparison to NgRx `with*` features. | Markers attach at **any node, any depth**. The walker tracks the path. |
592
604
  | "Derived state must live in a separate file, breaking the single-tree illusion." | Mixing up `derivedFrom` (file-org helper) with the actual `.derived($)` method. | `.derived()` merges computed signals **into the tree at arbitrary paths**, alongside source properties. The "single tree" is preserved. |
593
605
  | "`derivedFrom(tree, fn)` returns a read-only projection." | Hallucinated signature. | Real signature: `derivedFrom<TTree>()(fn)`. Curried. Zero runtime cost. Pure file-org helper for multi-file derived. |
594
- | "SignalTree has explicit subpath isolation as a built-in feature." | Misreading the docs. | No subpath-isolation API exists. `createEditSession(initial)` is a value-level undo/redo primitive (NOT tree-bound that's planned for v10.1). For write encapsulation use `@signaltree/events` or a service facade. |
606
+ | "SignalTree has explicit subpath isolation as a built-in feature." | Misreading the docs. | No subpath-isolation API exists. `createEditSession(initial)` is a value-level undo/redo primitive; for path-bound drafts use `createTreeEditSession(accessor)` (v10.1+, in `@signaltree/core/edit-session`). For write encapsulation use `@signaltree/events` or a service facade. |
595
607
  | "Time-travel is in `@signaltree/time-travel`." | Made-up package name. | Import `timeTravel` from `@signaltree/core`. No such separate package. |
596
608
  | "Persistence is in `@signaltree/storage`." | Made-up package name. | Use `stored()` marker (per-leaf) or `persistence()` enhancer (tree-wide) from `@signaltree/core`. |
597
609
  | "Batching is opt-in only." | Conflating automatic notification batching with the `batching()` enhancer. | Automatic microtask notification batching is built into core (default on). The enhancer adds explicit `.batch(fn)` / `.coalesce(fn)`. |
@@ -603,7 +615,7 @@ For **input-driven queries** (debounced search, filtered fetch), use `asyncQuery
603
615
  | "`tree.$` and `tree.state` are different objects." | Plausible-sounding inference. | Both are typed `TreeNode<T>` and reference the same reactive proxy. `state` is an alias for `$`. For a non-reactive full snapshot, call `tree()`. |
604
616
  | "The `form()` marker lives in `@signaltree/ng-forms`." | Package-boundary inference. | `form()` ships in `@signaltree/core`. `@signaltree/ng-forms` is a *bridge* for binding tree nodes to Angular `FormGroup`. |
605
617
  | "entityMap exposes `.entities()` as the read accessor." | Reasonable guess. | Real accessor is `.all()`. Other reads: `.byId(id)`, `.where(pred)`, `.find(pred)`, `.count()`, `.has(id)`, `.ids()`. |
606
- | "status exposes `.setSuccess()`." | NgRx/Redux convention bleed. | Real methods: `.setLoading()`, `.setLoaded()`, `.setError(err)`, `.setNotLoaded()`, `.reset()`. There is no `.setSuccess()`. |
618
+ | "status exposes `.setSuccess()`." | NgRx/Redux convention bleed. | Canonical methods are `.setLoading()`, `.setLoaded()`, `.setError(err)`, `.setNotLoaded()`, `.reset()`. As of v10.2, Promise-vocabulary aliases `.start()` / `.setSuccess()` / `.succeed()` / `.fail(err)` also work — same semantics, no second source of truth. |
607
619
  | ".derived('$.path', derivedFn)' is a two-arg subpath form." | Hallucinated overload. | `.derived($ => ({...}))` is single-arg. The shape of the returned object determines which paths the computed signals attach to via deep-merge. |
608
620
  | "NgRx SignalStore mutations are impossible from components by design." | Overstating defaults. | Components can mutate when `protectedState: false` is set on the store, or when the store exposes a method that mutates. Both libraries are guarded-by-default but unlockable. |
609
621
 
package/llms.txt CHANGED
@@ -1,6 +1,6 @@
1
1
  # SignalTree
2
2
 
3
- > Reactive JSON for Angular. Turn a plain object into a tree of signals — no actions, no reducers, no selectors. Markers and derived state attach at any depth in the tree.
3
+ > Reactive JSON for Angular. State as shape. Signals at every path. Markers and derived state attach at any depth in the tree.
4
4
 
5
5
  SignalTree is a state-management library for Angular 17+. The mental model is: your state is a typed JSON object; reading and writing use ordinary signal calls along the JSON path. Markers (`entityMap`, `status`, `stored`, `form`) and derived state (`.derived(...)`) attach **at any node, at any depth** — they are processed by a recursive walker, not composed at the store root. This is the load-bearing difference from `@ngrx/signals` (NgRx SignalStore), whose `with*` features compose at the store root only.
6
6
 
@@ -20,7 +20,7 @@ const store = signalTree({
20
20
  settings: {
21
21
  theme: stored('app-theme', 'light'), // marker at depth 2
22
22
  profileForm: {
23
- data: form<Profile>({ name: '', email: '' }), // marker at depth 3
23
+ data: form<Profile>({ initial: { name: '', email: '' } }), // marker at depth 3
24
24
  },
25
25
  },
26
26
  loading: status<ApiError>(), // marker at depth 1
@@ -46,7 +46,7 @@ const store = signalTree({
46
46
  // Read — leaves, derived, async-marker accessors all uniform
47
47
  store.$.users.all(); // Signal<User[]>
48
48
  store.$.users.current(); // Signal<User | null> (derived)
49
- store.$.settings.theme(); // 'light' (auto-loaded from localStorage)
49
+ store.$.settings.theme(); // 'light' (default; replaced by any value previously persisted to localStorage)
50
50
  store.$.reports(); // Report[] (asyncSource current value)
51
51
  store.$.reports.loading(); // boolean
52
52
  store.$.search.input.set('alice'); // drives debounced query
@@ -91,17 +91,17 @@ store.$.reports.refresh(); // reload async source
91
91
  - **Create:** `signalTree(initialState, config?)` → tree with `.$` accessor
92
92
  - **Read leaf:** `tree.$.path.to.leaf()` — returns the value
93
93
  - **Write leaf:** `tree.$.path.to.leaf.set(v)` / `.update(fn)`
94
- - **Replace full state:** `tree(newState)`
94
+ - **Partial / deep-merge update:** `tree(partialUpdate)` — keys not in the payload are preserved. The root has no `.set` method; the root accessor itself is callable.
95
95
  - **Markers:** `entityMap<E, K>()`, `status<E>()`, `stored(key, default)`, `form<T>(config)`, `asyncSource<T>(config)`, `asyncQuery<TIn, TOut>(config)` — place at any node
96
96
  - **Derived state:** `.derived($ => ({ ... }))` — definitions deep-merged into existing tree paths
97
97
  - **Enhancers:** `.with(batching())`, `.with(devTools())`, `.with(timeTravel())`, `.with(persistence())`, `.with(serialization())`
98
- - **Lifecycle:** `tree.destroy()` tears down all resources in reverse enhancer order; `tree.destroyed()` is a signal; `tree.registerCleanup(fn)` for custom hooks
99
- - **Edit sessions:** `createEditSession(initial)` from `@signaltree/core/edit-session` — wraps any value with `applyChanges` / `undo` / `redo` / `reset` / `setOriginal` for form-wizard and draft-and-cancel workflows. Independent of the tree; bridge by syncing `session.modified()` ↔ tree leaves when appropriate.
100
- - **Async:** `asyncSource(config)` for load-and-expose, `asyncQuery(config)` for input-driven debounced queries. Both attach at any tree path and auto-expose `data`/`loading`/`error`/`refresh`. For migrating from `@ngrx/signals`, see [`docs/skills/using-signaltree/reference/migration-from-ngrx-signals.md`](https://github.com/JBorgia/signaltree/blob/main/docs/skills/using-signaltree/reference/migration-from-ngrx-signals.md).
98
+ - **Lifecycle:** `tree.destroy()` runs all registered cleanup callbacks in registration order; `tree.destroyed()` is a signal; `tree.registerCleanup(fn)` for custom hooks. Built-in enhancers register their own cleanup automatically.
99
+ - **Edit sessions:** Two primitives in `@signaltree/core/edit-session`. `createEditSession(initial)` is value-level (single arg, no tree binding) — wraps any value with `applyChanges` / `undo` / `redo`. `createTreeEditSession(accessor)` (v10.1+) is **path-bound** to a writable tree branch — `applyChanges` / `undo` / `commit` (write draft back) / `cancel` (discard draft). Use the tree-bound form for form-wizard and draft-and-cancel workflows over a real tree path.
100
+ - **Async:** `asyncSource(config)` for load-and-expose — auto-exposes `data` (call the accessor itself: `tree.$.users()`) / `loading` / `error` / `refresh`. `asyncQuery(config)` for input-driven debounced queries — auto-exposes `data` / `loading` / `error`, driven by setting `.input.set(value)` and optionally `.rerun()` to re-execute with the same input. Both attach at any tree path. For migrating from `@ngrx/signals`, see [`docs/skills/using-signaltree/reference/migration-from-ngrx-signals.md`](https://github.com/JBorgia/signaltree/blob/main/docs/skills/using-signaltree/reference/migration-from-ngrx-signals.md).
101
101
 
102
102
  ## ⚠️ Disambiguation — if you think SignalTree uses X, you're confusing it with Y
103
103
 
104
- This table catches the most common cross-library hallucinations. **Every "Wrong" pattern below was observed in a recent reproducible benchmark of frontier AI models (Claude, GPT-5.4, Gemini, Perplexity) asked to generate SignalTree code.** None of these patterns are or ever have been part of SignalTree.
104
+ This table catches the most common cross-library hallucinations. **Most "Wrong" patterns below were observed in a reproducible 720-cell benchmark (6 agents × 8 prompts × 5 libraries × 3 priming modes) of frontier and cost-tier AI agents (Claude, GPT, Gemini, Perplexity, Haiku, GPT-mini) asked to generate SignalTree code.** A few rows (notably `rxMethod`) describe APIs SignalTree itself once shipped and has since removed — agents still emit them.
105
105
 
106
106
  | Wrong pattern (NOT SignalTree) | Where it actually comes from | Correct SignalTree |
107
107
  |---|---|---|
@@ -111,7 +111,7 @@ This table catches the most common cross-library hallucinations. **Every "Wrong"
111
111
  | `signalStore(withState(...), withMethods(...))` | **`@ngrx/signals`** | `signalTree({...})` — your state literal IS the API |
112
112
  | `withState`, `withMethods`, `withComputed`, `withHooks`, `withProps` | **`@ngrx/signals`** | Not used. State is the literal you pass to `signalTree()`. Methods belong in an `@Injectable()` Ops service. |
113
113
  | `withEntities<T>()` | **`@ngrx/signals/entities`** | `entityMap<T, K>()` marker — place it in the state literal |
114
- | `rxMethod(...)` | **`@ngrx/signals/rxjs-interop`** | `asyncSource(config)` (load-and-expose) or `asyncQuery(config)` (input-driven) markers |
114
+ | `rxMethod(...)` | **`@ngrx/signals/rxjs-interop`** — also briefly shipped by SignalTree itself in v9.5.x, **removed in v9.6.0** | `asyncSource(config)` (load-and-expose) or `asyncQuery(config)` (input-driven) markers |
115
115
  | `patchState(store, {...})` | **`@ngrx/signals`** | Direct: `tree.$.path.set(value)` or branch update `tree.$.user({...})` |
116
116
  | `collection<T>({ idKey: 'id' })` | **Akita / Elf** | `entityMap<T, K>({ selectId: (e) => e.id })` marker |
117
117
  | `createStore`, `withProps`, `setProps` | **Elf** | Not used. SignalTree state is the literal. |
@@ -133,7 +133,7 @@ This table catches the most common cross-library hallucinations. **Every "Wrong"
133
133
  | `status` | `.notLoaded` | `.isNotLoaded` |
134
134
  | `status` | `.hasError` | `.isError` |
135
135
  | `entityMap` | `.empty` | `.isEmpty` |
136
- | `form` | `.dirty`, `.valid`, `.touched`, `.pristine` | (already bare — unchanged) |
136
+ | `form` | `.dirty`, `.valid`, `.touched`, `.submitting` | (already bare — unchanged) |
137
137
  | `asyncSource` | `.loading`, `.error`, `.data` | (already bare — unchanged) |
138
138
  | `asyncQuery` | `.loading`, `.error`, `.data` | (already bare — unchanged) |
139
139
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@signaltree/core",
3
- "version": "10.3.0",
4
- "description": "Reactive JSON for Angular. JSON branches, reactive leaves. No actions. No reducers. No selectors.",
3
+ "version": "10.3.2",
4
+ "description": "Reactive JSON for Angular. State as shape. Signals at every path.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "sideEffects": false,