@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.
- package/README.md +83 -83
- package/llms-full.txt +37 -25
- package/llms.txt +10 -10
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -4,15 +4,13 @@
|
|
|
4
4
|
|
|
5
5
|
# SignalTree: Reactive JSON
|
|
6
6
|
|
|
7
|
-
**JSON
|
|
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
|
|
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
|
|
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`, `.
|
|
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
|
|
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
|
-
|
|
67
|
-
|
|
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() →
|
|
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
|
|
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 (
|
|
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
|
-
//
|
|
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
|
|
198
|
+
### Callable shape — branches natively, leaves with the build transform
|
|
201
199
|
|
|
202
|
-
|
|
200
|
+
**Branches are natively callable** for reads AND writes at runtime — no transform required:
|
|
203
201
|
|
|
204
202
|
```typescript
|
|
205
|
-
//
|
|
206
|
-
tree.$.name
|
|
207
|
-
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
tree.$.
|
|
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
|
-
//
|
|
214
|
-
const name = tree.$.name();
|
|
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**:
|
|
220
|
-
- **
|
|
221
|
-
- **
|
|
222
|
-
- **
|
|
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)(); //
|
|
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
|
-
//
|
|
301
|
-
const user = entityMap()[123];
|
|
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
|
-
//
|
|
626
|
-
//
|
|
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
|
|
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
|
-
- `
|
|
787
|
-
- `
|
|
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
|
-
|
|
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
|
|
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.
|
|
1266
|
-
tree.$.users.loadStatus.
|
|
1267
|
-
tree.$.users.loadStatus.
|
|
1268
|
-
tree.$.users.loadStatus.
|
|
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.
|
|
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
|
-
//
|
|
1550
|
-
|
|
1551
|
-
|
|
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
|
-
}
|
|
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
|
|
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([
|
|
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,
|
|
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 —
|
|
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)();
|
|
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 (
|
|
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 (
|
|
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,
|
|
42
|
-
|
|
43
|
-
treeName: 'AppStore',
|
|
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 }); //
|
|
63
|
-
store({ user: { name: 'Dave', age: 40 }, settings: { theme: 'dark' } }); //
|
|
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(); //
|
|
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); //
|
|
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(); //
|
|
134
|
-
store.$.users.loading.
|
|
135
|
-
store.$.users.loading.
|
|
136
|
-
store.$.users.loading.
|
|
137
|
-
store.$.users.loading.
|
|
138
|
-
|
|
139
|
-
//
|
|
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)`
|
|
178
|
+
### `form<T>(config: FormConfig<T>)`
|
|
170
179
|
|
|
171
|
-
Tree-integrated form
|
|
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.
|
|
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`, `.
|
|
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
|
|
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. |
|
|
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.
|
|
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' (
|
|
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
|
-
- **
|
|
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()`
|
|
99
|
-
- **Edit sessions:**
|
|
100
|
-
- **Async:** `asyncSource(config)` for load-and-expose
|
|
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. **
|
|
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`, `.
|
|
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.
|
|
4
|
-
"description": "Reactive JSON for Angular.
|
|
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,
|