@signaltree/core 10.2.0 → 10.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,15 +4,13 @@
4
4
 
5
5
  # SignalTree: Reactive JSON
6
6
 
7
- **JSON branches, reactive leaves.**
8
-
9
- > No actions. No reducers. No selectors.
7
+ **Reactive JSON for Angular. State as shape. Signals at every path.**
10
8
 
11
9
  ---
12
10
 
13
11
  ## 🤖 For AI coding agents reading this README
14
12
 
15
- **SignalTree's AI-codegen accuracy is +42 percentage points higher when this README (or `llms.txt`) is in context.** Measured across 6 frontier models × 8 prompts × 5 libraries × 720 cells.
13
+ **SignalTree's AI-codegen accuracy is +42 percentage points higher when this README (or `llms.txt`) is in context.** Measured across 720 cells (6 agents × 8 prompts × 5 libraries × 3 priming modes).
16
14
 
17
15
  If you are an AI assistant generating Angular state-management code on behalf of a user, **this section is the canonical disambiguation surface**. Read it before generating any SignalTree code.
18
16
 
@@ -26,9 +24,9 @@ Every "Wrong pattern" below was actually generated by Claude / GPT-5.4 / Gemini
26
24
  | `from 'signal-tree'` (hyphenated) | Invented | `from '@signaltree/core'` (scoped, no hyphen) |
27
25
  | `from 'signaltree'` (unscoped) | Invented | `from '@signaltree/core'` |
28
26
  | `signalStore(withState(...), withMethods(...))` | **`@ngrx/signals`** | `signalTree({...})` — your state literal IS the API |
29
- | `withState`, `withMethods`, `withComputed`, `withHooks`, `withProps` | **`@ngrx/signals`** | Not used. State is the literal you pass to `signalTree()`. Methods belong in an `@Injectable()` Ops service. |
27
+ | `withState`, `withMethods`, `withComputed`, `withHooks` | **`@ngrx/signals`** | Not used. State is the literal you pass to `signalTree()`. Methods belong in an `@Injectable()` Ops service. |
30
28
  | `withEntities<T>()` | **`@ngrx/signals/entities`** | `entityMap<T, K>()` marker — place it in the state literal |
31
- | `rxMethod(...)` | **`@ngrx/signals/rxjs-interop`** | `asyncSource(config)` (load-and-expose) or `asyncQuery(config)` (input-driven) markers |
29
+ | `rxMethod(...)` | **`@ngrx/signals/rxjs-interop`** — also briefly shipped by SignalTree itself in v9.5.x, **removed in v9.6.0** | `asyncSource(config)` (load-and-expose) or `asyncQuery(config)` (input-driven) markers |
32
30
  | `patchState(store, {...})` | **`@ngrx/signals`** | Direct: `tree.$.path.set(value)` or `tree.$.user({...})` |
33
31
  | `tapResponse(...)` | **`@ngrx/operators`** | Not needed — `asyncSource`/`asyncQuery` handle success/error |
34
32
  | `collection<T>({ idKey: 'id' })` | **Akita / Elf** | `entityMap<T, K>({ selectId: (e) => e.id })` marker |
@@ -40,18 +38,31 @@ Every "Wrong pattern" below was actually generated by Claude / GPT-5.4 / Gemini
40
38
  | `Store.dispatch(action)`, `Store.select(selector)` | **`@ngrx/store` (classic)** | `tree.$.path()` to read, `tree.$.path.set(v)` to write |
41
39
  | `.toPromise()` (deprecated RxJS 7+) | RxJS legacy | `firstValueFrom(obs)` — or let `asyncSource` consume directly |
42
40
 
43
- ### Status markerexact method names (frequently confused)
41
+ ### Marker accessor shape UNIFIED in v10.3
42
+
43
+ **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.
44
+
45
+ | Marker | v10.3 canonical (preferred) | Deprecated alias (v10.x only) |
46
+ |---|---|---|
47
+ | `status` | `.loading`, `.loaded`, `.notLoaded`, `.hasError` | `.isLoading`, `.isLoaded`, `.isNotLoaded`, `.isError` |
48
+ | `entityMap` | `.empty` | `.isEmpty` |
49
+ | `form` | `.dirty`, `.valid`, `.touched`, `.submitting` | (already bare — unchanged) |
50
+ | `asyncSource` / `asyncQuery` | `.loading`, `.error`, `.data` | (already bare — unchanged) |
51
+
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.
53
+
54
+ ### Status marker — method names (frequently confused)
44
55
 
45
56
  The `status()` marker's canonical methods are **`setLoading` / `setLoaded` / `setError`**. As of **v10.2**, Promise-vocabulary aliases also work (identical semantics):
46
57
 
47
58
  | Wrong-but-now-aliased (v10.2+) | Canonical | Equivalent? |
48
59
  |---|---|---|
49
- | `.setSuccess()` | `.setLoaded()` | Yes — alias |
60
+ | `.setSuccess()` (no args) | `.setLoaded()` | Yes — alias |
50
61
  | `.start()` | `.setLoading()` | Yes — alias |
51
62
  | `.succeed()` | `.setLoaded()` | Yes — alias |
52
63
  | `.fail(err)` | `.setError(err)` | Yes — alias |
53
- | `.loading` (bare property) | `.isLoading()` (call as signal) | **No — must call** |
54
- | `.error` (bare property) | `.error()` (call as signal) | **Nomust call** |
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.
55
66
 
56
67
  ### Canonical async pattern — use `asyncSource`, NOT `status` + manual try/catch
57
68
 
@@ -71,7 +82,7 @@ export class UsersService {
71
82
  }),
72
83
  });
73
84
 
74
- readonly users = this.tree.$.users; // .users() → data, .users.loading(), .users.error()
85
+ readonly users = this.tree.$.users; // .users() → User[] | undefined, .users.loading(), .users.error()
75
86
  load = () => this.tree.$.users.refresh();
76
87
  }
77
88
  ```
@@ -90,7 +101,9 @@ export class AppService {
90
101
  private readonly tree = signalTree({
91
102
  users: entityMap<User, number>(),
92
103
  saveStatus: status(),
93
- profile: form({ firstName: '', lastName: '' }),
104
+ profile: form<{ firstName: string; lastName: string }>({
105
+ initial: { firstName: '', lastName: '' },
106
+ }),
94
107
  feed: asyncSource<Post[]>({ initial: [], load: () => api.feed$() }),
95
108
  });
96
109
 
@@ -139,17 +152,15 @@ You don't model state as actions, reducers, selectors, or classes — you model
139
152
 
140
153
  ## Import guidance (tree-shaking)
141
154
 
142
- Modern bundlers (webpack 5+, esbuild, Rollup, Vite) **automatically tree-shake barrel imports** from `@signaltree/core`. Both import styles produce identical bundle sizes:
155
+ Modern bundlers (Vite, esbuild, Rollup, webpack 5+) **automatically tree-shake barrel imports** from `@signaltree/core`. Unused enhancers and markers drop out of the bundle.
143
156
 
144
157
  ```ts
145
- // Recommended: Simple and clean
158
+ // Import only what you use — unused symbols are tree-shaken away
146
159
  import { signalTree, batching } from '@signaltree/core';
147
-
148
- // ✅ Also fine: Explicit subpath (same bundle size)
149
- import { signalTree } from '@signaltree/core';
150
- import { batching } from '@signaltree/core/enhancers/batching';
151
160
  ```
152
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
+
153
164
  **Measured impact** (with modern bundlers):
154
165
 
155
166
  - Core only: ~8.5 KB gzipped
@@ -184,35 +195,36 @@ const tree = signalTree({ count: 0 });
184
195
 
185
196
  This repo's ESLint rule is **disabled by default** since testing confirms effective tree-shaking with barrel imports.
186
197
 
187
- ### Callable leaf signals (DX sugar only)
198
+ ### Callable shape branches natively, leaves with the build transform
188
199
 
189
- SignalTree provides TypeScript support for callable syntax on leaf signals as developer experience sugar:
200
+ **Branches are natively callable** for reads AND writes at runtime no transform required:
190
201
 
191
202
  ```typescript
192
- // TypeScript accepts this syntax (with proper tooling):
193
- tree.$.name('Jane'); // Set value
194
- tree.$.count((n) => n + 1); // Update with function
203
+ tree.$.user(); // Read the user subtree
204
+ tree.$.user({ name: 'Jane' }); // Deep-merge partial update at runtime
205
+ ```
206
+
207
+ **Leaves are Angular signals** — callable as getters, but writes go through `.set()` / `.update()`. The `@signaltree/callable-syntax` build-time transform extends the branch's call-with-arg shape down to leaf writes, so call-sites read uniformly from root to leaf:
195
208
 
196
- // At build time, transforms convert to:
197
- tree.$.name.set('Jane'); // Direct Angular signal API
198
- tree.$.count.update((n) => n + 1); // Direct Angular signal API
209
+ ```typescript
210
+ // With @signaltree/callable-syntax transform installed:
211
+ tree.$.name('Jane'); // compiles to tree.$.name.set('Jane')
212
+ tree.$.count((n) => n + 1); // compiles to tree.$.count.update((n) => n + 1)
199
213
 
200
- // Reading always works directly:
201
- const name = tree.$.name(); // No transform needed
214
+ // Without the transform — leaf reads work, leaf writes use .set() / .update():
215
+ const name = tree.$.name();
216
+ tree.$.name.set('Jane');
217
+ tree.$.count.update((n) => n + 1);
202
218
  ```
203
219
 
204
220
  **Key Points:**
205
221
 
206
- - **Zero runtime overhead**: No Proxy wrappers or runtime hooks
207
- - **Build-time only**: AST transform converts callable syntax to direct `.set/.update` calls
208
- - **Optional**: Use `@signaltree/callable-syntax` transform or stick with direct `.set/.update`
209
- - **Type-safe**: Full TypeScript support via module augmentation
210
-
211
- **Function-valued leaves:**
212
- When a leaf stores a function as its value, use direct `.set(fn)` to assign. Callable `sig(fn)` is treated as an updater.
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
213
226
 
214
- **Setup:**
215
- 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.
216
228
 
217
229
  ### Measuring performance and size
218
230
 
@@ -269,7 +281,7 @@ export function createUserTree() {
269
281
  // ✅ Correct: Derived from multiple signals
270
282
  const selectedUser = computed(() => {
271
283
  const id = $.selected.userId();
272
- return id ? $.users.byId(id)() : null;
284
+ return id ? $.users.byId(id)?.() ?? null : null;
273
285
  });
274
286
 
275
287
  // ❌ Wrong: Wrapping an existing signal
@@ -280,12 +292,12 @@ const selectedUserId = computed(() => $.selected.userId()); // Unnecessary!
280
292
 
281
293
  ```typescript
282
294
  // ✅ SignalTree-native
283
- const user = $.users.byId(123)(); // O(1) lookup
295
+ const user = $.users.byId(123)?.(); // EntityNode → User | undefined
284
296
  const allUsers = $.users.all; // Get all
285
297
  $.users.setAll(usersFromApi); // Replace all
286
298
 
287
- // NgRx-style (avoid)
288
- const user = entityMap()[123]; // Requires intermediate object
299
+ // (NgRx Signal Store equivalent — for context, not SignalTree syntax)
300
+ // const user = usersStore.entityMap()[123];
289
301
  ```
290
302
 
291
303
  ### Notification Batching
@@ -608,9 +620,9 @@ const tree = signalTree<AppState>({
608
620
  },
609
621
  });
610
622
 
611
- // Complex updates with type safety
612
- // Requires @signaltree/callable-syntax. Without the transform, use
613
- // tree.set((state) => ({ ... })) or leaf .set() / .update() calls instead.
623
+ // Complex updates with type safety — the root accessor itself is callable
624
+ // (no @signaltree/callable-syntax transform required for the root). Pass a
625
+ // partial object or an updater function. For leaf writes, use .set() / .update().
614
626
  tree((state) => ({
615
627
  auth: {
616
628
  ...state.auth,
@@ -709,7 +721,7 @@ const activeUsers = computed(() => tree.$.users().filter((user) => user.active))
709
721
 
710
722
  ### 4) Manual async state management
711
723
 
712
- Core provides basic state updates. For advanced async helpers, use the built-in async helpers (`createAsyncOperation`, `trackAsync`):
724
+ Core provides basic state updates. For canonical async patterns, use the **`asyncSource` and `asyncQuery` markers** (see the async section). The patterns below show the manual style for cases the markers don't cover (multi-stage orchestration, conditional pipelines):
713
725
 
714
726
  ```typescript
715
727
  const tree = signalTree({
@@ -770,8 +782,8 @@ All enhancers are exported directly from `@signaltree/core`:
770
782
 
771
783
  **Data Management:**
772
784
 
773
- - `createAsyncOperation()` - Async operation management with loading/error states
774
- - `trackAsync()` - Track async operations in your state
785
+ - `asyncSource(config)` marker - Load-and-expose async state (canonical, v9.5+)
786
+ - `asyncQuery(config)` marker - Input-driven debounced query state (canonical, v9.5+)
775
787
  - `serialization()` - State persistence and SSR support
776
788
  - `persistence()` - Auto-save to localStorage/IndexedDB
777
789
 
@@ -838,7 +850,10 @@ tree.$.products.addOne(newProduct);
838
850
  tree.$.products.setAll(productsFromApi);
839
851
 
840
852
  // Entity queries
841
- const electronics = tree.$.products.all.filter((p) => p.category === 'electronics');
853
+ // Reactive: returns Signal<Product[]> that tracks the predicate
854
+ const electronics = tree.$.products.where((p) => p.category === 'electronics');
855
+ // One-shot non-reactive read: call .all() then filter
856
+ const electronicsSnapshot = tree.$.products.all().filter((p) => p.category === 'electronics');
842
857
  ```
843
858
 
844
859
  **Full-Stack Application:**
@@ -897,7 +912,7 @@ const tree = signalTree(state)
897
912
  SignalTree Core includes all enhancer functionality built-in. No separate packages needed:
898
913
 
899
914
  ```typescript
900
- import { signalTree, entityMap, entities } from '@signaltree/core';
915
+ import { signalTree, entityMap } from '@signaltree/core';
901
916
 
902
917
  // Without entityMap - use manual array updates
903
918
  const basic = signalTree({ users: [] as User[] });
@@ -909,7 +924,7 @@ const enhanced = signalTree({
909
924
  });
910
925
 
911
926
  enhanced.$.users.addOne(newUser); // ✅ Advanced CRUD operations
912
- enhanced.$.users.byId(123)(); // ✅ O(1) lookups
927
+ enhanced.$.users.byId(123)?.(); // ✅ O(1) lookups (undefined if missing)
913
928
  enhanced.$.users.all; // ✅ Get all as array
914
929
  ```
915
930
 
@@ -1248,17 +1263,26 @@ const tree = signalTree({
1248
1263
  tree.$.users.loadStatus.state(); // Signal<LoadingState>
1249
1264
  tree.$.users.loadStatus.error(); // Signal<ApiError | null>
1250
1265
 
1251
- // Convenience signals
1252
- tree.$.users.loadStatus.isNotLoaded(); // Signal<boolean>
1253
- tree.$.users.loadStatus.isLoading(); // Signal<boolean>
1254
- tree.$.users.loadStatus.isLoaded(); // Signal<boolean>
1255
- tree.$.users.loadStatus.isError(); // Signal<boolean>
1266
+ // Convenience signals (v10.3 canonical — bare names)
1267
+ tree.$.users.loadStatus.notLoaded(); // Signal<boolean>
1268
+ tree.$.users.loadStatus.loading(); // Signal<boolean>
1269
+ tree.$.users.loadStatus.loaded(); // Signal<boolean>
1270
+ tree.$.users.loadStatus.hasError(); // Signal<boolean>
1271
+ // Deprecated aliases (work through v10.x, removed v11):
1272
+ // .isNotLoaded(), .isLoading(), .isLoaded(), .isError()
1256
1273
 
1257
- // Update methods
1274
+ // Update methods (canonical)
1258
1275
  tree.$.users.loadStatus.setLoading();
1259
1276
  tree.$.users.loadStatus.setLoaded();
1260
1277
  tree.$.users.loadStatus.setError({ code: 404, message: 'Not found' });
1261
- tree.$.users.loadStatus.reset();
1278
+ tree.$.users.loadStatus.setNotLoaded();
1279
+ tree.$.users.loadStatus.reset(); // alias for setNotLoaded
1280
+
1281
+ // v10.2+ Promise-vocabulary aliases (identical semantics, no args)
1282
+ tree.$.users.loadStatus.start(); // === setLoading()
1283
+ tree.$.users.loadStatus.setSuccess(); // === setLoaded() — NO ARGS
1284
+ tree.$.users.loadStatus.succeed(); // === setLoaded()
1285
+ tree.$.users.loadStatus.fail(err); // === setError(err)
1262
1286
 
1263
1287
  // LoadingState enum
1264
1288
  LoadingState.NotLoaded; // 'not-loaded'
@@ -1533,23 +1557,12 @@ const tree = signalTree({
1533
1557
  async function handleSubmit() {
1534
1558
  const contactForm = tree.$.contact;
1535
1559
 
1536
- // Validate all fields first
1537
- contactForm.touchAll();
1538
- const isValid = await contactForm.validate();
1539
-
1540
- if (!isValid) return;
1541
-
1542
- // Set submitting state
1543
- contactForm.setSubmitting(true);
1544
-
1545
- try {
1546
- 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);
1547
1564
  contactForm.reset();
1548
- } catch (error) {
1549
- // Handle error
1550
- } finally {
1551
- contactForm.setSubmitting(false);
1552
- }
1565
+ });
1553
1566
  }
1554
1567
  ```
1555
1568
 
@@ -1702,7 +1715,7 @@ const filteredProducts = computed(() => {
1702
1715
  ### Data Management Composition
1703
1716
 
1704
1717
  ```typescript
1705
- import { signalTree, entityMap, entities } from '@signaltree/core';
1718
+ import { signalTree, entityMap } from '@signaltree/core';
1706
1719
 
1707
1720
  // entityMap() markers self-register — entity operations available immediately
1708
1721
  const tree = signalTree({
@@ -1714,7 +1727,7 @@ const tree = signalTree({
1714
1727
  // Advanced entity operations via tree.$ accessor
1715
1728
  tree.$.users.addOne(newUser);
1716
1729
  tree.$.users.where((u) => u.active);
1717
- tree.$.users.updateMany([{ id: '1', changes: { status: 'active' } }]);
1730
+ tree.$.users.updateMany(['1', '2', '3'], { status: 'active' }); // ids + shared changes
1718
1731
 
1719
1732
  // Entity helpers work with nested structures
1720
1733
  // Example: deeply nested entities in a domain-driven design pattern
@@ -1790,7 +1803,7 @@ const tree = signalTree({
1790
1803
  async function fetchUser(id: string) {
1791
1804
  return await api.getUser(id);
1792
1805
  }
1793
- tree.$.app.data.users.byId(userId)(); // O(1) lookup
1806
+ tree.$.app.data.users.byId(userId)?.(); // O(1) lookup (undefined if missing)
1794
1807
  tree.undo(); // Time travel
1795
1808
  tree.save(); // Persistence
1796
1809
  ```
@@ -1854,7 +1867,7 @@ In Redux DevTools you will see a single instance named `"MyApp SignalTree"` with
1854
1867
  ### Production-Ready Composition
1855
1868
 
1856
1869
  ```typescript
1857
- import { signalTree, batching, entities, serialization } from '@signaltree/core';
1870
+ import { signalTree, batching, serialization } from '@signaltree/core';
1858
1871
 
1859
1872
  // Production build (no dev tools)
1860
1873
  const tree = signalTree(initialState)
@@ -1917,7 +1930,7 @@ const tree = signalTree(state);
1917
1930
  const tree2 = tree.with(batching());
1918
1931
 
1919
1932
  // Phase 3: Add async for API integration
1920
- // withAsync removed — no explicit async enhancer; use async helpers instead
1933
+ // withAsync removed in v9 use the asyncSource() / asyncQuery() markers (v10.x canonical) for load-and-expose and input-driven flows.
1921
1934
 
1922
1935
  // Each phase is fully functional and production-ready
1923
1936
  ```
@@ -2205,7 +2218,7 @@ tree.destroy(); // Cleanup resources
2205
2218
 
2206
2219
  // Entity helpers (entityMap() self-registers — no enhancer needed)
2207
2220
  tree.$.users.addOne(user); // Add single entity
2208
- tree.$.users.byId(id)(); // O(1) lookup by ID (read whole entity)
2221
+ tree.$.users.byId(id)?.(); // O(1) cursor unwrap (undefined if missing)
2209
2222
  tree.$.users.byId(id)?.name(); // Read single field (computed signal)
2210
2223
  tree.$.users.byId(id)?.name.set('Bob'); // Write single field (throws if entity removed)
2211
2224
  tree.$.users.byId(id)?.name.update(fn); // Updater on single field
@@ -2267,7 +2280,7 @@ Consider enhancers when you need:
2267
2280
 
2268
2281
  - ⚡ Performance optimization (batching)
2269
2282
  - 🐛 Advanced debugging (devTools, timeTravel)
2270
- - 📦 Entity management (entities)
2283
+ - 📦 Entity management (`entityMap` marker)
2271
2284
 
2272
2285
  Consider separate packages when you need:
2273
2286
 
@@ -2453,8 +2466,8 @@ npm install @signaltree/core
2453
2466
  # Everything is available from @signaltree/core:
2454
2467
  import {
2455
2468
  signalTree,
2469
+ entityMap,
2456
2470
  batching,
2457
- entities,
2458
2471
  devTools,
2459
2472
  timeTravel,
2460
2473
  serialization
@@ -2784,7 +2797,7 @@ export default {
2784
2797
  **Start with just `@signaltree/core`** - it includes comprehensive enhancers for most applications:
2785
2798
 
2786
2799
  - Performance optimization (batching)
2787
- - Data management (entities, async operations)
2800
+ - Data management (`entityMap` marker, `asyncSource` / `asyncQuery` markers)
2788
2801
  - Development tools (devtools, time-travel)
2789
2802
  - State persistence (serialization)
2790
2803
 
@@ -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 _isNotLoaded = null;
30
- let _isLoading = null;
31
- let _isLoaded = null;
32
- let _isError = null;
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 _isNotLoaded ??= computed(() => stateSignal() === LoadingState.NotLoaded);
53
+ return getNotLoaded();
38
54
  },
39
55
  get isLoading() {
40
- return _isLoading ??= computed(() => stateSignal() === LoadingState.Loading);
56
+ return getLoading();
41
57
  },
42
58
  get isLoaded() {
43
- return _isLoaded ??= computed(() => stateSignal() === LoadingState.Loaded);
59
+ return getLoaded();
44
60
  },
45
61
  get isError() {
46
- return _isError ??= computed(() => stateSignal() === LoadingState.Error);
62
+ return getHasError();
47
63
  },
48
64
  setNotLoaded() {
49
65
  stateSignal.set(LoadingState.NotLoaded);
package/llms-full.txt CHANGED
@@ -520,6 +520,23 @@ This is the pattern enforced in production migrations. The `$` access stays read
520
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
521
  | `.toPromise()` on Observables (deprecated RxJS 7+) | RxJS legacy | `firstValueFrom(observable)` — or let `asyncSource` consume the Observable directly |
522
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
+
523
540
  ### Status marker — exact method names (frequently confused)
524
541
 
525
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.
@@ -530,9 +547,8 @@ The `status()` marker's canonical methods are **`setLoading` / `setLoaded` / `se
530
547
  | `.start()` | `.setLoading()` | Yes — alias |
531
548
  | `.succeed()` | `.setLoaded()` | Yes — alias |
532
549
  | `.fail(err)` | `.setError(err)` | Yes — alias |
533
- | `.loading` (bare property) | `.isLoading()` (callable signal) | No — must call as signal |
534
- | `.error` (bare property) | `.error()` (callable signal) | No must call as signal |
535
- | `.success` (bare property) | `.isLoaded()` (callable signal) | No — must call as signal |
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) |
536
552
 
537
553
  ### Async patterns — prefer `asyncSource` / `asyncQuery` over manual `status` + try/catch
538
554
 
package/llms.txt CHANGED
@@ -120,18 +120,37 @@ This table catches the most common cross-library hallucinations. **Every "Wrong"
120
120
  | `.upsert(user)` on entity collections | **Akita** | `.upsertOne(user)` (singular suffix) |
121
121
  | `BehaviorSubject<T>`, `.next(v)`, `.asObservable()` | **RxJS classic** | A plain leaf in the `signalTree()` literal — no Observable wrapping needed |
122
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
+
123
142
  ### Status marker — exact method names (frequently confused)
124
143
 
125
- The `status()` marker uses **`setLoading` / `setLoaded` / `setError`**, NOT Promise-vocabulary names. **Aliases ship as of v10.2** so the most common wrong-names also work, but the canonical names are:
144
+ The `status()` marker's canonical methods are **`setLoading` / `setLoaded` / `setError` / `setNotLoaded` / `reset`**. Promise-vocabulary aliases also work as of v10.2 (identical semantics):
126
145
 
127
- | Wrong (Promise-vocab guess) | Correct (canonical) | Notes |
146
+ | Wrong (Promise-vocab guess) | Canonical | Notes |
128
147
  |---|---|---|
129
148
  | `.setSuccess()` | **`.setLoaded()`** | Alias `.setSuccess` works in v10.2+ |
130
149
  | `.start()` | **`.setLoading()`** | Alias `.start` works in v10.2+ |
131
150
  | `.succeed()` | **`.setLoaded()`** | Alias `.succeed` works in v10.2+ |
132
151
  | `.fail(err)` | **`.setError(err)`** | Alias `.fail` works in v10.2+ |
133
- | `.loading` (property) | **`.isLoading()`** (callable signal invoke it) |
134
- | `.error` (property) | **`.error()`** (callable signal invoke it) |
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 |
135
154
 
136
155
  ### Async pattern — prefer `asyncSource` / `asyncQuery` over `status` + manual try/catch
137
156
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@signaltree/core",
3
- "version": "10.2.0",
4
- "description": "Reactive JSON for Angular. JSON branches, reactive leaves. No actions. No reducers. No selectors.",
3
+ "version": "10.3.1",
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,
@@ -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>;
@@ -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[]>;