@signaltree/enterprise 13.1.1 → 13.2.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/package.json +2 -2
- package/skills/using-signaltree/SKILL.md +153 -0
- package/skills/using-signaltree/enterprise/SKILL.md +95 -0
- package/skills/using-signaltree/reference/anti-patterns.md +266 -0
- package/skills/using-signaltree/reference/core.md +398 -0
- package/skills/using-signaltree/reference/install.md +110 -0
- package/skills/using-signaltree/reference/migration-from-ngrx-signals.md +971 -0
- package/skills/using-signaltree/reference/migration-from-ngrx-store.md +396 -0
- package/skills/using-signaltree/reference/optimal-implementation.md +211 -0
- package/skills/using-signaltree/reference/orchestrating-a-migration.md +364 -0
- package/skills/using-signaltree/reference/patterns.md +1250 -0
- package/skills/using-signaltree/reference/testing.md +282 -0
|
@@ -0,0 +1,971 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: signaltree-migration-from-ngrx-signals
|
|
3
|
+
description: One-to-one mapping guide specifically for porting @ngrx/signals stores to SignalTree. Only load this skill when the user is converting code that uses the @ngrx/signals package (signalStore, withState, withMethods, withComputed, withHooks, rxMethod, patchState, withEntities, signalStoreFeature). Do NOT load for classic @ngrx/store (reducers/actions/effects) or @ngrx/component-store migrations — those are different patterns.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Migrating from @ngrx/signals to SignalTree
|
|
7
|
+
|
|
8
|
+
Quick reference for converting an existing `@ngrx/signals` codebase. Applies **only to `@ngrx/signals`** — the signal-based store package (`signalStore`, `withState`, `rxMethod`). For classic `@ngrx/store` (reducers, actions, effects, `@ngrx/entity`), use [`migration-from-ngrx-store.md`](./migration-from-ngrx-store.md) — it shares this file's target architecture but owns the actions/reducers/selectors/effects mappings. Not applicable to `@ngrx/component-store` (map its `ComponentStore<T>` to a component-local `signalTree()`).
|
|
9
|
+
|
|
10
|
+
Read the root `SKILL.md`, [`reference/optimal-implementation.md`](./optimal-implementation.md), and [`reference/patterns.md`](./patterns.md) for full SignalTree context; use this file for the mechanical mappings.
|
|
11
|
+
|
|
12
|
+
> **Driving multiple subagents through this migration?** A single implementer often runs out of context after building the foundation but before sweeping consumers. If the migration touches ≥ 2 legacy stores or ≥ 10 consumer files, read [`orchestrating-a-migration.md`](./orchestrating-a-migration.md) for the phased survey → audit → foundation → consumers → gate playbook before dispatching any subagent.
|
|
13
|
+
|
|
14
|
+
## Minimum viable migration (1 small store)
|
|
15
|
+
|
|
16
|
+
**Skip every pattern below this section** if the legacy app has exactly one `signalStore` with < 5 signals and < 3 methods. The full `AppStore` + `Ops` + tier ceremony is calibrated for multi-domain apps; on a one-store app it is pure overhead.
|
|
17
|
+
|
|
18
|
+
For a one-store app the closest 1:1 swap is **`defineStore()`** — the direct analog of `signalStore()`:
|
|
19
|
+
|
|
20
|
+
```ts skip
|
|
21
|
+
// Before (@ngrx/signals)
|
|
22
|
+
export const CounterStore = signalStore(
|
|
23
|
+
{ providedIn: 'root' },
|
|
24
|
+
withState({ count: 0 }),
|
|
25
|
+
withComputed(({ count }) => ({ double: computed(() => count() * 2) })),
|
|
26
|
+
withMethods((store) => ({ inc: () => patchState(store, (s) => ({ count: s.count + 1 })) }))
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
// After (@signaltree/core)
|
|
30
|
+
import { signalTree, defineStore } from '@signaltree/core';
|
|
31
|
+
import { computed } from '@angular/core';
|
|
32
|
+
|
|
33
|
+
export const CounterStore = defineStore(
|
|
34
|
+
() =>
|
|
35
|
+
signalTree({ count: 0 }).derived(($) => ({ double: computed(() => $.count() * 2) })),
|
|
36
|
+
{ providedIn: 'root' }
|
|
37
|
+
);
|
|
38
|
+
// inject(CounterStore) → the tree. Reads: store.$.count() / store.$.double().
|
|
39
|
+
// Writes: store.$.count.update((n) => n + 1) — no patchState, no withMethods.
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`inject(CounterStore)` resolves to the real tree (callable, full `$`/`.with()` API) and its `destroy()` is wired to the injector's `DestroyRef` — same lifecycle guarantee as SignalStore's `onDestroy`. Methods that don't fit inline can live on a thin `@Injectable` Ops service that injects the store, but for a small store the dot-path writes usually replace `withMethods` entirely.
|
|
43
|
+
|
|
44
|
+
If you prefer the token-based shape (or are about to grow into multi-domain), the explicit recipe is:
|
|
45
|
+
|
|
46
|
+
1. Replace the `signalStore({...}, withState(s), withMethods(m))` factory with `signalTree(s)` exported via an `APP_TREE` `InjectionToken` + `provideAppTree()` (or just `defineStore()` above).
|
|
47
|
+
2. Move each `withMethods` function onto a single `@Injectable({ providedIn: 'root' })` `AppOps` class that injects `APP_TREE`.
|
|
48
|
+
3. Replace every `inject(LegacyStore)` with `inject(APP_TREE)` (for reads) or `inject(AppOps)` (for writes). No `AppStore` facade needed.
|
|
49
|
+
4. Run the verifier (Step 6 below).
|
|
50
|
+
|
|
51
|
+
Go straight to [Definition of done](#definition-of-done). Patterns 1–5 below are about scaling architecture across domains — not relevant when there is only one.
|
|
52
|
+
|
|
53
|
+
## App shape audit (run before picking patterns)
|
|
54
|
+
|
|
55
|
+
The goal-state patterns below are **conditional**, not universal. Before applying any of them, answer these four questions about the legacy app and capture the answers in your scratch notes — each pattern explicitly references them:
|
|
56
|
+
|
|
57
|
+
1. **Are domains entity-collections or singletons?** A _collection_ domain has a list/map of records keyed by id (drivers, tickets, products). A _singleton_ domain has one record or a handful of scalars (app config, current user, theme, feature flags). Pattern #1 only applies to collection domains.
|
|
58
|
+
2. **Is there a typed error model in the codebase?** Search for an existing `Error`-shaped class, interface, or discriminated union (`grep -rln 'class .*Error\b\|interface .*Error\b' <app-src>/`). If yes, Pattern #1's `xError` sibling rule applies. If no, `status<string>()` alone is correct — do **not** invent an error type just to satisfy the pattern.
|
|
59
|
+
3. **Is there a cross-domain lifecycle?** Login/logout, multi-step wizard, tenant switch, anything that mutates ≥ 2 domain slices in one user action. If yes, Pattern #3 applies. If no, skip Pattern #3 entirely — a one-domain `Ops` calling itself is not orchestration.
|
|
60
|
+
4. **Is anything persisted to `localStorage` / `sessionStorage` / cookies?** If yes, Pattern #4 applies. If no, skip it.
|
|
61
|
+
|
|
62
|
+
The matrix:
|
|
63
|
+
|
|
64
|
+
| App shape | Patterns to apply |
|
|
65
|
+
| -------------------------------------------- | -------------------------- |
|
|
66
|
+
| Entity-CRUD multi-domain w/ session + errors | 1, 2, 3, 4, 5 (all) |
|
|
67
|
+
| Entity-CRUD multi-domain, no session | 1, 2, 4, 5 |
|
|
68
|
+
| Singleton/config-only app | 2 (if derivations), 4, 5 |
|
|
69
|
+
| Pure-state stores (no `withMethods`) | 1 (state shape only), 4 |
|
|
70
|
+
| Mixed (some entity, some singleton) | 1 for entity domains; 4, 5 |
|
|
71
|
+
|
|
72
|
+
Applying every pattern mechanically to a non-CRUD app produces an awkward shape (empty `selected` slice, derived tier files with one identity computation, `SessionOps` with one method that calls one other Ops). Match the patterns to the app's actual shape.
|
|
73
|
+
|
|
74
|
+
## Default: big-bang migration
|
|
75
|
+
|
|
76
|
+
**Big-bang is the default.** In one PR, you:
|
|
77
|
+
|
|
78
|
+
0. **Orient yourself in the codebase first.** Before touching any code, answer:
|
|
79
|
+
|
|
80
|
+
- **Where is the migrating app's source root?** (e.g. `src/app`, `apps/<name>/src`, `frontend/apps/<name>/src`). This is your `<app-src>` for every grep and the `--src` for the verifier script.
|
|
81
|
+
- **Where is the `package.json` that owns `@ngrx/signals`?** Workspace root in npm/single-app, app-local in pnpm workspaces, frontend-root in some monorepos. This is `--package-json`.
|
|
82
|
+
- **What are the build / test / lint commands?** Read `package.json` `scripts` and any `nx.json` / `angular.json` / `project.json`. These are `--build`, `--test`, `--lint`.
|
|
83
|
+
- **Is this a single app or a monorepo?** If sibling apps in the same `package.json` still import `@ngrx/signals`, you'll need `--allow-dep-presence` in step 6 and a tracking ticket for the dep removal.
|
|
84
|
+
- **Is `@angular-architects/ngrx-toolkit` (or any other `@ngrx/signals`-derivative package) also in use?** If yes, plan to remove it in step 5 and pass `--package` repeatedly in step 6.
|
|
85
|
+
- **Are there shared base classes built on `signalStore` / `signalStoreFeature`?** If yes, decide between the two coexistence strategies in [Hybrid adoption](#hybrid-adoption-fallback-path) before writing any code.
|
|
86
|
+
- **Is `@signaltree/core` already in the discovered `package.json`?** If not, install it before step 1 — see [`install.md`](./install.md). In a pnpm workspace either pass `-w` (root) or `--filter <pkg>` (specific package); plain `pnpm add @signaltree/core` at the workspace root will fail with `ERR_PNPM_ADDING_TO_ROOT`.
|
|
87
|
+
|
|
88
|
+
Capture these answers in your scratch notes — every subsequent step references them.
|
|
89
|
+
|
|
90
|
+
1. **Discover every legacy store and consumer.** The only universal anchor is the package itself — file names, class names, and folder paths vary by codebase. Use these greps regardless of layout:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
grep -rln "from '@ngrx/signals'" <app-src>/ # every consumer + every store file
|
|
94
|
+
grep -rln 'signalStore(' <app-src>/ # every store factory call site
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
The first list is your migration scope; the second list is the set of files you will delete.
|
|
98
|
+
|
|
99
|
+
2. Stand up the new shape (`AppStore` + `APP_TREE` + per-domain `Ops` + `app-tree.testing.ts`).
|
|
100
|
+
3. Migrate **every** consumer from the discovery list to `inject(AppStore)`.
|
|
101
|
+
4. **`rm` every file from the `signalStore(` list in the same commit.** Include each file's `*.spec.ts` sibling and any barrel/re-export that points at it. Standing up the new `AppStore` while leaving the legacy files on disk is _not_ a migration — it is a hybrid you forgot to finish, and it will rot.
|
|
102
|
+
5. Remove `@ngrx/signals` (and `@angular-architects/ngrx-toolkit` if used) from `package.json` `dependencies` and `peerDependencies`. Update the lockfile.
|
|
103
|
+
|
|
104
|
+
6. **Verify with [`scripts/verify-signaltree-migration.sh`](../../../../scripts/verify-signaltree-migration.sh).** This is the canonical "am I done?" gate. The script runs all three fingerprint checks (source-import grep, `signalStore(` grep, `package.json` parse) and then the build / test / lint gates in one invocation. It works on any layout and any package manager — you supply the commands:
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
scripts/verify-signaltree-migration.sh \
|
|
108
|
+
--src <app-src> \
|
|
109
|
+
--build "<your build command>" \
|
|
110
|
+
--test "<your test command>" \
|
|
111
|
+
--lint "<your lint command>" \
|
|
112
|
+
[--package-json <path-to-package.json>] \
|
|
113
|
+
[--package <legacy-pkg>] # repeatable; default: @ngrx/signals
|
|
114
|
+
[--allow-dep-presence] # only if another app in the same monorepo still needs the package
|
|
115
|
+
[--commit "feat(<app>): migrate to SignalTree"]
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
The script does **not** delete anything — that's your job in step 4 (you know which files you just replaced). Non-zero exit means the migration is not done; do not commit, do not write a report, do not declare success.
|
|
119
|
+
|
|
120
|
+
**If the script is unavailable** (downstream consumer without the `signaltree` repo checked out), run the equivalent checks manually:
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
grep -rln "from '@ngrx/signals'" <app-src>/ # must be empty
|
|
124
|
+
grep -rln 'signalStore(' <app-src>/ # must be empty
|
|
125
|
+
node -e "const p=require('./package.json'); \
|
|
126
|
+
['dependencies','peerDependencies'].forEach(k => \
|
|
127
|
+
console.assert(!p[k]?.['@ngrx/signals'], \
|
|
128
|
+
'@ngrx/signals still in '+k));"
|
|
129
|
+
# then your build / test / lint commands
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
#### Worked invocations (flex any shape)
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
# Nx + pnpm, single app or monorepo with this as the only ngrx app
|
|
136
|
+
scripts/verify-signaltree-migration.sh \
|
|
137
|
+
--src apps/<app>/src \
|
|
138
|
+
--build "pnpm nx build <app>" \
|
|
139
|
+
--test "pnpm nx test <app>" \
|
|
140
|
+
--lint "pnpm nx lint <app>" \
|
|
141
|
+
--package-json package.json
|
|
142
|
+
|
|
143
|
+
# Plain Angular CLI + npm
|
|
144
|
+
scripts/verify-signaltree-migration.sh \
|
|
145
|
+
--src src/app \
|
|
146
|
+
--build "npm run build" \
|
|
147
|
+
--test "npm test -- --watch=false" \
|
|
148
|
+
--lint "npm run lint"
|
|
149
|
+
|
|
150
|
+
# Monorepo where another app still uses @ngrx/signals (don't fail step 3)
|
|
151
|
+
scripts/verify-signaltree-migration.sh \
|
|
152
|
+
--src apps/<app>/src \
|
|
153
|
+
--build "yarn nx build <app>" \
|
|
154
|
+
--test "yarn nx test <app>" \
|
|
155
|
+
--lint "yarn nx lint <app>" \
|
|
156
|
+
--allow-dep-presence
|
|
157
|
+
|
|
158
|
+
# Incremental per-domain migration: only one of N stores migrated this PR.
|
|
159
|
+
# --src is narrowed to the new SignalTree foundation so Steps 1+2 still
|
|
160
|
+
# assert *that domain* is clean; --allow-source-presence + --allow-dep-presence
|
|
161
|
+
# allow the still-on-ngrx siblings to remain. See "Incremental per-domain
|
|
162
|
+
# migration" below for the full pattern.
|
|
163
|
+
scripts/verify-signaltree-migration.sh \
|
|
164
|
+
--src apps/<app>/src/app/store \
|
|
165
|
+
--build "pnpm nx build <app>" \
|
|
166
|
+
--test "pnpm nx test <app>" \
|
|
167
|
+
--lint "pnpm nx lint <app>" \
|
|
168
|
+
--allow-source-presence \
|
|
169
|
+
--allow-dep-presence
|
|
170
|
+
|
|
171
|
+
# Migrating off both @ngrx/signals AND @angular-architects/ngrx-toolkit
|
|
172
|
+
scripts/verify-signaltree-migration.sh \
|
|
173
|
+
--src src/app \
|
|
174
|
+
--build "npm run build" \
|
|
175
|
+
--test "npm test -- --watch=false" \
|
|
176
|
+
--lint "npm run lint" \
|
|
177
|
+
--package @ngrx/signals \
|
|
178
|
+
--package @angular-architects/ngrx-toolkit
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
The end-state is one mental model in the codebase, no adapter cruft, no "temporary" facades that live forever. See [`optimal-implementation.md`](./optimal-implementation.md#definition-of-done) for the full Definition-of-Done checklist.
|
|
182
|
+
|
|
183
|
+
**The hybrid-facade pattern is a fallback**, not the recommended path. Use it only when one of the constraints in [`optimal-implementation.md`](./optimal-implementation.md#when-the-hybrid-pattern-is-acceptable) genuinely applies, and ship it with a deletion deadline.
|
|
184
|
+
|
|
185
|
+
## Incremental per-domain migration
|
|
186
|
+
|
|
187
|
+
**Use this when big-bang is impractical and hybrid (permanent coexistence) is overkill** — typically: an app has ≥3 `signalStore`s, you want each migration in its own reviewable PR, but the end-state is still one tree. Each PR migrates exactly one domain and leaves the rest untouched on `@ngrx/signals`. The legacy package stays installed until the **last** domain migrates.
|
|
188
|
+
|
|
189
|
+
When to pick incremental over big-bang or hybrid:
|
|
190
|
+
|
|
191
|
+
| Shape | Pick |
|
|
192
|
+
| ------------------------------------------------------------- | ---------------------------------- |
|
|
193
|
+
| 1–2 stores, all owned by one team | **big-bang** (one PR) |
|
|
194
|
+
| ≥3 stores, single team, no shared `signalStoreFeature` base | **incremental** (one PR per store) |
|
|
195
|
+
| Shared `signalStoreFeature` base classes blocking partial cut | **hybrid** (legacy facade) |
|
|
196
|
+
| Multi-team consumer cutover constraint | **hybrid** (legacy facade) |
|
|
197
|
+
|
|
198
|
+
### Phase 0: foundation-only first PR (recommended)
|
|
199
|
+
|
|
200
|
+
The first PR should add the foundation **without importing it from any consumer**. The legacy stores stay; the new files are dead code from the runtime's perspective. This PR's only job is to prove that pulling `@signaltree/core` into the dependency graph keeps `build`, `test`, and `lint` green.
|
|
201
|
+
|
|
202
|
+
**Required foundation file set** (every one of these — missing any of them is a skill violation, not a stylistic choice):
|
|
203
|
+
|
|
204
|
+
```
|
|
205
|
+
src/app/store/
|
|
206
|
+
├── app-store.ts # @Injectable AppStore facade — the ONLY thing consumers inject
|
|
207
|
+
├── index.ts # barrel: export AppStore + types only (never APP_TREE/Ops)
|
|
208
|
+
├── ops/
|
|
209
|
+
│ ├── index.ts # barrel
|
|
210
|
+
│ ├── <first-domain>.ops.ts # @Injectable XOps service
|
|
211
|
+
│ └── <first-domain>.ops.spec.ts # XOps spec — required for Phase 0 to count as foundation
|
|
212
|
+
└── tree/
|
|
213
|
+
├── app-tree.ts # APP_TREE InjectionToken + createAppTree() + provideAppTree()
|
|
214
|
+
├── app-tree.spec.ts # asserts shape of $.<domain>, presence of derived tiers
|
|
215
|
+
├── state/
|
|
216
|
+
│ ├── index.ts # barrel
|
|
217
|
+
│ ├── selection.state.ts # ROOT selection slice (see Goal pattern #1) — even if first domain only writes one <x>Id
|
|
218
|
+
│ └── <first-domain>.state.ts # domain factory: entityMap + xLoad + xError, NO selection inside
|
|
219
|
+
└── derived/
|
|
220
|
+
├── index.ts # barrel
|
|
221
|
+
└── tier-entity-resolution.derived.ts # Tier 1: id → entity (always exists, even with one domain)
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
The foundation MUST also include a test helper for downstream PRs:
|
|
225
|
+
|
|
226
|
+
```
|
|
227
|
+
src/app/testing/
|
|
228
|
+
└── provide-app-tree-for-testing.ts # provideAppTreeForTesting(seed?) — used by every spec from PR 2 onward
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
Why this matters: if any test transitively injects the new `AppStore` (even via a route guard that's exercised in a fixture), the failure mode is a `NG0201` cascade through every `Ops` constructor — and you won't know whether the regression came from the foundation or from a consumer rewrite. Phase 0 isolates that risk.
|
|
232
|
+
|
|
233
|
+
Verifier invocation for Phase 0 is the same as Step 6 below (with `--allow-source-presence --allow-dep-presence`); the `--src` points at the new foundation dir and asserts it is clean ESM with no legacy imports.
|
|
234
|
+
|
|
235
|
+
Once Phase 0 is merged, subsequent PRs follow the per-PR workflow.
|
|
236
|
+
|
|
237
|
+
### Per-PR workflow (one domain at a time)
|
|
238
|
+
|
|
239
|
+
Everything from the big-bang Step 0 (orientation) still applies. The differences:
|
|
240
|
+
|
|
241
|
+
1. **Step 1 grep is scoped to the one store you're migrating**, not `<app-src>`. The whole-app grep would list every still-on-ngrx sibling and drown the signal.
|
|
242
|
+
```bash
|
|
243
|
+
grep -rln "<store-file-basename>" <app-src>/ # consumers of just this store
|
|
244
|
+
```
|
|
245
|
+
2. **Step 2 — stand up `AppStore` + `APP_TREE` once, on the first PR.** Subsequent PRs add domain slices to the existing tree. The first PR's `AppStore` exposes only the migrated domain; that's expected, not a smell.
|
|
246
|
+
3. **Step 3 — migrate the consumers of _this_ store only.** Other consumers stay on their respective legacy `signalStore`s. If a consumer needs both (transitional state), it injects both — acceptable for one PR, must be cleaned up by the next.
|
|
247
|
+
4. **Step 4 — `rm` the migrated store + its spec + any barrel that re-exports it.** Do **not** ship a legacy facade adapter unless one of the [hybrid constraints](#hybrid-adoption-fallback-path) applies, **or** the migrated domain has ≥ ~10 call sites of the form `_store.<domain>.X` through a shared aggregator object (a permitted, time-boxed exception — see below). Incremental is _not_ hybrid — the migrated store goes away in the same PR; it's only the _other_ stores that remain.
|
|
248
|
+
|
|
249
|
+
**Time-boxed aggregator-facade exception.** If consumers reach the legacy store through a shared aggregator (e.g. `_store.drivers.currentDriver()` everywhere instead of `inject(DriverStore)`), rewriting all call sites in the same PR can balloon the diff past reviewable size. In that case it is acceptable to keep the aggregator slot (`store.drivers`) backed by a thin adapter object that delegates to `AppStore` — provided you (a) delete `driver.store.ts` and `driver.store.spec.ts` in the same PR, (b) tag the adapter with `// TODO(legacy-facade): remove by <date/release>`, and (c) open a tracking issue for the call-site sweep. The legacy `signalStore` factory must not survive this PR; only the typed object literal that exposes the same field shape may remain.
|
|
250
|
+
|
|
251
|
+
**Sequencing rule (do not violate):** Migrate every consumer of the store **first**, run `build` + `test` to confirm green, **then** delete the legacy store / spec / aggregator slot in the same PR. Removing the aggregator slot before the consumer rewrites lands turns every still-broken consumer into a `TS2339` build error and forces a full revert. If you cannot finish all consumers in one PR, leave the legacy store in place and ship the partial work as a Phase 0-style additive PR.
|
|
252
|
+
|
|
253
|
+
5. **Step 5 — do NOT remove `@ngrx/signals` from `package.json`.** Other domains still need it. The dep removal happens automatically in the final domain's PR.
|
|
254
|
+
6. **Step 6 — verifier invocation, narrowed and permissive:**
|
|
255
|
+
```bash
|
|
256
|
+
scripts/verify-signaltree-migration.sh \
|
|
257
|
+
--src <app-src>/<new-foundation-dir> # e.g. apps/<app>/src/app/store
|
|
258
|
+
--build "<your build command>" \
|
|
259
|
+
--test "<your test command>" \
|
|
260
|
+
--lint "<your lint command>" \
|
|
261
|
+
--package-json <path-to-package.json> \
|
|
262
|
+
--allow-source-presence \
|
|
263
|
+
--allow-dep-presence
|
|
264
|
+
```
|
|
265
|
+
- `--src <new-foundation-dir>` asserts the _new_ SignalTree code is clean.
|
|
266
|
+
- `--allow-source-presence` tolerates the leftover `@ngrx/signals` imports in still-on-ngrx siblings.
|
|
267
|
+
- `--allow-dep-presence` tolerates the leftover entry in `package.json`.
|
|
268
|
+
- When you migrate the _last_ domain, drop both `--allow-*-presence` flags and widen `--src` to the full `<app-src>` — that PR has to pass the strict big-bang verification.
|
|
269
|
+
|
|
270
|
+
### Cross-store reads from new `Ops` to a still-ngrx store
|
|
271
|
+
|
|
272
|
+
An `Ops` class can inject a still-on-ngrx `signalStore` directly — it is just an Angular service. Read its public signals or observables; do **not** call `patchState` on it from your `Ops`. When that store later migrates, only the import in your `Ops` (and the seed in tests) changes.
|
|
273
|
+
|
|
274
|
+
```ts skip
|
|
275
|
+
@Injectable({ providedIn: 'root' })
|
|
276
|
+
class FooOps {
|
|
277
|
+
private readonly _$ = inject(APP_TREE).$.foo;
|
|
278
|
+
private readonly _legacy = inject(LegacySettingsStore); // still on @ngrx/signals
|
|
279
|
+
private readonly _fooService = inject(FooService);
|
|
280
|
+
|
|
281
|
+
loadAll$(filter?: FooFilter): Observable<void> {
|
|
282
|
+
return this._legacy.scope$.pipe(
|
|
283
|
+
// legacy store's public observable
|
|
284
|
+
filterNullish$,
|
|
285
|
+
switchMap((scope) => this._fooService.loadAll$({ ...filter, scopeUrl: scope.url })),
|
|
286
|
+
tap((items) => this._$.entities.setAll(items)),
|
|
287
|
+
map(() => void 0),
|
|
288
|
+
take(1)
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
The inverse direction — a still-on-ngrx store reading from `AppStore` — also works (`AppStore` is an Angular service too) and needs no special wiring.
|
|
295
|
+
|
|
296
|
+
### Root-injected `Ops` side-effect hazard
|
|
297
|
+
|
|
298
|
+
`Ops` classes are normally `@Injectable({ providedIn: 'root' })` and `AppStore` injects them eagerly. Any constructor work an `Ops` does — subscribing to a refresher, opening a websocket, calling `inject(SomeStore)` that itself injects an HTTP/DB service — runs **the moment any test injects `AppStore`**. In a partially-migrated app this manifests as `NG0201` cascades through transitive dependencies (e.g. `AppStore -> FooOps -> LegacySettingsStore -> SomeDbService`) in tests that previously had nothing to do with the new store.
|
|
299
|
+
|
|
300
|
+
Three safe patterns (pick one):
|
|
301
|
+
|
|
302
|
+
- **Lazy initialization.** Move constructor side-effects (subscriptions, refresher hookups) into a `start()` method called by the consumer that needs them, or guard them behind a feature flag. Never subscribe in the constructor of a `providedIn: 'root'` `Ops`.
|
|
303
|
+
- **Scope `Ops` to consumers.** Drop `providedIn: 'root'` and let consumers `provide` the `Ops` in a route-level or component-level injector. `AppStore` then exposes a typed `inject<FooOps>(FooOps)` accessor inside a component-scoped factory, not as an eager root-singleton.
|
|
304
|
+
- **Optional collaborators + lazy `Injector.get` in `AppStore`.** Keep `Ops` `providedIn: 'root'` but make every side-effect collaborator `inject(X, { optional: true })` and resolve `Ops` from `AppStore` via `inject(Injector).get(FooOps)` inside the facade method, not as a field. This is the lowest-churn option for a partially-migrated app where unrelated specs only read `tree.$` signals — they never instantiate the `Ops`, so missing collaborators never throw. Required pattern when the migrated `Ops` has ≥ 2 side-effect collaborators (refresher, telemetry, banners, websocket, etc.).
|
|
305
|
+
|
|
306
|
+
```ts skip
|
|
307
|
+
// foo.ops.ts — collaborators optional
|
|
308
|
+
@Injectable({ providedIn: 'root' })
|
|
309
|
+
export class FooOps {
|
|
310
|
+
private readonly _refresher = inject(RefresherService, { optional: true });
|
|
311
|
+
private readonly _telemetry = inject(TelemetryService, { optional: true });
|
|
312
|
+
// …
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// app-store.ts — lazy resolve
|
|
316
|
+
@Injectable({ providedIn: 'root' })
|
|
317
|
+
export class AppStore {
|
|
318
|
+
readonly $ = inject(APP_TREE).$;
|
|
319
|
+
private readonly _injector = inject(Injector);
|
|
320
|
+
readonly ops = {
|
|
321
|
+
foo: {
|
|
322
|
+
loadFoo$: () => this._injector.get(FooOps).loadFoo$(),
|
|
323
|
+
},
|
|
324
|
+
} as const;
|
|
325
|
+
}
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
All three patterns keep Phase 0 from regressing unrelated tests when the foundation lands.
|
|
329
|
+
|
|
330
|
+
**If you choose to fix the cascade in test files instead** (acceptable for a single-PR consumer migration), add the missing transitive provider to each affected legacy spec. **Verify each helper actually exists before importing it** — `grep -rn "export function <providerName>" <app-src>/testing/` (and any shared test-utils package). Hallucinated helper names are the most common follow-up failure mode of this fix.
|
|
331
|
+
|
|
332
|
+
**While you're in those spec files, sweep dead `providers: [LegacyStoreName]` entries.** Many specs list the legacy store in their `providers` array but never inject it from the SUT — leftover noise from earlier refactors. Migration is the right time to delete them; they will fail loudly if anything actually depended on them, and silently shrink the diff if not. Quick audit:
|
|
333
|
+
|
|
334
|
+
```bash
|
|
335
|
+
grep -rln "providers:.*<LegacyStoreName>" <app-src>/ | xargs -I {} grep -L "inject(<LegacyStoreName>)\|TestBed.inject(<LegacyStoreName>)" {}
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
Files in the output list the provider but never inject it — safe to strip.
|
|
339
|
+
|
|
340
|
+
### Co-locating the new foundation with a legacy `store/` directory
|
|
341
|
+
|
|
342
|
+
If the app already has a `store/` folder for legacy `signalStore`s (common when the team named it that), do **not** put the new SignalTree foundation in the same directory. Two options:
|
|
343
|
+
|
|
344
|
+
- Rename the legacy folder to `legacy-store/` (or `root-services/store/` if that already matches the codebase) in a separate prep commit, then put the new foundation at `src/app/store/`.
|
|
345
|
+
- Put the new foundation under a sibling name like `src/app/app-tree/` and keep the legacy folder where it is.
|
|
346
|
+
|
|
347
|
+
The verifier script doesn't care; pick whichever causes fewer import churn lines.
|
|
348
|
+
|
|
349
|
+
## Hybrid adoption (fallback path)
|
|
350
|
+
|
|
351
|
+
If you genuinely cannot land the migration in one PR — the diff is too large to review, multiple teams own consumers, the release cadence forces a coexistence window, or rollback risk demands it — stand up the new shape and use legacy-facade adapters as **temporary scaffolding**:
|
|
352
|
+
|
|
353
|
+
- **`@signaltree/core@9.2.0`+ no longer ships the global `declare module '@angular/core'` augmentation that previously activated callable overloads on every `WritableSignal<T>`.** Earlier versions (≤ 9.1.0) made the augmentation unconditional on any `core` import, which made `WritableSignal<T>` invariance-incompatible with `@ngrx/signals`' `WritableStateSource<T>`. Symptom on the older versions: ~30 `TS2345` errors in `@ngrx/signals` features the moment any consumer in the same `tsconfig` graph imported from `@signaltree/core`. Upgrade to `^9.2.0` before attempting hybrid adoption.
|
|
354
|
+
- The reverse (a SignalTree consumer that now wants the callable form on raw `WritableSignal<T>`) opts in via `import '@signaltree/callable-syntax/augmentation'` or by listing `@signaltree/callable-syntax` in `tsconfig.compilerOptions.types`. See [`install.md`](./install.md#signaltreecallable-syntax).
|
|
355
|
+
- **Shared `@ngrx/signals`-based base classes do not have to be migrated to SignalTree.** Two valid coexistence strategies:
|
|
356
|
+
1. **Replace shared base classes with vanilla Angular signals** (`signal()` / `computed()` / `effect()`) and migrate per-app stores to SignalTree at your own pace. The base classes lose the `signalStoreFeature` composition story but keep API parity with consumers.
|
|
357
|
+
2. **Keep `@ngrx/signals` in place for the legacy slice; introduce SignalTree alongside as the new canonical store.** With ≥ `9.2.0` this works without typecheck conflicts. Components migrate one at a time from `inject(LegacyStore)` to `inject(AppStore)`.
|
|
358
|
+
- Whichever strategy you pick, **do not introduce a partial SignalTree by per-domain `signalTree()` instances**. The single-tree rule below still applies — the AppStore facade should compose every SignalTree-managed domain into one tree even when other domains are still on the legacy store.
|
|
359
|
+
|
|
360
|
+
## Critical: one tree for all domains
|
|
361
|
+
|
|
362
|
+
**Do not create one `signalTree()` per ngrx store.** The entire application — every domain that had its own `signalStore` — must be composed into a single `signalTree()` call behind one `APP_TREE` `InjectionToken`, exposed through a single `AppStore` service.
|
|
363
|
+
|
|
364
|
+
```ts skip
|
|
365
|
+
// ngrx — FooStore, BarStore, BazStore, QuxStore each call signalStore()
|
|
366
|
+
// WRONG in SignalTree — do not do this:
|
|
367
|
+
// const fooTree = signalTree(fooState()); // ✗
|
|
368
|
+
// const barTree = signalTree(barState()); // ✗
|
|
369
|
+
|
|
370
|
+
// CORRECT — all domains in one tree:
|
|
371
|
+
const tree = signalTree({
|
|
372
|
+
foo: fooState(),
|
|
373
|
+
bar: barState(),
|
|
374
|
+
baz: bazState(),
|
|
375
|
+
qux: quxState(),
|
|
376
|
+
});
|
|
377
|
+
export const APP_TREE = new InjectionToken<typeof tree>('APP_TREE');
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
Each ngrx `signalStore` becomes a **domain slice** in the single tree, not its own tree. Its methods become an `Ops` class. Components never inject an Ops class or `APP_TREE` — they inject only `AppStore`.
|
|
381
|
+
|
|
382
|
+
See `reference/patterns.md` for the full `APP_TREE` + `AppStore` + `Ops` wiring before writing any store code.
|
|
383
|
+
|
|
384
|
+
## Concept map
|
|
385
|
+
|
|
386
|
+
| ngrx/signals | SignalTree equivalent |
|
|
387
|
+
| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
388
|
+
| `signalStore(...)` | Domain slice in the single `signalTree()` + an `Ops` class for its methods |
|
|
389
|
+
| `withState({ a, b })` | Initial state object passed to `signalTree()` |
|
|
390
|
+
| `withMethods(({ ... }) => ({ ... }))` | Methods on an `Ops` class that injects `APP_TREE` |
|
|
391
|
+
| `withComputed(({ ... }) => ({ ... }))` | Angular `computed()` on the component or in `.derived()` on the tree |
|
|
392
|
+
| `withHooks({ onInit })` | Constructor body of the service / `APP_TREE` factory |
|
|
393
|
+
| `withProps(({ ... }) => ({ ... }))` | Plain `readonly` fields on the `Ops` class (or `AppStore`); no signal magic needed |
|
|
394
|
+
| `rxMethod(pipe(...))` | **Preferred:** `asyncSource(config)` or `asyncQuery(config)` marker at the tree path the data lives at (auto status wiring, no `tap()` ceremony). **Fallback:** plain method returning `Observable<void>`; writes via `tap()`. SignalTree does NOT ship a `rxMethod` primitive — see `## rxMethod` section below for the two-option breakdown. |
|
|
395
|
+
| `patchState(store, { a, b })` | `tree.$.a.set(a); tree.$.b.set(b)` for individual leaves, or `tree.$.domain({ a, b })` / `tree.$.domain((s) => ({ ...s, a, b }))` for a nested patch — branches are natively callable; there is no `.update()` method on branch nodes, and branch writes are always deep-merge partials |
|
|
396
|
+
| `getState(store)` | `tree()` (whole-tree snapshot) or `tree.$.domain()` (one slice) — call the tree / node with no args to read the current plain value |
|
|
397
|
+
| `signalState({ ... })` (standalone) | `signalTree({ ... })` — `signalState` was the state-only primitive; `signalTree` is the equivalent baseline |
|
|
398
|
+
| `withEntities<T>()` | `entityMap<T, K>()` marker |
|
|
399
|
+
| `store.entities()` | `tree.$.items.all()` |
|
|
400
|
+
| `store.entityMap()[id]` | `tree.$.items.byId(id)?.()` — `byId(id)` returns `EntityNode<E> \| undefined` (a callable cursor with per-field signals), invoke the result to read the entity value |
|
|
401
|
+
| `store.entityMap()` (whole `Record<K, T>`) | `tree.$.items.map()` returns a `Signal<ReadonlyMap<K, T>>`. Bracket access (`m[id]`) becomes `m.get(id)`; for a `Record`-shaped consumer derive via `computed(() => Object.fromEntries(tree.$.items.map()))` |
|
|
402
|
+
| `addEntity(e)` | `tree.$.items.addOne(e)` |
|
|
403
|
+
| `setAllEntities(es)` | `tree.$.items.setAll(es)` |
|
|
404
|
+
| `updateEntity({ id, changes })` | `tree.$.items.updateOne(id, changes)` |
|
|
405
|
+
| `removeEntity(id)` | `tree.$.items.removeOne(id)` |
|
|
406
|
+
| `provideDevtoolsConfig({ name })` in providers | `.with(devTools({ treeName: name }))` on the tree — remove the provider |
|
|
407
|
+
|
|
408
|
+
## Goal-state architectural patterns
|
|
409
|
+
|
|
410
|
+
The concept map above keeps the build green. These five patterns are what separates a passing migration from one that lands at the **right end-state for a multi-domain entity-CRUD app**. They are **conditional** — each applies only when the [App shape audit](#app-shape-audit-run-before-picking-patterns) says it does. Applying them mechanically to a non-CRUD app produces a worse shape than vanilla SignalTree, not a better one.
|
|
411
|
+
|
|
412
|
+
A migration that ignores patterns _that do apply_ produces a SignalTree shaped like the old `signalStore` graph (one slice per old store, single-`currentX` signals, eager loads, no derived tiers) — green but architecturally thin. Apply on first migration; retrofitting later is significantly more churn.
|
|
413
|
+
|
|
414
|
+
### 1. `currentX: XDto` ngrx signal → `entityMap` + root `selected.<id>` + derived current
|
|
415
|
+
|
|
416
|
+
**Applies when:** the domain is a _collection_ per audit Q1 — multiple records of the same shape with a stable key field. Skip entirely for singleton domains; port them as plain leaves (or `stored()` slots, see Pattern #4).
|
|
417
|
+
|
|
418
|
+
When the ngrx store has a `currentFoo: FooDto` (or `selectedBar`, `activeQux`) signal **and `Foo` has a stable key field**, do not port it as a single signal in the new state. The "current" is one of many — model it that way, and put **selection at the _root_ of the tree**, not inside the domain slice. Selection is inherently cross-domain (FooOps writes `selected.fooId`, BarOps writes `selected.barId`, derived tiers read both); putting it under `foo.selected` blocks that pattern and forces awkward `$.foo.selected.fooId()` reads.
|
|
419
|
+
|
|
420
|
+
```ts
|
|
421
|
+
// state/driver.state.ts — domain entities + load state ONLY. No selection.
|
|
422
|
+
import { entityMap, status } from '@signaltree/core';
|
|
423
|
+
// import your app's DTOs and typed-error / Nullable aliases from wherever they live
|
|
424
|
+
// e.g. import type { DriverDto } from '<your-models>';
|
|
425
|
+
// import type { AppError, Nullable } from '<your-models>';
|
|
426
|
+
|
|
427
|
+
export function driverState() {
|
|
428
|
+
return {
|
|
429
|
+
drivers: entityMap<DriverDto, number>(),
|
|
430
|
+
driversLoad: status<string>(),
|
|
431
|
+
driversError: null as Nullable<AppError>, // typed sibling — see rule below
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// state/selection.state.ts — root-level selection slice, ONE per app
|
|
436
|
+
export function selectionState() {
|
|
437
|
+
return {
|
|
438
|
+
driverId: null as Nullable<number>,
|
|
439
|
+
truckId: null as Nullable<number>,
|
|
440
|
+
haulerId: null as Nullable<number>,
|
|
441
|
+
// ...add more <x>Id scalars as new domains migrate; never split this slice
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// app-tree.ts — selection is a TOP-LEVEL slice, sibling to domains
|
|
446
|
+
export function createBaseState() {
|
|
447
|
+
return {
|
|
448
|
+
driver: driverState(),
|
|
449
|
+
selected: selectionState(), // <-- root, not driver.selected
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// derived/tier-entity-resolution.derived.ts — current entity is *derived*
|
|
454
|
+
import { computed } from '@angular/core';
|
|
455
|
+
import { derivedFrom } from '@signaltree/core';
|
|
456
|
+
import type { AppTreeBase } from '../app-tree';
|
|
457
|
+
|
|
458
|
+
export const entityResolutionDerived = derivedFrom<AppTreeBase>()(($) => ({
|
|
459
|
+
driver: {
|
|
460
|
+
current: computed(() => {
|
|
461
|
+
const id = $.selected.driverId(); // <-- root selection
|
|
462
|
+
return id != null ? $.driver.drivers.byId(id)?.() ?? null : null;
|
|
463
|
+
}),
|
|
464
|
+
},
|
|
465
|
+
}));
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
This unlocks `upsertOne`, `removeOne`, `byId`, `setAll` for the entity collection — operations the single-signal port permanently loses access to.
|
|
469
|
+
|
|
470
|
+
**Hard rules (when this pattern applies):**
|
|
471
|
+
|
|
472
|
+
- If the ngrx signal type is `XDto` or `Nullable<XDto>` and `X` has a stable key field, port it as `entityMap<X, K>()` (in the domain slice) plus a `<key>: Nullable<K>` scalar (in the root `selected` slice). The selected-key field name should match the natural key (`fooId`, `barUuid`, `quxSlug`) — not force-fit `<x>Id` if the natural key is a string/uuid/composite.
|
|
473
|
+
- For **composite keys** (e.g. `{ tenant, id }`), use `entityMap<X, string>()` with a stringified composite as K, and store the same composite-string in `selected`. SignalTree's `entityMap` only supports primitive K.
|
|
474
|
+
- `selected` is **always a top-level slice of the tree** when used, never nested inside a domain. The derived tier reads `$.selected.<key>()` and `$.<domain>.<entityMap>.byId(...)` — both at root.
|
|
475
|
+
- **Skip the root `selected` slice entirely** if no domain in the app has a "current/selected" concept. An empty `selected: {}` slice is noise.
|
|
476
|
+
- Load + error: see **Typed error sibling rule** below — also conditional.
|
|
477
|
+
|
|
478
|
+
**Typed error sibling rule (conditional on audit Q2):**
|
|
479
|
+
|
|
480
|
+
- **If the app has a typed error model** (existing class/interface/discriminated union): the domain slice has **two siblings** — `xLoad: status<string>()` for the state machine _and_ `xError: Nullable<AppError>` for the typed payload (so consumers don't lose typed error info to the `string`-only payload of `status<T>().setError(msg)`). Ops set both: `$.x.xLoad.setError(String(err)); $.x.xError.set(toAppError(err));`. `status<AppError>()` is **not** a substitute — its API is state-machine, not error-storage.
|
|
481
|
+
- **If the app has no typed error model**: `xLoad: status<string>()` alone is correct. Do **not** invent an error type just to satisfy the pattern — the cost (new type, mapping function, every `Ops` learns to use it) outweighs the benefit until the app actually needs structured errors.
|
|
482
|
+
|
|
483
|
+
### 2. Materialize derivations inside the tree with `derivedFrom` + `.derived()`
|
|
484
|
+
|
|
485
|
+
**Applies when:** the legacy app has ≥ 1 `withComputed` derivation that is read from ≥ 2 consumers. Skip when every derivation is consumer-local (one component reads it once) — a component-level `computed()` is correct in that case.
|
|
486
|
+
|
|
487
|
+
The naive port of `withComputed(({ ... }) => ({ isExternal: computed(...) }))` is a component-level `computed()`. That works but forfeits SignalTree's strongest feature when reuse exists: **typed derived tiers composed into the tree itself**, reusable from any consumer without reimplementation.
|
|
488
|
+
|
|
489
|
+
```ts
|
|
490
|
+
// app-tree.ts
|
|
491
|
+
import { signalTree, type WithDerived } from '@signaltree/core';
|
|
492
|
+
import { entityResolutionDerived } from './derived/tier-entity-resolution.derived';
|
|
493
|
+
import { complexLogicDerived } from './derived/tier-complex-logic.derived';
|
|
494
|
+
|
|
495
|
+
export type AppTreeBase = ReturnType<typeof signalTree<ReturnType<typeof createBaseState>>>;
|
|
496
|
+
export type AppTreeWithEntityResolution = WithDerived<AppTreeBase, typeof entityResolutionDerived>;
|
|
497
|
+
export type AppTreeWithComplexLogic = WithDerived<AppTreeWithEntityResolution, typeof complexLogicDerived>;
|
|
498
|
+
export type AppTree = AppTreeWithComplexLogic;
|
|
499
|
+
|
|
500
|
+
export function createAppTree(initial: {
|
|
501
|
+
/* ... */
|
|
502
|
+
}) {
|
|
503
|
+
return signalTree(createBaseState(initial))
|
|
504
|
+
.with(devTools({ treeName: 'AppTree' }))
|
|
505
|
+
.with(batching())
|
|
506
|
+
.derived(entityResolutionDerived) // tier 1: id → entity
|
|
507
|
+
.derived(complexLogicDerived); // tier 2+: depends on tier 1
|
|
508
|
+
}
|
|
509
|
+
```
|
|
510
|
+
|
|
511
|
+
**When to add a derived tier:** any `computed()` that ≥ 2 components, services, or specs would otherwise reimplement. Common tiers: entity resolution (id → entity), workflow state (current record's status, active step), UI aggregates (counts, badge text), navigation guards.
|
|
512
|
+
|
|
513
|
+
**When NOT to add a derived tier:**
|
|
514
|
+
|
|
515
|
+
- One-shot derivations consumed by a single component — use a component-local `computed()`.
|
|
516
|
+
- Pure value transforms with no state read (e.g. `formatDate`) — use a plain function.
|
|
517
|
+
- Derivations that depend on injected services as well as tree state — put them on the consuming service or use `derivedFrom` at the call site, not in a tier (tiers only see `$`).
|
|
518
|
+
|
|
519
|
+
**Tiering rule:** later tiers may read earlier-tier derivations via `$.<earlierTier>.…`; never the reverse. Each tier gets its own `tier-<name>.derived.ts` file and a typed `AppTreeWith<Name>` alias so derived tiers themselves are type-safe.
|
|
520
|
+
|
|
521
|
+
### 3. Cross-domain orchestration → dedicated `<purpose>Ops`, not domain-Ops
|
|
522
|
+
|
|
523
|
+
**Applies when:** audit Q3 returned yes — the app has a real cross-domain lifecycle (login/logout, wizard, tenant switch). Skip entirely otherwise. A one-domain `Ops` calling its own slice's writes is not orchestration; do not invent a `SessionOps` that wraps one method on one collaborator.
|
|
524
|
+
|
|
525
|
+
When a migration needs to coordinate two or more domains (e.g. end-of-session clears qux + bar + selection; start-of-session hydrates config + foo + baz), **do not put the orchestration inside one of the domain `Ops`**. Stand up a dedicated `<purpose>Ops` class — the name reflects the lifecycle, not necessarily "Session":
|
|
526
|
+
|
|
527
|
+
```ts
|
|
528
|
+
// session.ops.ts
|
|
529
|
+
import { inject, Injectable } from '@angular/core';
|
|
530
|
+
import { SelectionOps } from './selection.ops';
|
|
531
|
+
import { TicketOps } from './ticket.ops';
|
|
532
|
+
import { TruckOps } from './truck.ops';
|
|
533
|
+
|
|
534
|
+
@Injectable({ providedIn: 'root' })
|
|
535
|
+
export class SessionOps {
|
|
536
|
+
private readonly _tickets = inject(TicketOps);
|
|
537
|
+
private readonly _trucks = inject(TruckOps);
|
|
538
|
+
private readonly _selection = inject(SelectionOps);
|
|
539
|
+
|
|
540
|
+
/** Reset all session-scoped state. Order matters: clear active ticket first
|
|
541
|
+
* so any UI bound to it sees null before driver/selection are cleared. */
|
|
542
|
+
endSession(): void {
|
|
543
|
+
this._tickets.clearActiveTicket();
|
|
544
|
+
this._trucks.clearAllTrucksAndHaulers();
|
|
545
|
+
this._selection.clearAll();
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
```
|
|
549
|
+
|
|
550
|
+
Equivalent pattern for one-shot atomic mutations of `selected.*` (when Pattern #1 applies): a dedicated `SelectionOps` owns every write to `$.selected.*` so the rest of the codebase has a single, lintable boundary. The domain `Ops` (FooOps, BarOps) own their own slice's writes — never another slice's.
|
|
551
|
+
|
|
552
|
+
**Smell to refactor:** any `Ops` class that injects ≥ 2 other `Ops` classes is doing orchestration; extract to its own `<purpose>Ops`.
|
|
553
|
+
|
|
554
|
+
### 4. Persisted state → `stored()` slices, not constructor `localStorage` reads
|
|
555
|
+
|
|
556
|
+
**Applies when:** audit Q4 returned yes — the legacy app reads/writes `localStorage`/`sessionStorage`/cookies for any piece of state. Skip otherwise.
|
|
557
|
+
|
|
558
|
+
Baseline ngrx stores typically read `localStorage` in `withHooks({ onInit })` and write back via a service. SignalTree's `stored()` marker handles both directions reactively:
|
|
559
|
+
|
|
560
|
+
```ts
|
|
561
|
+
// settings.state.ts
|
|
562
|
+
import { stored } from '@signaltree/core';
|
|
563
|
+
// import your Nullable<T> alias (or inline `T | null`)
|
|
564
|
+
|
|
565
|
+
const K = '<your-app-prefix>-settings-'; // namespace storage keys per app
|
|
566
|
+
|
|
567
|
+
export function settingsState() {
|
|
568
|
+
return {
|
|
569
|
+
regionId: stored(`${K}regionId`, null as Nullable<number>),
|
|
570
|
+
tenantId: stored(`${K}tenantId`, null as Nullable<number>),
|
|
571
|
+
measurementSystem: stored(`${K}measurementSystem`, null as Nullable<string>),
|
|
572
|
+
permissionsRequested: stored(`${K}permissionsRequested`, false),
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
```
|
|
576
|
+
|
|
577
|
+
**Migration recipe:** every `localStorage.getItem('key')` / `setItem('key', …)` call site that wraps a piece of state becomes a `stored('key', defaultValue)` slot. Delete the `LocalStorageService.set(key, …)` calls in `Ops` — `stored()` writes through automatically on signal mutation.
|
|
578
|
+
|
|
579
|
+
**When `stored()` doesn't fit:** initial values that depend on other injection (e.g. environment-keyed storage keys like `<x>Id-${env.targetEnvironment}`). For those, use the `AppTreeService` pattern: a `providedIn: 'root'` service that reads your storage service in its constructor and passes the seed values to `createAppTree(seed)` once.
|
|
580
|
+
|
|
581
|
+
### 5. `Ops` injection in `AppStore` → eager by default, lazy only for incremental
|
|
582
|
+
|
|
583
|
+
**Applies when:** the legacy store has ≥ 1 `withMethods` function. Skip entirely for pure-state stores (just `withState` + optional `withComputed`) — there are no `Ops` to inject; the domain slice in `app-tree.ts` is the whole migration. Components inject `AppStore` and read `store.$.<domain>.*` directly.
|
|
584
|
+
|
|
585
|
+
**Sync vs async methods:** `Ops` methods don't all need to return `Observable<void>`. Sync setters return `void`; async I/O methods return `Observable<void>`. Pick by what the legacy method did:
|
|
586
|
+
|
|
587
|
+
```ts skip
|
|
588
|
+
@Injectable({ providedIn: 'root' })
|
|
589
|
+
export class FooOps {
|
|
590
|
+
private readonly _$ = inject(APP_TREE).$.foo;
|
|
591
|
+
|
|
592
|
+
// sync setter — returns void, no observable wrapping
|
|
593
|
+
setTheme(theme: 'light' | 'dark'): void {
|
|
594
|
+
this._$.theme.set(theme);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// async loader — returns Observable<void>, consumer subscribes
|
|
598
|
+
loadAll$(): Observable<void> {
|
|
599
|
+
this._$.itemsLoad.setLoading();
|
|
600
|
+
return this._fooService.loadAll$().pipe(
|
|
601
|
+
tap((items) => {
|
|
602
|
+
this._$.items.setAll(items);
|
|
603
|
+
this._$.itemsLoad.setLoaded();
|
|
604
|
+
}),
|
|
605
|
+
map(() => void 0),
|
|
606
|
+
catchError((err: unknown) => {
|
|
607
|
+
this._$.itemsLoad.setError(String(err));
|
|
608
|
+
return EMPTY;
|
|
609
|
+
})
|
|
610
|
+
);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
```
|
|
614
|
+
|
|
615
|
+
For a **completed migration** (no remaining `@ngrx/signals` consumers), `AppStore` injects every `Ops` class eagerly as a field:
|
|
616
|
+
|
|
617
|
+
```ts
|
|
618
|
+
// app-store.ts \u2014 goal-state pattern
|
|
619
|
+
@Injectable({ providedIn: 'root' })
|
|
620
|
+
export class AppStore {
|
|
621
|
+
readonly tree = inject(APP_TREE);
|
|
622
|
+
readonly $ = this.tree.$;
|
|
623
|
+
readonly ops = {
|
|
624
|
+
tickets: inject(TicketOps),
|
|
625
|
+
trucks: inject(TruckOps),
|
|
626
|
+
drivers: inject(DriversOps),
|
|
627
|
+
selection: inject(SelectionOps),
|
|
628
|
+
session: inject(SessionOps),
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
```
|
|
632
|
+
|
|
633
|
+
This exposes the **whole Ops surface** (`store.ops.tickets.loadTickets$()`, `store.ops.tickets.clearActive()`, etc.) without method-by-method delegation. Discoverable via IDE autocomplete. Adding a new method on `TicketOps` requires zero changes in `AppStore`.
|
|
634
|
+
|
|
635
|
+
The lazy `Injector.get(XOps)` pattern — and the "method-delegation object literal" shape — is **only** for [incremental migrations](#root-injected-ops-side-effect-hazard) where unrelated specs would otherwise trigger a constructor cascade through Ops they don't use. Once the migration completes, refactor to eager `inject()`.
|
|
636
|
+
|
|
637
|
+
**Smell that you forgot to refactor:** lazy `Injector.get` calls in `AppStore` after the last domain migrates. If `grep -l '@ngrx/signals' <app-src>/` returns nothing, eager injection is the correct shape.
|
|
638
|
+
|
|
639
|
+
## Custom feature patterns (signalStoreFeature)
|
|
640
|
+
|
|
641
|
+
> **Do not silently drop a `withFeature(...)` call during migration.** Custom features encode real behavior (error banners, refresh hooks, telemetry collectors). Map each one to its SignalTree equivalent or an `effect()` inside the `Ops` constructor — never reduce one to a no-op placeholder. If you legitimately want to drop the behavior, do it in a separate commit with a code-owner sign-off — not silently inside the migration PR.
|
|
642
|
+
|
|
643
|
+
The ngrx feature surface — `withFeature`, `withHooks`, `withProps` — collapses into three SignalTree shapes:
|
|
644
|
+
|
|
645
|
+
| ngrx primitive | SignalTree equivalent | Where it lives |
|
|
646
|
+
| ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------- |
|
|
647
|
+
| `withFeature(myFeature)` | `effect(() => { … })` reading `this._$.<leaf>()` | `Ops` class constructor |
|
|
648
|
+
| `withHooks({ onInit, onDestroy })` | `onInit` → `start(injector)` method called from `AppStore` constructor; `onDestroy` → an explicit `Subject<void>` field cancelled in `stop()` | `Ops` class |
|
|
649
|
+
| `withProps({ x$ })` | `readonly x$ = toObservable(this._$.x)` | `Ops` class |
|
|
650
|
+
|
|
651
|
+
### Worked example: `withErrorBanners` + `withRefreshHandling` + `withTelemetryBaggage`
|
|
652
|
+
|
|
653
|
+
Before (ngrx — three custom features stacked on `signalStore`):
|
|
654
|
+
|
|
655
|
+
```ts skip
|
|
656
|
+
export const DriverStore = signalStore(
|
|
657
|
+
{ providedIn: 'root' },
|
|
658
|
+
withReduxDevtools(driverStoreName),
|
|
659
|
+
withTelemetryBaggage(driverStoreName),
|
|
660
|
+
withState(initialState),
|
|
661
|
+
withErrorBanners(driverStoreName), // shows a banner on error
|
|
662
|
+
withRefreshHandling(driverStoreName), // hooks app-wide refresher
|
|
663
|
+
withMethods((store, driverService = inject(DriverService)) => ({
|
|
664
|
+
loadActiveDriver$: rxMethod<void>(/* … */),
|
|
665
|
+
clearCurrentDriver: () => patchState(store, { currentDriver: null }),
|
|
666
|
+
}))
|
|
667
|
+
);
|
|
668
|
+
```
|
|
669
|
+
|
|
670
|
+
After (SignalTree — features become opt-in side-effects on the `Ops` class). All collaborators are `inject(_, { optional: true })` so unrelated specs can construct `DriverOps` without dragging the whole banner / refresher / telemetry chain into the test bed:
|
|
671
|
+
|
|
672
|
+
```ts skip
|
|
673
|
+
@Injectable({ providedIn: 'root' })
|
|
674
|
+
export class DriverOps {
|
|
675
|
+
private readonly _tree = inject(APP_TREE);
|
|
676
|
+
private readonly _$ = this._tree.$.driver;
|
|
677
|
+
private readonly _driverService = inject(DriverService);
|
|
678
|
+
|
|
679
|
+
// Optional collaborators — see "Root-injected Ops side-effect hazard" above.
|
|
680
|
+
private readonly _banners = inject(BannerStore, { optional: true });
|
|
681
|
+
private readonly _refresher = inject(AppRefresherService, { optional: true });
|
|
682
|
+
private readonly _baggage = inject(TelemetryBaggageService, { optional: true });
|
|
683
|
+
|
|
684
|
+
constructor() {
|
|
685
|
+
// ↳ withErrorBanners equivalent: react to error leaf changes.
|
|
686
|
+
effect(() => {
|
|
687
|
+
const err = this._$.driverError();
|
|
688
|
+
if (err) this._banners?.show(BannerType.Error, err.message);
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
// ↳ withTelemetryBaggage equivalent: stamp baggage when the driver changes.
|
|
692
|
+
effect(() => {
|
|
693
|
+
const driver = this._$.currentDriver();
|
|
694
|
+
if (driver) this._baggage?.set('driver.id', driver.id);
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/** ↳ withHooks({ onInit }) equivalent — called once from AppStore. */
|
|
699
|
+
start(injector: Injector): void {
|
|
700
|
+
this._refresher?.register(injector, () => this.loadActiveDriver$().subscribe());
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
loadActiveDriver$(): Observable<void> {
|
|
704
|
+
/* … */
|
|
705
|
+
}
|
|
706
|
+
clearCurrentDriver(): void {
|
|
707
|
+
this._$.currentDriver.set(null);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
```
|
|
711
|
+
|
|
712
|
+
And in `AppStore`:
|
|
713
|
+
|
|
714
|
+
```ts skip
|
|
715
|
+
@Injectable({ providedIn: 'root' })
|
|
716
|
+
export class AppStore {
|
|
717
|
+
readonly tree = inject(APP_TREE);
|
|
718
|
+
readonly $ = this.tree.$;
|
|
719
|
+
readonly ops = { driver: inject(DriverOps) /* … */ } as const;
|
|
720
|
+
|
|
721
|
+
constructor() {
|
|
722
|
+
// Wire each Ops's withHooks({ onInit }) port exactly once.
|
|
723
|
+
const injector = inject(Injector);
|
|
724
|
+
this.ops.driver.start(injector);
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
```
|
|
728
|
+
|
|
729
|
+
Three tradeoffs to call out for the user before signing off the migration:
|
|
730
|
+
|
|
731
|
+
1. **`effect()` runs eagerly inside the injection context.** Wrap any side-effect that should not fire on initial state in a guard (`if (driver)` above) — `effect()` doesn't have ngrx's `withMethods` lazy semantics.
|
|
732
|
+
2. **`{ optional: true }` collaborators silently no-op when missing.** That's intentional for tests, but it also means a _production_ misconfiguration (forgot to `provideRefresher()`) will silently disable the feature instead of throwing. If the feature is mandatory in production, document that in the Ops class header and add a smoke test in `app.config.spec.ts`.
|
|
733
|
+
3. **`withProps({ x$: toObservable(store.x) })` only needs `inject(Injector)` if it runs outside the constructor.** Inside the constructor it's free: `readonly driver$ = toObservable(this._$.currentDriver);`.
|
|
734
|
+
|
|
735
|
+
Custom features that add state shape (e.g. `withLoadingState`, `withSavingState`, `withErrorState`) map to **built-in markers**, not factory functions. Don't recreate the feature as a helper — put the marker directly in the state object.
|
|
736
|
+
|
|
737
|
+
```ts skip
|
|
738
|
+
// ngrx — withLoadingState adds isLoading, error, etc.
|
|
739
|
+
withLoadingState();
|
|
740
|
+
|
|
741
|
+
// SignalTree — status() marker placed at the relevant path
|
|
742
|
+
load: status<string>();
|
|
743
|
+
```
|
|
744
|
+
|
|
745
|
+
The `status()` marker provides:
|
|
746
|
+
|
|
747
|
+
- `.setLoading()` / `.setLoaded()` / `.setError(e)` / `.setNotLoaded()`
|
|
748
|
+
- Boolean signals: `.loading()` / `.loaded()` / `.hasError()` / `.notLoaded()` — v10.3 canonical bare-name predicates; use these in templates and `computed()`. The `is`-prefix forms (`.isLoading()` etc.) were removed in v11 — use the bare names.
|
|
749
|
+
- Raw state via `.state()` — returns `Signal<LoadingState>`. **When comparing the raw value always import and use the `LoadingState` enum from `@signaltree/core`**; never compare to string literals (`'loading'`, `'loaded'`, etc.), which cause TypeScript errors.
|
|
750
|
+
|
|
751
|
+
```ts
|
|
752
|
+
import { LoadingState } from '@signaltree/core';
|
|
753
|
+
|
|
754
|
+
// ✓
|
|
755
|
+
tree.$.driver.load.state() === LoadingState.Loading;
|
|
756
|
+
|
|
757
|
+
// ✗ TypeScript error — string literals don't satisfy the enum type
|
|
758
|
+
tree.$.driver.load.state() === 'loading';
|
|
759
|
+
```
|
|
760
|
+
|
|
761
|
+
Prefer the boolean helpers over raw state comparisons wherever possible.
|
|
762
|
+
|
|
763
|
+
## rxMethod
|
|
764
|
+
|
|
765
|
+
`rxMethod` wraps an operator pipeline so that it can be called with static values or observables. **SignalTree does not ship a `rxMethod` primitive** — its callable-factory-inside-`withMethods` shape is NgRx-flavored and doesn't fit SignalTree's path-attached marker philosophy. **Two replacement options, in order of preference:**
|
|
766
|
+
|
|
767
|
+
### Option A — `asyncSource` / `asyncQuery` markers (canonical, preferred)
|
|
768
|
+
|
|
769
|
+
For load-and-expose and input-driven query patterns (the vast majority of `rxMethod` use cases), the SignalTree-native answer is a **marker at the tree path the data lives at**. No Ops class subscription, no manual `tap()`/`setLoading()` wiring — the materializer handles it.
|
|
770
|
+
|
|
771
|
+
```ts
|
|
772
|
+
import { signalTree, asyncSource, asyncQuery } from '@signaltree/core';
|
|
773
|
+
|
|
774
|
+
const store = signalTree({
|
|
775
|
+
// rxMethod<void>(pipe(switchMap(() => api.load$()))) → asyncSource
|
|
776
|
+
driver: asyncSource<Driver>({
|
|
777
|
+
initial: null,
|
|
778
|
+
load: () => api.loadDriver$(),
|
|
779
|
+
}),
|
|
780
|
+
|
|
781
|
+
// rxMethod<string>(pipe(debounceTime(300), switchMap(q => api.search$(q)))) → asyncQuery
|
|
782
|
+
search: asyncQuery<string, Result[]>({
|
|
783
|
+
initialResult: [],
|
|
784
|
+
debounce: 300,
|
|
785
|
+
query: (q) => api.search$(q),
|
|
786
|
+
}),
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
// Driver loaded automatically on tree construction; consumers just read:
|
|
790
|
+
store.$.driver(); // Driver | undefined
|
|
791
|
+
store.$.driver.loading(); // boolean
|
|
792
|
+
store.$.driver.error(); // unknown | null
|
|
793
|
+
store.$.driver.refresh(); // reload on demand
|
|
794
|
+
|
|
795
|
+
// Search input drives the debounced pipeline:
|
|
796
|
+
store.$.search.input.set('alice');
|
|
797
|
+
store.$.search(); // Result[]
|
|
798
|
+
```
|
|
799
|
+
|
|
800
|
+
See [`core.md` § asyncSource](core.md#asyncsourcetconfig) and [§ asyncQuery](core.md#asyncquerytinput-tresultconfig).
|
|
801
|
+
|
|
802
|
+
### Option B — plain Observable in an Ops class (manual fallback)
|
|
803
|
+
|
|
804
|
+
When neither marker fits — e.g., complex multi-step orchestration where the caller needs explicit subscription control — write a method that returns `Observable<void>` and subscribes at the call site, or fire-and-forget with `.subscribe()` internally:
|
|
805
|
+
|
|
806
|
+
```ts
|
|
807
|
+
import { inject, Injectable, InjectionToken } from '@angular/core';
|
|
808
|
+
import { signalTree, status } from '@signaltree/core';
|
|
809
|
+
import { catchError, EMPTY, map, tap } from 'rxjs';
|
|
810
|
+
import type { Observable } from 'rxjs';
|
|
811
|
+
|
|
812
|
+
interface DriverState {
|
|
813
|
+
currentDriver: { name: string } | null;
|
|
814
|
+
load: ReturnType<typeof status>;
|
|
815
|
+
}
|
|
816
|
+
const tree = signalTree({ driver: { currentDriver: null as { name: string } | null, load: status<string>() } });
|
|
817
|
+
type AppTree = typeof tree;
|
|
818
|
+
const APP_TREE = new InjectionToken<AppTree>('APP_TREE');
|
|
819
|
+
|
|
820
|
+
// ngrx style (before) — rxMethod wrapping a pipeline, patchState for writes
|
|
821
|
+
// readonly loadDriver = rxMethod<void>(
|
|
822
|
+
// pipe(switchMap(() => this.driverService.load$()
|
|
823
|
+
// .pipe(tap(d => patchState(this, { driver: d })))))
|
|
824
|
+
// );
|
|
825
|
+
|
|
826
|
+
@Injectable({ providedIn: 'root' })
|
|
827
|
+
class DriverOps {
|
|
828
|
+
private readonly _tree = inject(APP_TREE);
|
|
829
|
+
private readonly _$ = this._tree.$.driver;
|
|
830
|
+
private readonly _driverService: { load$(): Observable<{ name: string }> } = inject(Object as any);
|
|
831
|
+
|
|
832
|
+
// SignalTree style (after) — plain Observable method, tap() writes to tree
|
|
833
|
+
loadDriver$(): Observable<void> {
|
|
834
|
+
this._$.load.setLoading();
|
|
835
|
+
return this._driverService.load$().pipe(
|
|
836
|
+
tap((d) => {
|
|
837
|
+
this._$.currentDriver.set(d);
|
|
838
|
+
this._$.load.setLoaded();
|
|
839
|
+
}),
|
|
840
|
+
map(() => void 0),
|
|
841
|
+
catchError((err: unknown) => {
|
|
842
|
+
this._$.load.setError(String(err));
|
|
843
|
+
return EMPTY;
|
|
844
|
+
})
|
|
845
|
+
);
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
```
|
|
849
|
+
|
|
850
|
+
## Gotchas
|
|
851
|
+
|
|
852
|
+
- **Flat signal reads vs nested paths** — `signalStore` exposes every signal flatly on the store instance: `store.customers()`, `store.isLoading()`, `store.selectedCustomerExternalId()`. In SignalTree those signals live under the `$` proxy at their domain path: `appStore.$.ticket.customers()`, `appStore.$.ticket.loading()` (v10.3 canonical — formerly `isLoading`), `appStore.$.ticket.selectedCustomerExternalId()`. When converting a component that read many signals off the old store, **every read site must be updated** — it is easy to miss them because the old and new injection names look similar. After renaming the injection, grep the component for the old store variable name and make sure no bare `store.signalName()` calls remain.
|
|
853
|
+
|
|
854
|
+
```ts
|
|
855
|
+
// ngrx/signals — signals flat on the store instance
|
|
856
|
+
// readonly customers = inject(TicketStore).customers; // a Signal<Customer[]>
|
|
857
|
+
// template: {{ customers() }}
|
|
858
|
+
|
|
859
|
+
// SignalTree — signals at their domain path through AppStore
|
|
860
|
+
private readonly _store = inject(AppStore);
|
|
861
|
+
readonly customers = this._store.$.ticket.customers; // same Signal<Customer[]>
|
|
862
|
+
// template: {{ customers() }} ← template unchanged; only the source path changed
|
|
863
|
+
```
|
|
864
|
+
|
|
865
|
+
- **`withEntities` uses an EntityMap** — SignalTree's `entityMap` is normalized (id → entity). If the ngrx store used array-based entities without ids, add a key selector.
|
|
866
|
+
- **`patchState` was batched** — SignalTree branch writes (`tree.$.domain(updater)`) auto-batch. Individual `.set()` calls on separate leaves do NOT batch unless you add `batching()`. If you're setting multiple sibling leaves, use a branch updater or add `batching()`.
|
|
867
|
+
- **`withHooks({ onDestroy })` cleanup** — use Angular `DestroyRef` injected in the constructor and register cleanup with `destroyRef.onDestroy(() => ...)`.
|
|
868
|
+
- **Feature store as service** — the ngrx `signalStore` is a service under the hood. The Ops class is its replacement. Keep `providedIn: 'root'` for app-wide operations; use component providers only for component-local state.
|
|
869
|
+
- **No `inject()` outside injection context** — `APP_TREE` must be injected in a constructor or field initializer, not in a plain function called at module load time.
|
|
870
|
+
- **Spec file placement after migration** — ngrx feature stores bundle state + methods in one class, so tests for both often live in the component spec or a single store spec. After migration, tests that exercise ops methods (loading data, triggering writes, cascading logic) belong in a spec file co-located with the Ops class (e.g. `ticket.ops.spec.ts`), not in the component spec. Component specs should only test rendering and UI interaction. Move any test that calls `store.loadTickets()`, `store.createTicket()`, or similar methods to the ops spec; leave template/binding tests in the component spec.
|
|
871
|
+
|
|
872
|
+
## Test bed must provide `APP_TREE`
|
|
873
|
+
|
|
874
|
+
After replacing an `@ngrx/signals` store with `AppStore` + `Ops`, every existing `TestBed` that mocked the old store will start failing with:
|
|
875
|
+
|
|
876
|
+
```text
|
|
877
|
+
NG0201: No provider found for `InjectionToken APP_TREE`.
|
|
878
|
+
```
|
|
879
|
+
|
|
880
|
+
…even when `AppStore` is mocked with `useValue`, because Angular still instantiates the legacy facade adapter (or any other `providedIn: 'root'` consumer) which itself injects `AppStore`, which injects `APP_TREE`.
|
|
881
|
+
|
|
882
|
+
The fix is mechanical:
|
|
883
|
+
|
|
884
|
+
1. Export `createBaseState()` from `app-tree.ts`.
|
|
885
|
+
2. Add `app-tree.testing.ts` exporting `provideAppTreeForTesting()`. The un-enhanced testing tree is structurally narrower than the production `AppTree` (no `devTools` / `batching` / `timeTravel`), so the factory must cast:
|
|
886
|
+
|
|
887
|
+
```ts skip
|
|
888
|
+
// app-tree.testing.ts
|
|
889
|
+
export function provideAppTreeForTesting(overrides?: (s: ReturnType<typeof createBaseState>) => ReturnType<typeof createBaseState>): Provider[] {
|
|
890
|
+
return [
|
|
891
|
+
{
|
|
892
|
+
provide: APP_TREE,
|
|
893
|
+
useFactory: (): AppTree => {
|
|
894
|
+
const base = createBaseState();
|
|
895
|
+
const seeded = overrides ? overrides(base) : base;
|
|
896
|
+
// Cast: tree without enhancers satisfies every consumer that
|
|
897
|
+
// only reads `$` / writes signals.
|
|
898
|
+
return signalTree(seeded) as unknown as AppTree;
|
|
899
|
+
},
|
|
900
|
+
},
|
|
901
|
+
];
|
|
902
|
+
}
|
|
903
|
+
```
|
|
904
|
+
|
|
905
|
+
3. **For a small migration (≤ 5 affected spec files):** add `provideAppTreeForTesting()` to each failing TestBed's `providers`.
|
|
906
|
+
4. **For an existing large app (many parameterised testing helpers):** register `provideAppTreeForTesting()` once globally via `getTestBed().initTestEnvironment(...)` in `test-setup.ts`. This is documented in [`testing.md`](./testing.md#wiring-app_tree-once-for-a-large-existing-test-suite). Each spec still gets an isolated tree because `useFactory` runs per child injector.
|
|
907
|
+
5. Do **not** mock `AppStore` or the legacy adapter to "make the error go away" — the underlying `APP_TREE` still needs to exist for any transitive consumer.
|
|
908
|
+
|
|
909
|
+
Full recipe and matrix (which layer to mock per test type) in [`testing.md`](./testing.md).
|
|
910
|
+
|
|
911
|
+
### Specs that called `patchState(store, …)` on the real store
|
|
912
|
+
|
|
913
|
+
Specs that previously called `patchState(legacyStore, { … })` to seed state on the **real** legacy store (rather than mocking it) will throw `Reflect.ownKeys called on non-object` after the migration — the legacy facade no longer derives from `signalStore`, so `patchState` has nothing to patch.
|
|
914
|
+
|
|
915
|
+
Replace with one of:
|
|
916
|
+
|
|
917
|
+
- **Direct tree write** — preferred when the spec needs to set state arbitrarily mid-test:
|
|
918
|
+
```ts
|
|
919
|
+
TestBed.inject(APP_TREE).$.<domain>(s => ({ ...s, ...overrides }));
|
|
920
|
+
```
|
|
921
|
+
- **`overrides` callback on `provideAppTreeForTesting()`** — preferred when the spec needs the seeded state from the start:
|
|
922
|
+
```ts
|
|
923
|
+
providers: [provideAppTreeForTesting((s) => ({ ...s, driver: { ...s.driver, currentDriver: { id: 1 } } }))];
|
|
924
|
+
```
|
|
925
|
+
|
|
926
|
+
**`patchState(legacyStore, setAllEntities([...]))` and the entity-op family** rewrite to direct `entityMap` API calls on the seeded tree:
|
|
927
|
+
|
|
928
|
+
```ts skip
|
|
929
|
+
// before
|
|
930
|
+
patchState(legacyStore, setAllEntities(plants));
|
|
931
|
+
patchState(legacyStore, addEntity(p));
|
|
932
|
+
patchState(legacyStore, updateEntity({ id, changes }));
|
|
933
|
+
patchState(legacyStore, removeEntity(id));
|
|
934
|
+
|
|
935
|
+
// after
|
|
936
|
+
TestBed.inject(APP_TREE).$.<domain>.entities.setAll(plants);
|
|
937
|
+
TestBed.inject(APP_TREE).$.<domain>.entities.addOne(p);
|
|
938
|
+
TestBed.inject(APP_TREE).$.<domain>.entities.updateOne(id, changes);
|
|
939
|
+
TestBed.inject(APP_TREE).$.<domain>.entities.removeOne(id);
|
|
940
|
+
```
|
|
941
|
+
|
|
942
|
+
Do not call any `Ops` method to seed state in tests — `Ops` are for runtime behaviour, not test fixtures.
|
|
943
|
+
|
|
944
|
+
## Keep the legacy facade — adapt its internals (fallback only)
|
|
945
|
+
|
|
946
|
+
> Use this only when [big-bang isn't feasible](./optimal-implementation.md#when-the-hybrid-pattern-is-acceptable). Big-bang gives a cleaner end-state and avoids the dual-store maintenance tax.
|
|
947
|
+
|
|
948
|
+
When the existing `@ngrx/signals` store (e.g. `DriverStore`) is referenced by dozens of components and specs and you cannot land the full migration in one PR, do not rename it. Replace its internals with a small adapter over `AppStore` so the public shape is preserved while the implementation moves to SignalTree. See [`patterns.md`](./patterns.md#hybrid-migration-legacy-facade-adapters) for the adapter pattern. The legacy spec mocks (`Mock<DriverStore>`) keep working — they now mock the adapter's interface instead of the original `signalStore` instance.
|
|
949
|
+
|
|
950
|
+
**Every adapter must ship with a deletion deadline.** Add `// TODO(legacy-facade): remove by <date/release>` and open a tracking issue. A facade without a deletion plan becomes a permanent second store — the worst possible end-state.
|
|
951
|
+
|
|
952
|
+
## Definition of done
|
|
953
|
+
|
|
954
|
+
A migration is **not done** when the build is green. See [`optimal-implementation.md`](./optimal-implementation.md#definition-of-done) for the full checklist. The hard gates:
|
|
955
|
+
|
|
956
|
+
- `grep -rln '@ngrx/signals' src/app/` returns nothing in the migrated app.
|
|
957
|
+
- `@ngrx/signals` (and any related toolkit packages) removed from `package.json` (or tracked for removal if a shared lib elsewhere still uses them).
|
|
958
|
+
- All adapter facades either deleted or carrying a deletion-deadline comment + tracking issue.
|
|
959
|
+
- Test suite green; lint clean; DevTools shows the tree under its `treeName`.
|
|
960
|
+
|
|
961
|
+
### Architectural self-check (run on your own diff before declaring done)
|
|
962
|
+
|
|
963
|
+
The verifier and the gates above catch import-level and dependency-level regressions. They cannot catch shape-level mistakes — the migration can be green and still ship the wrong architecture. Before you declare done, answer each item below against your own diff. "N/A per app shape audit" is a valid answer; "I forgot" is not.
|
|
964
|
+
|
|
965
|
+
1. **Selection placement.** If Pattern #1 applies: is `selected` a top-level slice (`$.selected.<key>()`), not nested inside a domain (`$.<domain>.selected.<key>()`)? If Pattern #1 does not apply (singleton-only app): is there _no_ `selected` slice at all?
|
|
966
|
+
2. **Load + error siblings.** For each domain slice that does async I/O: does it have a `xLoad: status<string>()` field? If the app has a typed error model, does it also have a `xError: Nullable<AppError>` sibling? If not, is the absence intentional per audit Q2?
|
|
967
|
+
3. **Derived tiers.** If ≥ 1 derivation is reused across consumers: is there a `derived/tier-<name>.derived.ts` file with a typed `AppTreeWith<Name>` alias? If every derivation is one-shot: is there _no_ tier file (component-local `computed()` only)?
|
|
968
|
+
4. **Orchestration boundary.** Does any single `Ops` class inject ≥ 2 other `Ops` classes? If yes, that's a smell — extract to a `<purpose>Ops`. If audit Q3 said no cross-domain lifecycle: is there _no_ orchestration `Ops`?
|
|
969
|
+
5. **`AppStore` injection style.** Post-migration (no `@ngrx/signals` consumers left): does `AppStore` use eager `inject(XOps)` fields, not lazy `Injector.get(XOps)` calls? Mid-incremental-migration: is the lazy pattern only on Ops whose collaborators trigger NG0201 cascades in unrelated specs?
|
|
970
|
+
|
|
971
|
+
If any answer is "yes, that's wrong" or "I'm not sure," fix the shape before merging. Retrofitting these later is the most expensive churn in the entire migration.
|