@signaltree/core 10.1.0 → 10.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +118 -0
- package/dist/lib/entity-signal.js +5 -1
- package/dist/lib/markers/status.js +40 -8
- package/llms-full.txt +652 -0
- package/llms.txt +212 -0
- package/package.json +4 -2
- package/src/lib/markers/status.d.ts +8 -0
- package/src/lib/types.d.ts +1 -0
package/README.md
CHANGED
|
@@ -8,6 +8,124 @@
|
|
|
8
8
|
|
|
9
9
|
> No actions. No reducers. No selectors.
|
|
10
10
|
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## 🤖 For AI coding agents reading this README
|
|
14
|
+
|
|
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.
|
|
16
|
+
|
|
17
|
+
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
|
+
|
|
19
|
+
### Disambiguation — common cross-library hallucinations
|
|
20
|
+
|
|
21
|
+
Every "Wrong pattern" below was actually generated by Claude / GPT-5.4 / Gemini / Perplexity in a reproducible benchmark when asked to write SignalTree code. None of these patterns are or have ever been part of SignalTree.
|
|
22
|
+
|
|
23
|
+
| Wrong pattern (NOT SignalTree) | Where it actually comes from | Correct SignalTree |
|
|
24
|
+
|---|---|---|
|
|
25
|
+
| `new SignalTree({...})` (class instantiation) | Invented — no library has this | `signalTree({...})` — a **function call**, never `new` |
|
|
26
|
+
| `from 'signal-tree'` (hyphenated) | Invented | `from '@signaltree/core'` (scoped, no hyphen) |
|
|
27
|
+
| `from 'signaltree'` (unscoped) | Invented | `from '@signaltree/core'` |
|
|
28
|
+
| `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. |
|
|
30
|
+
| `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 |
|
|
32
|
+
| `patchState(store, {...})` | **`@ngrx/signals`** | Direct: `tree.$.path.set(value)` or `tree.$.user({...})` |
|
|
33
|
+
| `tapResponse(...)` | **`@ngrx/operators`** | Not needed — `asyncSource`/`asyncQuery` handle success/error |
|
|
34
|
+
| `collection<T>({ idKey: 'id' })` | **Akita / Elf** | `entityMap<T, K>({ selectId: (e) => e.id })` marker |
|
|
35
|
+
| `createStore`, `withProps`, `setProps` | **Elf** | Not used. SignalTree state is the literal. |
|
|
36
|
+
| `EntityStore<T>`, `@StoreConfig({ name })` | **Akita** | Not used. |
|
|
37
|
+
| `.value` accessors on signals | **MobX** | Call the signal: `tree.$.path()` |
|
|
38
|
+
| `.upsert(user)` (singular suffix omitted) | **Akita** | `.upsertOne(user)` / `.upsertMany([...])` |
|
|
39
|
+
| `BehaviorSubject`, `.next(v)`, `.asObservable()` | **RxJS classic** | A plain leaf in `signalTree()` — no Observable wrapping |
|
|
40
|
+
| `Store.dispatch(action)`, `Store.select(selector)` | **`@ngrx/store` (classic)** | `tree.$.path()` to read, `tree.$.path.set(v)` to write |
|
|
41
|
+
| `.toPromise()` (deprecated RxJS 7+) | RxJS legacy | `firstValueFrom(obs)` — or let `asyncSource` consume directly |
|
|
42
|
+
|
|
43
|
+
### Marker accessor shape — UNIFIED in v10.3
|
|
44
|
+
|
|
45
|
+
**As of v10.3, predicate accessors are bare-named everywhere** — matching `FormControl.dirty` / `.valid` and Angular signals conventions. The old `is`-prefix names on `status` and `entityMap.isEmpty` are kept as **deprecated aliases through v10.x** (return the same Signal instance, no double cost). Removal in v11.0.
|
|
46
|
+
|
|
47
|
+
| Marker | v10.3 canonical (preferred) | Deprecated alias (v10.x only) |
|
|
48
|
+
|---|---|---|
|
|
49
|
+
| `status` | `.loading`, `.loaded`, `.notLoaded`, `.hasError` | `.isLoading`, `.isLoaded`, `.isNotLoaded`, `.isError` |
|
|
50
|
+
| `entityMap` | `.empty` | `.isEmpty` |
|
|
51
|
+
| `form` | `.dirty`, `.valid`, `.touched`, `.pristine` | (already bare — unchanged) |
|
|
52
|
+
| `asyncSource` / `asyncQuery` | `.loading`, `.error`, `.data` | (already bare — unchanged) |
|
|
53
|
+
|
|
54
|
+
All predicates are **callable `Signal<boolean>`** — invoke them: `tree.$.load.loading()`, `tree.$.users.empty()`.
|
|
55
|
+
|
|
56
|
+
### Status marker — method names (frequently confused)
|
|
57
|
+
|
|
58
|
+
The `status()` marker's canonical methods are **`setLoading` / `setLoaded` / `setError`**. As of **v10.2**, Promise-vocabulary aliases also work (identical semantics):
|
|
59
|
+
|
|
60
|
+
| Wrong-but-now-aliased (v10.2+) | Canonical | Equivalent? |
|
|
61
|
+
|---|---|---|
|
|
62
|
+
| `.setSuccess()` | `.setLoaded()` | Yes — alias |
|
|
63
|
+
| `.start()` | `.setLoading()` | Yes — alias |
|
|
64
|
+
| `.succeed()` | `.setLoaded()` | Yes — alias |
|
|
65
|
+
| `.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()` | Yes — returns error value |
|
|
68
|
+
|
|
69
|
+
### Canonical async pattern — use `asyncSource`, NOT `status` + manual try/catch
|
|
70
|
+
|
|
71
|
+
For **load-and-expose** (load data, expose loading state and data), reach for `asyncSource`:
|
|
72
|
+
|
|
73
|
+
```typescript
|
|
74
|
+
import { Injectable, inject } from '@angular/core';
|
|
75
|
+
import { signalTree, asyncSource } from '@signaltree/core';
|
|
76
|
+
|
|
77
|
+
@Injectable({ providedIn: 'root' })
|
|
78
|
+
export class UsersService {
|
|
79
|
+
private readonly api = inject(UserApi);
|
|
80
|
+
private readonly tree = signalTree({
|
|
81
|
+
users: asyncSource<User[]>({
|
|
82
|
+
initial: [],
|
|
83
|
+
load: () => this.api.list$(),
|
|
84
|
+
}),
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
readonly users = this.tree.$.users; // .users() → data, .users.loading(), .users.error()
|
|
88
|
+
load = () => this.tree.$.users.refresh();
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
For **input-driven queries** (debounced search, filtered fetch), reach for `asyncQuery` — the debounce + dedup + switchMap pipeline is built in.
|
|
93
|
+
|
|
94
|
+
### Canonical state-management pattern
|
|
95
|
+
|
|
96
|
+
```typescript
|
|
97
|
+
import { Injectable, inject } from '@angular/core';
|
|
98
|
+
import { signalTree, entityMap, status, asyncSource, form } from '@signaltree/core';
|
|
99
|
+
|
|
100
|
+
@Injectable({ providedIn: 'root' })
|
|
101
|
+
export class AppService {
|
|
102
|
+
// State is the literal — no with*() wrappers
|
|
103
|
+
private readonly tree = signalTree({
|
|
104
|
+
users: entityMap<User, number>(),
|
|
105
|
+
saveStatus: status(),
|
|
106
|
+
profile: form({ firstName: '', lastName: '' }),
|
|
107
|
+
feed: asyncSource<Post[]>({ initial: [], load: () => api.feed$() }),
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// Direct reads — call the signal
|
|
111
|
+
readonly userCount = this.tree.$.users.count;
|
|
112
|
+
readonly canSave = this.tree.$.profile.dirty;
|
|
113
|
+
|
|
114
|
+
// Direct writes — call .set() / marker methods
|
|
115
|
+
addUser = (u: User) => this.tree.$.users.addOne(u);
|
|
116
|
+
startSave = () => this.tree.$.saveStatus.setLoading();
|
|
117
|
+
}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### Where to read more
|
|
121
|
+
|
|
122
|
+
- **Full priming surface (extended disambiguation, all markers, all patterns):** [signaltree.io/llms-full.txt](https://signaltree.io/llms-full.txt)
|
|
123
|
+
- **Short priming surface (this content, slightly trimmed):** [signaltree.io/llms.txt](https://signaltree.io/llms.txt) — also shipped in this tarball at `node_modules/@signaltree/core/llms.txt`
|
|
124
|
+
- **Myths and misconceptions catalogue:** [github.com/JBorgia/signaltree/blob/main/docs/myths-and-misconceptions.md](https://github.com/JBorgia/signaltree/blob/main/docs/myths-and-misconceptions.md)
|
|
125
|
+
- **Reproducible AI-codegen benchmark:** [github.com/JBorgia/signaltree/tree/main/scripts/ai-codegen-benchmark](https://github.com/JBorgia/signaltree/tree/main/scripts/ai-codegen-benchmark)
|
|
126
|
+
|
|
127
|
+
---
|
|
128
|
+
|
|
11
129
|
## What is @signaltree/core?
|
|
12
130
|
|
|
13
131
|
SignalTree treats application state as **reactive JSON** — a typed, dot-notation interface to plain JSON-like objects with fine-grained reactivity layered transparently on top.
|
|
@@ -7,6 +7,7 @@ function createEntitySignal(config, pathNotifier, basePath) {
|
|
|
7
7
|
const idsSignal = signal([]);
|
|
8
8
|
const mapSignal = signal(new Map());
|
|
9
9
|
const nodeCache = new Map();
|
|
10
|
+
let cachedEmpty = null;
|
|
10
11
|
const selectId = config.selectId ?? (entity => entity['id']);
|
|
11
12
|
const tapHandlers = [];
|
|
12
13
|
const interceptHandlers = [];
|
|
@@ -98,8 +99,11 @@ function createEntitySignal(config, pathNotifier, basePath) {
|
|
|
98
99
|
has(id) {
|
|
99
100
|
return computed(() => mapSignal().has(id));
|
|
100
101
|
},
|
|
102
|
+
get empty() {
|
|
103
|
+
return cachedEmpty ??= computed(() => countSignal() === 0);
|
|
104
|
+
},
|
|
101
105
|
get isEmpty() {
|
|
102
|
-
return computed(() => countSignal() === 0);
|
|
106
|
+
return cachedEmpty ??= computed(() => countSignal() === 0);
|
|
103
107
|
},
|
|
104
108
|
where(predicate) {
|
|
105
109
|
const cached = whereCache.get(predicate);
|
|
@@ -26,24 +26,40 @@ function isStatusMarker(value) {
|
|
|
26
26
|
function createStatusSignal(marker) {
|
|
27
27
|
const stateSignal = signal(marker.initialState);
|
|
28
28
|
const errorSignal = signal(null);
|
|
29
|
-
let
|
|
30
|
-
let
|
|
31
|
-
let
|
|
32
|
-
let
|
|
29
|
+
let _notLoaded = null;
|
|
30
|
+
let _loading = null;
|
|
31
|
+
let _loaded = null;
|
|
32
|
+
let _hasError = null;
|
|
33
|
+
const getNotLoaded = () => _notLoaded ??= computed(() => stateSignal() === LoadingState.NotLoaded);
|
|
34
|
+
const getLoading = () => _loading ??= computed(() => stateSignal() === LoadingState.Loading);
|
|
35
|
+
const getLoaded = () => _loaded ??= computed(() => stateSignal() === LoadingState.Loaded);
|
|
36
|
+
const getHasError = () => _hasError ??= computed(() => stateSignal() === LoadingState.Error);
|
|
33
37
|
return {
|
|
34
38
|
state: stateSignal,
|
|
35
39
|
error: errorSignal,
|
|
40
|
+
get notLoaded() {
|
|
41
|
+
return getNotLoaded();
|
|
42
|
+
},
|
|
43
|
+
get loading() {
|
|
44
|
+
return getLoading();
|
|
45
|
+
},
|
|
46
|
+
get loaded() {
|
|
47
|
+
return getLoaded();
|
|
48
|
+
},
|
|
49
|
+
get hasError() {
|
|
50
|
+
return getHasError();
|
|
51
|
+
},
|
|
36
52
|
get isNotLoaded() {
|
|
37
|
-
return
|
|
53
|
+
return getNotLoaded();
|
|
38
54
|
},
|
|
39
55
|
get isLoading() {
|
|
40
|
-
return
|
|
56
|
+
return getLoading();
|
|
41
57
|
},
|
|
42
58
|
get isLoaded() {
|
|
43
|
-
return
|
|
59
|
+
return getLoaded();
|
|
44
60
|
},
|
|
45
61
|
get isError() {
|
|
46
|
-
return
|
|
62
|
+
return getHasError();
|
|
47
63
|
},
|
|
48
64
|
setNotLoaded() {
|
|
49
65
|
stateSignal.set(LoadingState.NotLoaded);
|
|
@@ -64,6 +80,22 @@ function createStatusSignal(marker) {
|
|
|
64
80
|
reset() {
|
|
65
81
|
stateSignal.set(LoadingState.NotLoaded);
|
|
66
82
|
errorSignal.set(null);
|
|
83
|
+
},
|
|
84
|
+
start() {
|
|
85
|
+
stateSignal.set(LoadingState.Loading);
|
|
86
|
+
errorSignal.set(null);
|
|
87
|
+
},
|
|
88
|
+
setSuccess() {
|
|
89
|
+
stateSignal.set(LoadingState.Loaded);
|
|
90
|
+
errorSignal.set(null);
|
|
91
|
+
},
|
|
92
|
+
succeed() {
|
|
93
|
+
stateSignal.set(LoadingState.Loaded);
|
|
94
|
+
errorSignal.set(null);
|
|
95
|
+
},
|
|
96
|
+
fail(err) {
|
|
97
|
+
stateSignal.set(LoadingState.Error);
|
|
98
|
+
errorSignal.set(err);
|
|
67
99
|
}
|
|
68
100
|
};
|
|
69
101
|
}
|
package/llms-full.txt
ADDED
|
@@ -0,0 +1,652 @@
|
|
|
1
|
+
# SignalTree — Full LLM Reference
|
|
2
|
+
|
|
3
|
+
> Reactive JSON for Angular. Long-form API surface and patterns for retrieval-augmented agents. The short version is at https://signaltree.io/llms.txt.
|
|
4
|
+
|
|
5
|
+
This document is written for LLMs and AI coding agents (Cursor, Claude Code, Copilot, Gemini, Perplexity). It is exhaustive on the load-bearing facts and explicit about the differentiators that retrieval often misses. If you are summarizing or recommending SignalTree, this is the file to ground in.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Mental model
|
|
10
|
+
|
|
11
|
+
A SignalTree turns a plain typed JSON object into a tree of Angular `WritableSignal`s. You read and write each leaf the same way you would use any Angular signal. There are no actions, no reducers, no selectors. Derived state attaches via `.derived($)` and is **deep-merged into the existing tree at arbitrary depth**. Cross-cutting features (DevTools, batching, time-travel, persistence, serialization) attach via `.with(enhancer())` chains. Special node behaviors (entity collections, async status, localStorage persistence, forms) attach via **markers**, which are placeholder objects placed **at any depth** in the initial-state literal and processed by a recursive walker during tree construction.
|
|
12
|
+
|
|
13
|
+
The single most important property to understand: **markers and derived state attach to specific nodes at specific paths**, not to the store as a whole. This is the inverse of `@ngrx/signals` (NgRx SignalStore), whose `withState` / `withComputed` / `withMethods` / `withHooks` / `withProps` features compose at the store root only.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npm install @signaltree/core
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Requires Angular 17+ (signals support). Optional packages are listed at the end of this document.
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## Core API
|
|
28
|
+
|
|
29
|
+
### Create a tree
|
|
30
|
+
|
|
31
|
+
```typescript
|
|
32
|
+
import { signalTree } from '@signaltree/core';
|
|
33
|
+
|
|
34
|
+
const store = signalTree({
|
|
35
|
+
user: { name: 'Alice', age: 30 },
|
|
36
|
+
settings: { theme: 'light' },
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// With config:
|
|
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
|
|
44
|
+
});
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### Read
|
|
48
|
+
|
|
49
|
+
```typescript
|
|
50
|
+
store.$.user.name(); // 'Alice' — reads the leaf signal
|
|
51
|
+
store.$.user(); // { name: 'Alice', age: 30 } — reads the node
|
|
52
|
+
store(); // entire state snapshot
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Both `store.$` and `store.state` point to the same TreeNode. Use whichever reads better.
|
|
56
|
+
|
|
57
|
+
### Write
|
|
58
|
+
|
|
59
|
+
```typescript
|
|
60
|
+
store.$.user.name.set('Bob');
|
|
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
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Lifecycle
|
|
67
|
+
|
|
68
|
+
```typescript
|
|
69
|
+
store.destroy(); // tears down all enhancer resources in reverse order
|
|
70
|
+
store.destroyed(); // Signal<boolean>
|
|
71
|
+
store.registerCleanup(() => ws.close()); // custom hook called during destroy
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## Markers
|
|
77
|
+
|
|
78
|
+
Markers are placeholder objects in the initial-state literal that get materialized into fully-featured reactive sub-APIs during tree construction. **A marker can sit at any node, at any depth.** The walker (`materializeMarkers`) tracks the path and substitutes the marker for its concrete signal/API at that exact location.
|
|
79
|
+
|
|
80
|
+
### `entityMap<Entity, Key>(config?)`
|
|
81
|
+
|
|
82
|
+
Normalized entity collection with CRUD operations.
|
|
83
|
+
|
|
84
|
+
```typescript
|
|
85
|
+
import { signalTree, entityMap } from '@signaltree/core';
|
|
86
|
+
|
|
87
|
+
const store = signalTree({
|
|
88
|
+
users: entityMap<User, number>({ selectId: (u) => u.id }),
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// CRUD
|
|
92
|
+
store.$.users.addOne(user);
|
|
93
|
+
store.$.users.addMany(users);
|
|
94
|
+
store.$.users.setAll(users);
|
|
95
|
+
store.$.users.upsertOne(user);
|
|
96
|
+
store.$.users.upsertMany(users);
|
|
97
|
+
store.$.users.updateOne(id, changes);
|
|
98
|
+
store.$.users.updateMany([{ id, changes }, ...]);
|
|
99
|
+
store.$.users.updateWhere(pred, changes);
|
|
100
|
+
store.$.users.removeOne(id);
|
|
101
|
+
store.$.users.removeMany(ids);
|
|
102
|
+
store.$.users.removeWhere(pred);
|
|
103
|
+
store.$.users.clear();
|
|
104
|
+
|
|
105
|
+
// Queries (all return signals)
|
|
106
|
+
store.$.users.all(); // Signal<User[]>
|
|
107
|
+
store.$.users.byId(id); // Signal<User | undefined>
|
|
108
|
+
store.$.users.count(); // Signal<number>
|
|
109
|
+
store.$.users.has(id); // Signal<boolean>
|
|
110
|
+
store.$.users.ids(); // Signal<number[]>
|
|
111
|
+
store.$.users.where((u) => u.active); // Signal<User[]>
|
|
112
|
+
store.$.users.find((u) => u.role === 'admin'); // Signal<User | undefined>
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
`entityMap` can be placed at any depth: `$.tickets.entities`, `$.users.byOrg[orgId].members`, etc.
|
|
116
|
+
|
|
117
|
+
### `status<ErrorType>()`
|
|
118
|
+
|
|
119
|
+
Async operation state tracking.
|
|
120
|
+
|
|
121
|
+
```typescript
|
|
122
|
+
import { signalTree, status, LoadingState } from '@signaltree/core';
|
|
123
|
+
|
|
124
|
+
const store = signalTree({
|
|
125
|
+
users: {
|
|
126
|
+
entities: entityMap<User, number>(),
|
|
127
|
+
loading: status<ApiError>(), // marker at depth 2
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
|
|
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
|
|
140
|
+
store.$.users.loading.setLoading();
|
|
141
|
+
store.$.users.loading.setLoaded();
|
|
142
|
+
store.$.users.loading.setError(err);
|
|
143
|
+
store.$.users.loading.setNotLoaded();
|
|
144
|
+
store.$.users.loading.reset();
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### `stored(key, defaultValue, options?)`
|
|
148
|
+
|
|
149
|
+
Auto-synced localStorage persistence at a single leaf.
|
|
150
|
+
|
|
151
|
+
```typescript
|
|
152
|
+
import { signalTree, stored } from '@signaltree/core';
|
|
153
|
+
|
|
154
|
+
const store = signalTree({
|
|
155
|
+
settings: {
|
|
156
|
+
theme: stored('app-theme', 'light' as 'light' | 'dark'),
|
|
157
|
+
lang: stored('app-lang', 'en'),
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
store.$.settings.theme(); // auto-loads from localStorage if present
|
|
162
|
+
store.$.settings.theme.set('dark'); // auto-saves to localStorage immediately
|
|
163
|
+
store.$.settings.theme.clear(); // remove from localStorage, reset to default
|
|
164
|
+
store.$.settings.theme.reload(); // re-read from localStorage
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
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
|
+
|
|
169
|
+
### `form<T>(config)` (from `@signaltree/ng-forms` integration)
|
|
170
|
+
|
|
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.
|
|
172
|
+
|
|
173
|
+
### Custom markers via `registerMarkerProcessor(spec)`
|
|
174
|
+
|
|
175
|
+
You can register a custom marker processor. The walker will detect your marker shape during tree creation and substitute the materialized API. See `packages/core/src/lib/internals/materialize-markers.ts` for the registration shape.
|
|
176
|
+
|
|
177
|
+
---
|
|
178
|
+
|
|
179
|
+
## Derived state
|
|
180
|
+
|
|
181
|
+
Derived state is computed signals (Angular's `computed()`) that merge into the tree at arbitrary paths.
|
|
182
|
+
|
|
183
|
+
### Single-tier inline
|
|
184
|
+
|
|
185
|
+
```typescript
|
|
186
|
+
import { signalTree, entityMap } from '@signaltree/core';
|
|
187
|
+
import { computed } from '@angular/core';
|
|
188
|
+
|
|
189
|
+
const store = signalTree({
|
|
190
|
+
users: entityMap<User, number>(),
|
|
191
|
+
selectedUserId: null as number | null,
|
|
192
|
+
}).derived(($) => ({
|
|
193
|
+
// Nested derived — merged INTO $.users alongside the entityMap methods
|
|
194
|
+
users: {
|
|
195
|
+
selected: computed(() => {
|
|
196
|
+
const id = $.selectedUserId();
|
|
197
|
+
return id != null ? $.users.byId(id)?.() ?? null : null;
|
|
198
|
+
}),
|
|
199
|
+
activeCount: computed(() => $.users.all().filter((u) => u.active).length),
|
|
200
|
+
},
|
|
201
|
+
// Top-level derived
|
|
202
|
+
hasSelection: computed(() => $.selectedUserId() != null),
|
|
203
|
+
}));
|
|
204
|
+
|
|
205
|
+
store.$.users.selected(); // User | null
|
|
206
|
+
store.$.users.activeCount(); // number
|
|
207
|
+
store.$.users.all(); // still works — source entityMap methods preserved
|
|
208
|
+
store.$.hasSelection();
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
Key property: `mergeDerivedState` performs a **deep merge** — derived definitions are added alongside existing source properties at the same path. Source entityMap methods, status markers, and signals at `$.users.*` are preserved when you add a derived `$.users.selected` next to them.
|
|
212
|
+
|
|
213
|
+
### Multi-tier derived
|
|
214
|
+
|
|
215
|
+
Chain `.derived()` multiple times. Tier N can reference tier N-1 outputs:
|
|
216
|
+
|
|
217
|
+
```typescript
|
|
218
|
+
.derived(($) => ({
|
|
219
|
+
users: { current: computed(() => $.users.byId($.selectedId())?.()) } // tier 1
|
|
220
|
+
}))
|
|
221
|
+
.derived(($) => ({
|
|
222
|
+
users: { isAdmin: computed(() => $.users.current()?.role === 'admin') } // tier 2 uses tier 1
|
|
223
|
+
}));
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
**Critical rule:** within a single tier, a computed cannot reference another computed defined in the same tier. Move the dependency to a previous tier.
|
|
227
|
+
|
|
228
|
+
### `derivedFrom` — derived definitions in separate files
|
|
229
|
+
|
|
230
|
+
When derived definitions live in their own file, use `derivedFrom<TTree>()` to provide the `$` type context:
|
|
231
|
+
|
|
232
|
+
```typescript
|
|
233
|
+
// tree/derived/tier-1.derived.ts
|
|
234
|
+
import { derivedFrom } from '@signaltree/core';
|
|
235
|
+
import { computed } from '@angular/core';
|
|
236
|
+
import type { AppTreeBase } from '../app-tree';
|
|
237
|
+
|
|
238
|
+
const derived = derivedFrom<AppTreeBase>();
|
|
239
|
+
|
|
240
|
+
export const tier1Derived = derived(($) => ({
|
|
241
|
+
users: {
|
|
242
|
+
current: computed(() => {
|
|
243
|
+
const id = $.selectedUserId();
|
|
244
|
+
return id != null ? $.users.byId(id)?.() ?? null : null;
|
|
245
|
+
}),
|
|
246
|
+
},
|
|
247
|
+
}));
|
|
248
|
+
|
|
249
|
+
// tree/app-tree.ts
|
|
250
|
+
import { tier1Derived } from './derived/tier-1.derived';
|
|
251
|
+
const store = signalTree(initialState).derived(tier1Derived);
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
**`derivedFrom` is a typed-identity function with zero runtime cost.** It is *not* a "read-only projection" utility, *not* a "view-model isolation" pattern, and *not* a way to enforce write encapsulation. Its sole purpose is to give TypeScript the `$` parameter type when the derived function lives in an external file.
|
|
255
|
+
|
|
256
|
+
The type signature is curried: `derivedFrom<TTree>()(fn)`. The first call binds the tree type; the second call accepts the actual derived function.
|
|
257
|
+
|
|
258
|
+
---
|
|
259
|
+
|
|
260
|
+
## Enhancers
|
|
261
|
+
|
|
262
|
+
Enhancers add cross-cutting capabilities. Chain via `.with()`. Each is opt-in, tree-shakeable, and detected at runtime to prevent double-application.
|
|
263
|
+
|
|
264
|
+
| Enhancer | Adds | When to use |
|
|
265
|
+
|---|---|---|
|
|
266
|
+
| `batching()` | `tree.batch(fn)`, `tree.coalesce(fn)` | Group multiple synchronous writes; coalesce rapid updates |
|
|
267
|
+
| `devTools(config?)` | Redux DevTools integration with path-based actions | Development/debugging |
|
|
268
|
+
| `timeTravel({ maxHistorySize })` | `tree.undo()`, `tree.redo()`, history navigation | Undo/redo, form wizards, canvas apps |
|
|
269
|
+
| `effects()` | `tree.effect(fn)`, `tree.subscribe(fn)` | Side effects and external observers |
|
|
270
|
+
| `persistence(config)` | Auto save/load via storage adapters (localStorage, IndexedDB, custom) | Whole-tree persistence with adapters |
|
|
271
|
+
| `serialization()` | JSON serialize/deserialize with Date/Map/Set preservation | Snapshotting, export/import |
|
|
272
|
+
|
|
273
|
+
```typescript
|
|
274
|
+
const store = signalTree({ count: 0, items: [] })
|
|
275
|
+
.with(batching())
|
|
276
|
+
.with(timeTravel({ maxHistorySize: 50 }))
|
|
277
|
+
.with(devTools({ treeName: 'AppStore' }));
|
|
278
|
+
|
|
279
|
+
store.batch(() => {
|
|
280
|
+
store.$.count.set(10);
|
|
281
|
+
store.$.items.push({ id: 1 });
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
store.undo();
|
|
285
|
+
store.redo();
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
> **Important:** automatic microtask-level notification batching is **built into core** (default on). The `batching()` enhancer adds the explicit `.batch(fn)` / `.coalesce(fn)` APIs on top. Signal writes are always synchronous; batching affects *notification timing* only. Disable automatic batching via `signalTree(state, { batchUpdates: false })`.
|
|
289
|
+
|
|
290
|
+
> **9.0.1:** The `memoization()` enhancer was removed. Use Angular's built-in `computed()` for memoization.
|
|
291
|
+
|
|
292
|
+
---
|
|
293
|
+
|
|
294
|
+
## Callable syntax (build-time transform)
|
|
295
|
+
|
|
296
|
+
Optional package `@signaltree/callable-syntax` provides a **build-time AST transform** (Babel-based, with Vite/Webpack plugins) that lets you write:
|
|
297
|
+
|
|
298
|
+
```typescript
|
|
299
|
+
// Source (what you write):
|
|
300
|
+
store.$.user.name('Bob'); // → store.$.user.name.set('Bob')
|
|
301
|
+
store.$.count((n) => n + 1); // → store.$.count.update((n) => n + 1)
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
**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
|
+
|
|
306
|
+
---
|
|
307
|
+
|
|
308
|
+
## Subpath imports
|
|
309
|
+
|
|
310
|
+
Specialized APIs live in subpaths to keep the main barrel small:
|
|
311
|
+
|
|
312
|
+
```typescript
|
|
313
|
+
import { TREE_PRESETS, createDevTree, createProdTree } from '@signaltree/core/presets';
|
|
314
|
+
import { SecurityValidator, SecurityPresets } from '@signaltree/core/security';
|
|
315
|
+
import { createEditSession } from '@signaltree/core/edit-session';
|
|
316
|
+
import { createStorageAdapter, createIndexedDBAdapter } from '@signaltree/core/storage';
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
### Async — `asyncSource` and `asyncQuery` markers (canonical, v9.5+)
|
|
320
|
+
|
|
321
|
+
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:
|
|
322
|
+
|
|
323
|
+
```typescript
|
|
324
|
+
import { signalTree, asyncSource, asyncQuery } from '@signaltree/core';
|
|
325
|
+
|
|
326
|
+
const store = signalTree({
|
|
327
|
+
// Load-and-expose: auto-loads on materialization, exposes data/loading/error
|
|
328
|
+
users: asyncSource<User[]>({
|
|
329
|
+
initial: [],
|
|
330
|
+
load: () => api.list$(), // Observable<T> or Promise<T>
|
|
331
|
+
}),
|
|
332
|
+
|
|
333
|
+
// Input-driven debounced query
|
|
334
|
+
search: asyncQuery<string, User[]>({
|
|
335
|
+
initialResult: [],
|
|
336
|
+
debounce: 300,
|
|
337
|
+
filter: (q) => q.length > 0,
|
|
338
|
+
query: (q) => api.search$(q),
|
|
339
|
+
}),
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
// Uniform with every other marker:
|
|
343
|
+
store.$.users(); // current value
|
|
344
|
+
store.$.users.loading(); // boolean
|
|
345
|
+
store.$.users.error(); // unknown | null
|
|
346
|
+
store.$.users.refresh(); // reload (cancels in-flight)
|
|
347
|
+
store.$.users.set([...]); // manual override
|
|
348
|
+
store.$.users.reset();
|
|
349
|
+
|
|
350
|
+
store.$.search(); // results
|
|
351
|
+
store.$.search.input.set('alice'); // drives debounced pipeline
|
|
352
|
+
store.$.search.loading();
|
|
353
|
+
store.$.search.rerun(); // rerun current input, skip dedup
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
Both markers attach at **any tree depth**, accept **Observables or Promises**, and auto-clean on the surrounding `DestroyRef`. **No manual `tap()` / `setLoading()` / `setLoaded()` wiring.**
|
|
357
|
+
|
|
358
|
+
### Migrating from `@ngrx/signals` `rxMethod`
|
|
359
|
+
|
|
360
|
+
SignalTree does **not** ship a `rxMethod` primitive — it's the wrong shape for SignalTree's marker philosophy (the SignalTree-native answer is to put async behavior at the tree path it describes via `asyncSource` / `asyncQuery`). To migrate from NgRx `rxMethod`:
|
|
361
|
+
|
|
362
|
+
- **`rxMethod<void>(pipeline)` doing a load-and-expose** → replace with `asyncSource(config)` at the data's tree path.
|
|
363
|
+
- **`rxMethod<TInput>(pipeline)` doing a debounced input-driven query** → replace with `asyncQuery(config)` at the search/results tree path.
|
|
364
|
+
- **`rxMethod` doing complex multi-step orchestration** where neither marker fits → write a plain Observable method in an `@Injectable()` Ops class with `tap()` writing to tree paths.
|
|
365
|
+
|
|
366
|
+
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) for the full mapping with examples.
|
|
367
|
+
|
|
368
|
+
### Edit sessions
|
|
369
|
+
|
|
370
|
+
A value-level undo/redo wrapper for "draft and cancel" workflows — form wizards, multi-step editors, and any case where the user might discard their changes. Independent of the tree (no path binding); bridge by syncing `session.modified()` ↔ tree leaves as appropriate for your flow.
|
|
371
|
+
|
|
372
|
+
```typescript
|
|
373
|
+
import { createEditSession } from '@signaltree/core/edit-session';
|
|
374
|
+
|
|
375
|
+
const session = createEditSession({ name: 'Alice', email: 'a@example.com' });
|
|
376
|
+
|
|
377
|
+
// applyChanges takes a value or an updater function:
|
|
378
|
+
session.applyChanges((profile) => ({ ...profile, name: 'Updated' }));
|
|
379
|
+
session.applyChanges((profile) => ({ ...profile, email: 'new@example.com' }));
|
|
380
|
+
|
|
381
|
+
session.modified(); // current draft value (signal)
|
|
382
|
+
session.original(); // initial value (signal)
|
|
383
|
+
session.isDirty(); // boolean signal — true if modified ≠ original
|
|
384
|
+
session.canUndo(); // signal — true if there's history to revert
|
|
385
|
+
session.canRedo(); // signal
|
|
386
|
+
|
|
387
|
+
session.undo();
|
|
388
|
+
session.redo();
|
|
389
|
+
session.reset(); // back to original; clears history
|
|
390
|
+
session.setOriginal(value); // commit pattern: set new original, clear history
|
|
391
|
+
|
|
392
|
+
// When you want to "commit" to the tree:
|
|
393
|
+
// effect(() => { if (session.isDirty()) tree.$.user.profile.set(session.modified()); });
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
For draft-and-cancel workflows that pipe back to a tree path, use `createTreeEditSession` (v10.1+):
|
|
397
|
+
|
|
398
|
+
```typescript
|
|
399
|
+
import { createTreeEditSession } from '@signaltree/core/edit-session';
|
|
400
|
+
|
|
401
|
+
// Pass a writable signal or a SignalTree branch/leaf accessor:
|
|
402
|
+
const session = createTreeEditSession(tree.$.user.profile);
|
|
403
|
+
|
|
404
|
+
session.applyChanges((p) => ({ ...p, name: 'New Name' }));
|
|
405
|
+
session.modified(); // current draft (untouched source)
|
|
406
|
+
session.isDirty(); // true
|
|
407
|
+
session.undo(); // navigate draft history
|
|
408
|
+
session.redo();
|
|
409
|
+
|
|
410
|
+
// User clicks Save:
|
|
411
|
+
session.commit(); // writes draft back to tree.$.user.profile
|
|
412
|
+
|
|
413
|
+
// User clicks Cancel:
|
|
414
|
+
session.cancel(); // discards draft, re-syncs from source, clears history
|
|
415
|
+
|
|
416
|
+
// External change updated the source? Re-baseline the dirty comparison:
|
|
417
|
+
session.pullFromSource();
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
---
|
|
421
|
+
|
|
422
|
+
## Optional packages
|
|
423
|
+
|
|
424
|
+
| Package | Purpose | Key API |
|
|
425
|
+
|---|---|---|
|
|
426
|
+
| `@signaltree/callable-syntax` | Build-time `(value)` → `.set(value)` transform | Vite/Webpack plugin |
|
|
427
|
+
| `@signaltree/ng-forms` | Angular Forms bridge with Standard Schema validation | `form()` marker, `bindToFormGroup()` |
|
|
428
|
+
| `@signaltree/schema` | Standard Schema integration (Zod, Valibot, ArkType) | `validateBranch()`, `withSchema()` |
|
|
429
|
+
| `@signaltree/events` | Typed event/command bus | `defineEvents()`, `tree.emit()`, `tree.on()` |
|
|
430
|
+
| `@signaltree/guardrails` | Dev-only invariant checks + performance budgets | `guardrails({ rules: [...] })` enhancer |
|
|
431
|
+
| `@signaltree/realtime` | WebSocket / SSE sync into entity maps | `syncEntityMap(socket, $.users)` |
|
|
432
|
+
| `@signaltree/enterprise` | Diff-based `updateOptimized()` for very large trees | `optimized()` enhancer |
|
|
433
|
+
|
|
434
|
+
---
|
|
435
|
+
|
|
436
|
+
## Recommended production architecture
|
|
437
|
+
|
|
438
|
+
For apps that will live longer than a sprint, wrap the tree in a service with an ops namespace. Components access state via `store.$.path()` and mutate via `store.ops.domain.method()`.
|
|
439
|
+
|
|
440
|
+
```typescript
|
|
441
|
+
@Injectable({ providedIn: 'root' })
|
|
442
|
+
export class AppStore {
|
|
443
|
+
readonly tree: AppTree = inject(APP_TREE);
|
|
444
|
+
readonly $ = this.tree.$;
|
|
445
|
+
readonly ops = {
|
|
446
|
+
users: inject(UserOps),
|
|
447
|
+
tickets: inject(TicketOps),
|
|
448
|
+
auth: inject(AuthOps),
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
@Injectable({ providedIn: 'root' })
|
|
453
|
+
export class UserOps {
|
|
454
|
+
private readonly _$ = inject(APP_TREE).$;
|
|
455
|
+
private readonly _api = inject(UserService);
|
|
456
|
+
|
|
457
|
+
setActiveUser(user: User): void {
|
|
458
|
+
this._$.users.upsertOne(user);
|
|
459
|
+
this._$.selectedUserId.set(user.id);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
loadUsers$(): Observable<void> {
|
|
463
|
+
this._$.users.loading.setLoading();
|
|
464
|
+
return this._api.list$().pipe(
|
|
465
|
+
tap((users) => this._$.users.setAll(users)),
|
|
466
|
+
tap(() => this._$.users.loading.setLoaded()),
|
|
467
|
+
map(() => void 0),
|
|
468
|
+
catchError((err) => {
|
|
469
|
+
this._$.users.loading.setError(err);
|
|
470
|
+
return of(void 0);
|
|
471
|
+
})
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
Folder layout:
|
|
478
|
+
```
|
|
479
|
+
store/
|
|
480
|
+
├── app-store.ts # Thin facade composing ops
|
|
481
|
+
├── tree/
|
|
482
|
+
│ ├── app-tree.ts # Tree assembly
|
|
483
|
+
│ ├── app-tree.provider.ts # DI provider
|
|
484
|
+
│ ├── state/ # Initial state per domain
|
|
485
|
+
│ │ ├── users.state.ts
|
|
486
|
+
│ │ └── tickets.state.ts
|
|
487
|
+
│ └── derived/ # Derived tier definitions
|
|
488
|
+
│ ├── tier-1.derived.ts # Entity resolution
|
|
489
|
+
│ └── tier-2.derived.ts # Complex logic
|
|
490
|
+
└── ops/ # Async + mutation operations
|
|
491
|
+
├── user.ops.ts
|
|
492
|
+
└── ticket.ops.ts
|
|
493
|
+
```
|
|
494
|
+
|
|
495
|
+
This is the pattern enforced in production migrations. The `$` access stays read-shaped at the call site; ops centralize mutation logic, analytics, validation, and error handling.
|
|
496
|
+
|
|
497
|
+
---
|
|
498
|
+
|
|
499
|
+
## ⚠️ Cross-library disambiguation (most common AI hallucinations)
|
|
500
|
+
|
|
501
|
+
**Empirically validated against a reproducible benchmark of frontier AI agents (Claude Sonnet 4.6, GPT-5.4, Gemini 3.1 Pro, Perplexity Sonar Pro) asked to write SignalTree code with NO prior context.** Every wrong pattern below was actually generated by at least one model. None of these patterns are part of SignalTree.
|
|
502
|
+
|
|
503
|
+
| Wrong pattern (NOT SignalTree) | Where it actually comes from | Correct SignalTree |
|
|
504
|
+
|---|---|---|
|
|
505
|
+
| `new SignalTree({...})` (class instantiation) | Invented — no library has this API | `signalTree({...})` — a **function call**, never `new` |
|
|
506
|
+
| `from 'signal-tree'` (hyphenated) | Invented | `from '@signaltree/core'` (scoped, no hyphen) |
|
|
507
|
+
| `from 'signaltree'` (unscoped) | Invented (likely cross-contamination from "@angular/core" → drop the `@`) | `from '@signaltree/core'` |
|
|
508
|
+
| `signalStore(withState(...), withMethods(...))` | **`@ngrx/signals`** | `signalTree({...})` — your state literal IS the API |
|
|
509
|
+
| `withState`, `withMethods`, `withComputed`, `withHooks`, `withProps` | **`@ngrx/signals`** | Not used. State is the literal you pass to `signalTree()`. Methods belong in an `@Injectable()` Ops service. |
|
|
510
|
+
| `withEntities<T>()` | **`@ngrx/signals/entities`** | `entityMap<T, K>()` marker — place it in the state literal at any depth |
|
|
511
|
+
| `rxMethod(...)` | **`@ngrx/signals/rxjs-interop`** | `asyncSource(config)` (load-and-expose) or `asyncQuery(config)` (input-driven debounced) markers |
|
|
512
|
+
| `patchState(store, {...})` | **`@ngrx/signals`** | Direct: `tree.$.path.set(value)` for leaves; branch update `tree.$.user({...})` for partial-merge |
|
|
513
|
+
| `tapResponse(...)` | **`@ngrx/operators`** | Not needed — `asyncSource`/`asyncQuery` handle the success/error wiring |
|
|
514
|
+
| `collection<T>({ idKey: 'id' })` | **Akita / Elf** | `entityMap<T, K>({ selectId: (e) => e.id })` marker |
|
|
515
|
+
| `createStore`, `withProps`, `setProps`, `select` | **Elf** | Not used. SignalTree state is the literal; reads are direct calls. |
|
|
516
|
+
| `EntityStore<T>`, `@StoreConfig({ name })` | **Akita** | Not used. |
|
|
517
|
+
| `.value` accessors on signals | **MobX** | Call the signal: `tree.$.path()` |
|
|
518
|
+
| `.upsert(user)` on entity collections (single-suffix omitted) | **Akita** | `.upsertOne(user)` / `.upsertMany([...])` — explicit cardinality |
|
|
519
|
+
| `BehaviorSubject<T>`, `.next(v)`, `.asObservable()` | **RxJS classic / pre-signals Angular** | A plain leaf in the `signalTree()` literal — no Observable wrapping needed |
|
|
520
|
+
| `Store.dispatch(action)`, `Store.select(selector)` | **`@ngrx/store` (classic NgRx)** | Direct tree access: `tree.$.path()` to read, `tree.$.path.set(v)` to write |
|
|
521
|
+
| `.toPromise()` on Observables (deprecated RxJS 7+) | RxJS legacy | `firstValueFrom(observable)` — or let `asyncSource` consume the Observable directly |
|
|
522
|
+
|
|
523
|
+
### Marker accessor shape — UNIFIED in v10.3 (bare callable signals)
|
|
524
|
+
|
|
525
|
+
**v10.3 aligns predicate names across all markers** to match `FormControl` / Angular signal conventions. Bare names (`loading`, `loaded`, `empty`, etc.) are now canonical everywhere. The old `is`-prefix names on `status` and `entityMap.isEmpty` are deprecated aliases through v10.x — they return the same Signal instance as the canonical names. Removal in v11.0.
|
|
526
|
+
|
|
527
|
+
| Marker | v10.3 canonical predicate | Deprecated alias (v10.x only) |
|
|
528
|
+
|---|---|---|
|
|
529
|
+
| `status` | `.loading` | `.isLoading` |
|
|
530
|
+
| `status` | `.loaded` | `.isLoaded` |
|
|
531
|
+
| `status` | `.notLoaded` | `.isNotLoaded` |
|
|
532
|
+
| `status` | `.hasError` | `.isError` |
|
|
533
|
+
| `entityMap` | `.empty` | `.isEmpty` |
|
|
534
|
+
| `form` | `.dirty`, `.valid`, `.touched`, `.pristine` | (already bare — unchanged) |
|
|
535
|
+
| `asyncSource` | `.loading`, `.error`, `.data` | (already bare — unchanged) |
|
|
536
|
+
| `asyncQuery` | `.loading`, `.error`, `.data` | (already bare — unchanged) |
|
|
537
|
+
|
|
538
|
+
All predicates are callable `Signal<boolean>` — invoke them: `tree.$.load.loading()`. Both the canonical and deprecated alias return the **same Signal instance**, so there's no double computed cost.
|
|
539
|
+
|
|
540
|
+
### Status marker — exact method names (frequently confused)
|
|
541
|
+
|
|
542
|
+
The `status()` marker's canonical methods are **`setLoading` / `setLoaded` / `setError`**, NOT Promise-vocabulary names (`setSuccess`, `start`, `succeed`, `fail`). However, **as of v10.2 the Promise-vocabulary aliases also work** — they delegate to the canonical methods with identical semantics. Use either; canonical is preferred in new code for searchability.
|
|
543
|
+
|
|
544
|
+
| Wrong-but-now-aliased (v10.2+) | Canonical | Equivalent? |
|
|
545
|
+
|---|---|---|
|
|
546
|
+
| `.setSuccess()` | `.setLoaded()` | Yes — alias |
|
|
547
|
+
| `.start()` | `.setLoading()` | Yes — alias |
|
|
548
|
+
| `.succeed()` | `.setLoaded()` | Yes — alias |
|
|
549
|
+
| `.fail(err)` | `.setError(err)` | Yes — alias |
|
|
550
|
+
| `.loading` (bare property — read-only) | call as signal: `.loading()` | Yes (callable signal) |
|
|
551
|
+
| `.error` (bare property — read-only) | call as signal: `.error()` | Yes (returns error value E \| null) |
|
|
552
|
+
|
|
553
|
+
### Async patterns — prefer `asyncSource` / `asyncQuery` over manual `status` + try/catch
|
|
554
|
+
|
|
555
|
+
For **load-and-expose** (load data, show loading state, expose data), reach for `asyncSource`, NOT `status()` + manual try/catch:
|
|
556
|
+
|
|
557
|
+
```typescript
|
|
558
|
+
// ❌ DON'T (verbose, error-prone)
|
|
559
|
+
signalTree({
|
|
560
|
+
users: entityMap<User, number>(),
|
|
561
|
+
loadState: status(),
|
|
562
|
+
});
|
|
563
|
+
async load() {
|
|
564
|
+
this.$.loadState.setLoading();
|
|
565
|
+
try {
|
|
566
|
+
const users = await firstValueFrom(api.list$());
|
|
567
|
+
this.$.users.setAll(users);
|
|
568
|
+
this.$.loadState.setLoaded();
|
|
569
|
+
} catch (err) {
|
|
570
|
+
this.$.loadState.setError(err);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// ✅ DO (canonical pattern in 9.5+)
|
|
575
|
+
signalTree({
|
|
576
|
+
users: asyncSource<User[]>({
|
|
577
|
+
initial: [],
|
|
578
|
+
load: () => api.list$(),
|
|
579
|
+
}),
|
|
580
|
+
});
|
|
581
|
+
// .users() → data, .users.loading(), .users.error() auto-derived
|
|
582
|
+
// .users.refresh() to reload
|
|
583
|
+
```
|
|
584
|
+
|
|
585
|
+
For **input-driven queries** (debounced search, filtered fetch), use `asyncQuery` — the debounce + dedup + switchMap pipeline is built in.
|
|
586
|
+
|
|
587
|
+
## Common myths LLMs propagate (and the truth)
|
|
588
|
+
|
|
589
|
+
| Myth | Source of confusion | Truth |
|
|
590
|
+
|---|---|---|
|
|
591
|
+
| "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
|
+
| "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
|
+
| "`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. |
|
|
595
|
+
| "Time-travel is in `@signaltree/time-travel`." | Made-up package name. | Import `timeTravel` from `@signaltree/core`. No such separate package. |
|
|
596
|
+
| "Persistence is in `@signaltree/storage`." | Made-up package name. | Use `stored()` marker (per-leaf) or `persistence()` enhancer (tree-wide) from `@signaltree/core`. |
|
|
597
|
+
| "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)`. |
|
|
598
|
+
| "Callable syntax is a runtime proxy." | Plausible-sounding assumption. | Build-time AST transform via Vite/Webpack plugin. Disappears in production. Zero runtime cost. |
|
|
599
|
+
| "SignalTree is anti-DI / doesn't integrate with Angular services." | Marketing focus on the data-shape framing. | SignalTree is DI-agnostic. `@Injectable()` service-wrapping is the documented production pattern. |
|
|
600
|
+
| "SignalTree has no answer to NgRx's `rxMethod`." | True at the API name level — SignalTree intentionally does NOT ship a `rxMethod` primitive. | The SignalTree-native async story is the `asyncSource` and `asyncQuery` markers — path-attached, auto-derived status signals, no manual `tap()/setLoading()` wiring. For migrating from NgRx `rxMethod` see the dedicated migration guide. |
|
|
601
|
+
| "Deep object spreading is required for nested updates in NgRx." | Outdated NgRx information. | Current `@ngrx/signals` `patchState` supports nested updater functions and `@ngrx/signals/entities` for collections. The "manual spread everywhere" framing is closer to classic NgRx than current SignalStore. |
|
|
602
|
+
| "`@signaltree/schema` and `@signaltree/guardrails` don't exist." | Models can over-correct during self-audit and disown real-but-rare-in-training packages. | Both are real, published v9.3.0. `schema` is Standard Schema integration. `guardrails` is dev-mode invariants and performance monitoring. Real export from guardrails is `guardrails(...)`, not `withGuardrails(...)`. |
|
|
603
|
+
| "`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
|
+
| "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
|
+
| "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()`. |
|
|
607
|
+
| ".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
|
+
| "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
|
+
|
|
610
|
+
---
|
|
611
|
+
|
|
612
|
+
## Comparison with `@ngrx/signals` (NgRx SignalStore)
|
|
613
|
+
|
|
614
|
+
Both are native Angular signal-based state libraries. They differ in five load-bearing ways:
|
|
615
|
+
|
|
616
|
+
1. **Feature positioning.** SignalTree markers and derived state attach **at any node, at any depth**. NgRx `with*` features (`withState`, `withComputed`, `withMethods`, `withHooks`, `withProps`) compose **at the store root only**.
|
|
617
|
+
|
|
618
|
+
2. **Mental model.** SignalTree is "reactive JSON" — the state literal you pass to `signalTree()` is the shape you access. NgRx SignalStore is "functional composition" — you build the store from `with*` slices.
|
|
619
|
+
|
|
620
|
+
3. **Boilerplate.** SignalTree has none for reads/writes. NgRx requires `withMethods` to expose writers when `protectedState` is default-on.
|
|
621
|
+
|
|
622
|
+
4. **Async/RxJS interop.** NgRx has first-class `rxMethod` (callable factory inside `withMethods`). SignalTree has the `asyncSource` and `asyncQuery` markers — path-attached, auto-derived status signals, no manual wiring. Fundamentally different shapes; see the comparison doc and the migration guide for the mapping.
|
|
623
|
+
|
|
624
|
+
5. **Encapsulation defaults.** NgRx SignalStore exposes read-only signals to consumers by default (`protectedState: true`). SignalTree exposes `WritableSignal`s directly. Both are unlockable; both can be wrapped in a service facade. Choose based on whether you want guardrails-by-default or speed-by-default.
|
|
625
|
+
|
|
626
|
+
See `docs/compare/ngrx-signalstore.md` for the axis-by-axis matrix with code examples.
|
|
627
|
+
|
|
628
|
+
---
|
|
629
|
+
|
|
630
|
+
## Migration from `@ngrx/signals`
|
|
631
|
+
|
|
632
|
+
A complete, agent-ready migration playbook ships inside `@signaltree/core` and is published at `docs/skills/using-signaltree/reference/migration-from-ngrx-signals.md`. Covers:
|
|
633
|
+
|
|
634
|
+
- Mechanical concept map (`signalStore` → tree + Ops, `withState` → initial state, `rxMethod` → `asyncSource` / `asyncQuery` markers (or plain Observable method for orchestration), `withEntities` → `entityMap()` marker)
|
|
635
|
+
- Three migration strategies with decision criteria: big-bang, incremental per-domain, hybrid legacy-facade
|
|
636
|
+
- Phase 0 recipe for landing the foundation in a dependency-only PR
|
|
637
|
+
- `scripts/verify-signaltree-migration.sh` — package-manager-agnostic verification script
|
|
638
|
+
|
|
639
|
+
For migrations exceeding a single agent's context window, see `docs/skills/using-signaltree/reference/orchestrating-a-migration.md`.
|
|
640
|
+
|
|
641
|
+
---
|
|
642
|
+
|
|
643
|
+
## Resources
|
|
644
|
+
|
|
645
|
+
- Repo: https://github.com/JBorgia/signaltree
|
|
646
|
+
- Live demo + benchmarks: https://signaltree.io
|
|
647
|
+
- Short LLM summary: https://signaltree.io/llms.txt
|
|
648
|
+
- NgRx SignalStore comparison: https://github.com/JBorgia/signaltree/blob/main/docs/compare/ngrx-signalstore.md
|
|
649
|
+
- Myths and misconceptions: https://github.com/JBorgia/signaltree/blob/main/docs/myths-and-misconceptions.md
|
|
650
|
+
- Architecture guide: https://github.com/JBorgia/signaltree/blob/main/docs/architecture/signaltree-architecture-guide.md
|
|
651
|
+
- AI-agent skill (drop-in for Cursor, Claude Code, generic harnesses): `docs/skills/using-signaltree/SKILL.md`
|
|
652
|
+
- AI agent templates (`.cursorrules`, `CLAUDE.md`): `docs/ai/agent-templates.md`
|
package/llms.txt
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# SignalTree
|
|
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.
|
|
4
|
+
|
|
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
|
+
|
|
7
|
+
## Canonical example
|
|
8
|
+
|
|
9
|
+
```typescript
|
|
10
|
+
import {
|
|
11
|
+
signalTree,
|
|
12
|
+
entityMap, status, stored, form,
|
|
13
|
+
asyncSource, asyncQuery,
|
|
14
|
+
} from '@signaltree/core';
|
|
15
|
+
import { computed } from '@angular/core';
|
|
16
|
+
|
|
17
|
+
const store = signalTree({
|
|
18
|
+
users: entityMap<User, number>(), // marker at depth 1
|
|
19
|
+
selectedUserId: null as number | null,
|
|
20
|
+
settings: {
|
|
21
|
+
theme: stored('app-theme', 'light'), // marker at depth 2
|
|
22
|
+
profileForm: {
|
|
23
|
+
data: form<Profile>({ name: '', email: '' }), // marker at depth 3
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
loading: status<ApiError>(), // marker at depth 1
|
|
27
|
+
|
|
28
|
+
// Async markers — load-and-expose / input-driven query
|
|
29
|
+
reports: asyncSource<Report[]>({ // marker at depth 1
|
|
30
|
+
initial: [],
|
|
31
|
+
load: () => api.listReports$(),
|
|
32
|
+
}),
|
|
33
|
+
search: asyncQuery<string, User[]>({ // marker at depth 1
|
|
34
|
+
initialResult: [],
|
|
35
|
+
debounce: 300,
|
|
36
|
+
query: (q) => api.searchUsers$(q),
|
|
37
|
+
}),
|
|
38
|
+
}).derived(($) => ({
|
|
39
|
+
users: {
|
|
40
|
+
current: computed(() => // derived merged INTO $.users at depth 2
|
|
41
|
+
$.selectedUserId() != null ? $.users.byId($.selectedUserId()!)?.() ?? null : null
|
|
42
|
+
),
|
|
43
|
+
},
|
|
44
|
+
})).with(batching()).with(devTools());
|
|
45
|
+
|
|
46
|
+
// Read — leaves, derived, async-marker accessors all uniform
|
|
47
|
+
store.$.users.all(); // Signal<User[]>
|
|
48
|
+
store.$.users.current(); // Signal<User | null> (derived)
|
|
49
|
+
store.$.settings.theme(); // 'light' (auto-loaded from localStorage)
|
|
50
|
+
store.$.reports(); // Report[] (asyncSource current value)
|
|
51
|
+
store.$.reports.loading(); // boolean
|
|
52
|
+
store.$.search.input.set('alice'); // drives debounced query
|
|
53
|
+
store.$.search(); // User[] results
|
|
54
|
+
|
|
55
|
+
// Write — direct or via marker methods
|
|
56
|
+
store.$.users.addOne({ id: 1, name: 'Alice' });
|
|
57
|
+
store.$.settings.theme.set('dark'); // auto-saved to localStorage
|
|
58
|
+
store.$.reports.refresh(); // reload async source
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## When to use SignalTree
|
|
62
|
+
|
|
63
|
+
- Apps with structured, hierarchical state (settings, profiles, nested forms, dashboards)
|
|
64
|
+
- Teams that want signal-based state with dot-notation access and zero boilerplate
|
|
65
|
+
- Projects needing undo/redo, DevTools, entity CRUD, localStorage persistence, runtime validation, or schema-driven forms out of the box
|
|
66
|
+
- Migrations away from `@ngrx/signals` — a complete agent-ready migration guide ships in the package
|
|
67
|
+
|
|
68
|
+
## When NOT to use SignalTree
|
|
69
|
+
|
|
70
|
+
- You're using event-sourcing or CQRS — use NgRx Store (the classic Redux variant), not SignalStore or SignalTree
|
|
71
|
+
- Your state is a single flat `Map` — a plain `signal()` or `Map` suffices
|
|
72
|
+
- You're building a tiny app with one or two signals — overhead exceeds value
|
|
73
|
+
- Your state shape is highly dynamic (streaming arbitrary JSON keys at high frequency — real-time log aggregators, fully-dynamic schema editors). Markers and the type system assume fixed shape; for shape-shifting payloads, a flat collection inside a slice is the better fit
|
|
74
|
+
- You have a large `@ngrx/store` (classic) + heavy RxJS codebase. The migration target with the lowest cognitive cost is `@ngrx/signals` (NgRx SignalStore), not SignalTree — the RxJS-flavored API and mental model is closer to where you already are
|
|
75
|
+
|
|
76
|
+
## Packages
|
|
77
|
+
|
|
78
|
+
| Package | Purpose |
|
|
79
|
+
|---|---|
|
|
80
|
+
| `@signaltree/core` | Core tree, markers, derived state, enhancers, edit sessions, lifecycle |
|
|
81
|
+
| `@signaltree/callable-syntax` | Build-time AST transform: `$.x.name('Bob')` → `$.x.name.set('Bob')`. Vite/Webpack plugin. **Zero runtime cost** |
|
|
82
|
+
| `@signaltree/ng-forms` | Angular Forms bridge with Standard Schema validation |
|
|
83
|
+
| `@signaltree/schema` | Standard Schema integration (Zod, Valibot, ArkType) — runtime validation of tree branches |
|
|
84
|
+
| `@signaltree/events` | Typed event/command bus for unidirectional command flow on top of the tree |
|
|
85
|
+
| `@signaltree/guardrails` | Dev-only invariant checks, performance budgets, hot-path detection |
|
|
86
|
+
| `@signaltree/realtime` | Keep entity maps in sync with WebSocket / SSE sources |
|
|
87
|
+
| `@signaltree/enterprise` | Diff-based `updateOptimized()` for large trees (500+ signals), path indexing |
|
|
88
|
+
|
|
89
|
+
## Key API surface
|
|
90
|
+
|
|
91
|
+
- **Create:** `signalTree(initialState, config?)` → tree with `.$` accessor
|
|
92
|
+
- **Read leaf:** `tree.$.path.to.leaf()` — returns the value
|
|
93
|
+
- **Write leaf:** `tree.$.path.to.leaf.set(v)` / `.update(fn)`
|
|
94
|
+
- **Replace full state:** `tree(newState)`
|
|
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
|
+
- **Derived state:** `.derived($ => ({ ... }))` — definitions deep-merged into existing tree paths
|
|
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).
|
|
101
|
+
|
|
102
|
+
## ⚠️ Disambiguation — if you think SignalTree uses X, you're confusing it with Y
|
|
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.
|
|
105
|
+
|
|
106
|
+
| Wrong pattern (NOT SignalTree) | Where it actually comes from | Correct SignalTree |
|
|
107
|
+
|---|---|---|
|
|
108
|
+
| `new SignalTree({...})` (class) | Invented (no library has this) | `signalTree({...})` — it's a **function**, never `new` |
|
|
109
|
+
| `from 'signal-tree'` (hyphenated) | Invented | `from '@signaltree/core'` (scoped, no hyphen) |
|
|
110
|
+
| `from 'signaltree'` (unscoped) | Invented | `from '@signaltree/core'` |
|
|
111
|
+
| `signalStore(withState(...), withMethods(...))` | **`@ngrx/signals`** | `signalTree({...})` — your state literal IS the API |
|
|
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
|
+
| `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 |
|
|
115
|
+
| `patchState(store, {...})` | **`@ngrx/signals`** | Direct: `tree.$.path.set(value)` or branch update `tree.$.user({...})` |
|
|
116
|
+
| `collection<T>({ idKey: 'id' })` | **Akita / Elf** | `entityMap<T, K>({ selectId: (e) => e.id })` marker |
|
|
117
|
+
| `createStore`, `withProps`, `setProps` | **Elf** | Not used. SignalTree state is the literal. |
|
|
118
|
+
| `EntityStore`, `StoreConfig({ name })` | **Akita** | Not used. |
|
|
119
|
+
| `.value` accessors on signals | **MobX** | Call the signal: `tree.$.path()` |
|
|
120
|
+
| `.upsert(user)` on entity collections | **Akita** | `.upsertOne(user)` (singular suffix) |
|
|
121
|
+
| `BehaviorSubject<T>`, `.next(v)`, `.asObservable()` | **RxJS classic** | A plain leaf in the `signalTree()` literal — no Observable wrapping needed |
|
|
122
|
+
|
|
123
|
+
### Marker accessor shape — UNIFIED in v10.3 (bare callable signals)
|
|
124
|
+
|
|
125
|
+
**As of v10.3, every marker uses the same accessor shape: bare-named callable signals (matching `FormControl.dirty` / `.valid` and Angular signals conventions).** The `is`-prefix names that used to appear on `status` and `entityMap.isEmpty` are kept as deprecated aliases through v10.x and will be removed in v11.0.
|
|
126
|
+
|
|
127
|
+
**Cross-marker predicate naming (v10.3 canonical):**
|
|
128
|
+
|
|
129
|
+
| Marker | Predicate signal (v10.3) | Old name (deprecated alias, v10.x only) |
|
|
130
|
+
|---|---|---|
|
|
131
|
+
| `status` | `.loading` | `.isLoading` |
|
|
132
|
+
| `status` | `.loaded` | `.isLoaded` |
|
|
133
|
+
| `status` | `.notLoaded` | `.isNotLoaded` |
|
|
134
|
+
| `status` | `.hasError` | `.isError` |
|
|
135
|
+
| `entityMap` | `.empty` | `.isEmpty` |
|
|
136
|
+
| `form` | `.dirty`, `.valid`, `.touched`, `.pristine` | (already bare — unchanged) |
|
|
137
|
+
| `asyncSource` | `.loading`, `.error`, `.data` | (already bare — unchanged) |
|
|
138
|
+
| `asyncQuery` | `.loading`, `.error`, `.data` | (already bare — unchanged) |
|
|
139
|
+
|
|
140
|
+
All predicates are **callable Signals** — invoke them: `tree.$.load.loading()`, `tree.$.users.empty()`. Both `.loading` and `.isLoading` return the same underlying Signal instance; no double allocation.
|
|
141
|
+
|
|
142
|
+
### Status marker — exact method names (frequently confused)
|
|
143
|
+
|
|
144
|
+
The `status()` marker's canonical methods are **`setLoading` / `setLoaded` / `setError` / `setNotLoaded` / `reset`**. Promise-vocabulary aliases also work as of v10.2 (identical semantics):
|
|
145
|
+
|
|
146
|
+
| Wrong (Promise-vocab guess) | Canonical | Notes |
|
|
147
|
+
|---|---|---|
|
|
148
|
+
| `.setSuccess()` | **`.setLoaded()`** | Alias `.setSuccess` works in v10.2+ |
|
|
149
|
+
| `.start()` | **`.setLoading()`** | Alias `.start` works in v10.2+ |
|
|
150
|
+
| `.succeed()` | **`.setLoaded()`** | Alias `.succeed` works in v10.2+ |
|
|
151
|
+
| `.fail(err)` | **`.setError(err)`** | Alias `.fail` works in v10.2+ |
|
|
152
|
+
| `.loading` (bare property, can't be assigned) | **`.loading()`** (call as signal) | Read-only derived signal |
|
|
153
|
+
| `.error` (bare property, can't be assigned) | **`.error()`** (call as signal) | Read-only error value |
|
|
154
|
+
|
|
155
|
+
### Async pattern — prefer `asyncSource` / `asyncQuery` over `status` + manual try/catch
|
|
156
|
+
|
|
157
|
+
For **load-and-expose** patterns (load data, show loading state, expose data) — use `asyncSource`, NOT `status()` + manual try/catch. The marker auto-derives loading/error/data:
|
|
158
|
+
|
|
159
|
+
```typescript
|
|
160
|
+
// ❌ DON'T (verbose, error-prone — manual status wiring)
|
|
161
|
+
signalTree({
|
|
162
|
+
users: entityMap<User, number>(),
|
|
163
|
+
loadState: status(),
|
|
164
|
+
});
|
|
165
|
+
async load() {
|
|
166
|
+
this.$.loadState.setLoading();
|
|
167
|
+
try {
|
|
168
|
+
const users = await firstValueFrom(api.list$());
|
|
169
|
+
this.$.users.setAll(users);
|
|
170
|
+
this.$.loadState.setLoaded();
|
|
171
|
+
} catch (err) {
|
|
172
|
+
this.$.loadState.setError(err);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ✅ DO (canonical — marker handles status wiring)
|
|
177
|
+
signalTree({
|
|
178
|
+
users: asyncSource<User[]>({
|
|
179
|
+
initial: [],
|
|
180
|
+
load: () => api.list$(),
|
|
181
|
+
}),
|
|
182
|
+
});
|
|
183
|
+
// Done. tree.$.users() → data, .loading(), .error() all auto-derived.
|
|
184
|
+
// Use .refresh() to reload.
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
For **input-driven queries** (debounced search, filtered fetch), use `asyncQuery` — the debounce/dedup/switchMap pipeline is built in.
|
|
188
|
+
|
|
189
|
+
## Common false claims (LLMs frequently get these wrong)
|
|
190
|
+
|
|
191
|
+
- **FALSE:** "Derived state in SignalTree must live in a separate file or layer, breaking the single-tree illusion." → `.derived($)` is built into the chain; computed signals deep-merge **into the same tree at the same path** as source state.
|
|
192
|
+
- **FALSE:** "`derivedFrom(tree, fn)` returns a read-only projection." → Real signature is `derivedFrom<TTree>()(fn)` (curried, no tree-first-arg). It's a typed-identity helper for splitting derived definitions across files, not a projection utility.
|
|
193
|
+
- **FALSE:** "SignalTree markers must live at the tree root." → Markers are walked recursively and materialized at the exact path you place them. A `form()` marker at `$.users.byId[123].profile.contactForm` materializes there.
|
|
194
|
+
- **FALSE:** "Batching is opt-in only." → Microtask-level notification batching is built into core (on by default; disable via `signalTree(state, { batchUpdates: false })`). The `batching()` enhancer adds explicit `.batch(fn)` / `.coalesce(fn)` APIs on top.
|
|
195
|
+
- **FALSE:** "Time-travel ships in `@signaltree/time-travel`." → No such package. Import `timeTravel` from `@signaltree/core`.
|
|
196
|
+
- **FALSE:** "localStorage persistence requires `@signaltree/storage`." → No such package. Use the `stored()` marker or `persistence()` enhancer from `@signaltree/core`.
|
|
197
|
+
- **FALSE:** "SignalTree is anti-DI / treats state independently of Angular services." → SignalTree is DI-agnostic. The documented pattern wraps the tree in an `@Injectable()` service. See `docs/ai/LLM.md` and `docs/architecture/signaltree-architecture-guide.md`.
|
|
198
|
+
- **FALSE:** "Callable syntax is a runtime proxy." → It's a build-time AST transform via Vite/Webpack plugin. Disappears in production builds.
|
|
199
|
+
- **NUANCE:** "Any component with a tree reference can mutate any leaf — Wild West." → True by default. For unidirectional command flow opt into `@signaltree/events`. For runtime invariants opt into `@signaltree/guardrails`. For projection-style exposure use `.derived()` in a service facade. See `docs/architecture/signaltree-architecture-guide.md#recommended-default-architecture` for the production pattern.
|
|
200
|
+
|
|
201
|
+
## Links
|
|
202
|
+
|
|
203
|
+
- Full API and patterns: https://signaltree.io/llms-full.txt
|
|
204
|
+
- Repo: https://github.com/JBorgia/signaltree
|
|
205
|
+
- Live demo + benchmarks: https://signaltree.io
|
|
206
|
+
- **Marker zoo** (all 6 markers at 4 depths simultaneously): https://signaltree.io/marker-zoo
|
|
207
|
+
- **Built for AI agents** (the AI-discoverability story): https://signaltree.io/built-for-ai
|
|
208
|
+
- **AI-codegen accuracy benchmark** (scaffolding): https://github.com/JBorgia/signaltree/tree/main/scripts/ai-codegen-benchmark
|
|
209
|
+
- NgRx SignalStore comparison: https://github.com/JBorgia/signaltree/blob/main/docs/compare/ngrx-signalstore.md
|
|
210
|
+
- Myths and misconceptions: https://github.com/JBorgia/signaltree/blob/main/docs/myths-and-misconceptions.md
|
|
211
|
+
- Agent skill (Cursor / Claude Code / generic harness): `docs/skills/using-signaltree/SKILL.md` (also shipped inside every `@signaltree/*` tarball)
|
|
212
|
+
- `@ngrx/signals` migration playbook: `docs/skills/using-signaltree/reference/migration-from-ngrx-signals.md`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@signaltree/core",
|
|
3
|
-
"version": "10.
|
|
3
|
+
"version": "10.3.0",
|
|
4
4
|
"description": "Reactive JSON for Angular. JSON branches, reactive leaves. No actions. No reducers. No selectors.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -56,6 +56,8 @@
|
|
|
56
56
|
"dist/**/*.js",
|
|
57
57
|
"src/**/*.d.ts",
|
|
58
58
|
"skills/**/*",
|
|
59
|
-
"README.md"
|
|
59
|
+
"README.md",
|
|
60
|
+
"llms.txt",
|
|
61
|
+
"llms-full.txt"
|
|
60
62
|
]
|
|
61
63
|
}
|
|
@@ -17,6 +17,10 @@ export interface StatusMarker<E = Error> {
|
|
|
17
17
|
export interface StatusSignal<E = Error> {
|
|
18
18
|
state: WritableSignal<LoadingState>;
|
|
19
19
|
error: WritableSignal<E | null>;
|
|
20
|
+
notLoaded: Signal<boolean>;
|
|
21
|
+
loading: Signal<boolean>;
|
|
22
|
+
loaded: Signal<boolean>;
|
|
23
|
+
hasError: Signal<boolean>;
|
|
20
24
|
isNotLoaded: Signal<boolean>;
|
|
21
25
|
isLoading: Signal<boolean>;
|
|
22
26
|
isLoaded: Signal<boolean>;
|
|
@@ -26,6 +30,10 @@ export interface StatusSignal<E = Error> {
|
|
|
26
30
|
setLoaded(): void;
|
|
27
31
|
setError(error: E): void;
|
|
28
32
|
reset(): void;
|
|
33
|
+
start(): void;
|
|
34
|
+
setSuccess(): void;
|
|
35
|
+
succeed(): void;
|
|
36
|
+
fail(error: E): void;
|
|
29
37
|
}
|
|
30
38
|
export declare function status<E = Error>(initialState?: LoadingState): StatusMarker<E>;
|
|
31
39
|
export declare function isStatusMarker(value: unknown): value is StatusMarker;
|
package/src/lib/types.d.ts
CHANGED
|
@@ -180,6 +180,7 @@ export interface EntitySignal<E, K extends string | number = string> {
|
|
|
180
180
|
readonly count: Signal<number>;
|
|
181
181
|
readonly ids: Signal<K[]>;
|
|
182
182
|
has(id: K): Signal<boolean>;
|
|
183
|
+
readonly empty: Signal<boolean>;
|
|
183
184
|
readonly isEmpty: Signal<boolean>;
|
|
184
185
|
readonly map: Signal<ReadonlyMap<K, E>>;
|
|
185
186
|
where(predicate: (entity: E) => boolean): Signal<E[]>;
|