feat(fp): WP-06 — kill $any() in templates (18x)

Make AsyncLoadedDirective generic with a static ngTemplateContextGuard for
AsyncComponent's own internal typing. That can't propagate to consumer
`<ng-template appAsyncLoaded let-p>` sites though -- Angular only infers a
structural directive's type parameter from an input bound on that same
node, not from a sibling input on the parent component -- so the ~9
root-cause consumers (dashboard, registration-detail, aanvraag-detail,
registratie-wizard) instead unwrap the RemoteData Success value via a
typed computed() and narrow it locally with `@if (x(); as p)`. The
remaining union-narrowing casts (registration-summary, showcase concepts
page) are replaced with a stable @let binding and a direct resource read,
respectively. Documented as a deviation in WP-06's backlog file.
This commit is contained in:
2026-07-03 21:27:01 +02:00
parent 34d34512b3
commit 199cbe1f8c
10 changed files with 514 additions and 328 deletions

View File

@@ -45,7 +45,7 @@ for its existing violations, so every WP ends green.
| [WP-03](WP-03-contracts-purity.md) | Boundaries I: contracts purity + ApiClient confinement | 0 · gates | done |
| [WP-04](WP-04-ui-not-infrastructure.md) | Boundaries II: `ui ↛ infrastructure` + showcase sanction | 0 · gates | done |
| [WP-05](WP-05-parse-boundaries.md) | Parse-don't-validate closure + MDX | 1 · FP/DDD | done |
| [WP-06](WP-06-typed-async.md) | Generic async template contexts — kill `$any()` | 1 · FP/DDD | todo |
| [WP-06](WP-06-typed-async.md) | Generic async template contexts — kill `$any()` | 1 · FP/DDD | done |
| [WP-07](WP-07-brief-idioms.md) | Brief on the shared idioms + RemoteData MDX | 1 · FP/DDD | todo |
| [WP-08](WP-08-store-idiom.md) | One store idiom + machine naming + TEA MDX | 1 · FP/DDD | todo |
| [WP-09](WP-09-pure-logic.md) | Pure-logic closure: dates + missing command specs | 1 · FP/DDD | todo |

View File

@@ -1,6 +1,6 @@
# WP-06 — Generic async template contexts: kill `$any()` (18×)
Status: todo
Status: done
Phase: 1 — FP/DDD core
## Why
@@ -44,19 +44,43 @@ let-p>` consumer must cast. The rest are template union-narrowing workarounds.
## Acceptance criteria
- [ ] `grep -rn '\$any(' src/app` → zero hits.
- [ ] No `as` casts added to compensate in component classes (typed getters are fine).
- [ ] Build green with strict template checking.
- [x] `grep -rn '\$any(' src/app` → zero hits.
- [x] No `as` casts added to compensate in component classes (typed getters are fine).
- [x] Build green with strict template checking.
## Verification
GREEN + `npm run test-storybook:ci`. Smoke: dashboard + registration detail render.
GREEN + `npm run test-storybook:ci` (one unrelated flake on `review-section.stories.ts`'s
smoke-test timeout, confirmed by re-running green — untouched by this WP). Manual smoke
via a running `docker compose` stack + Playwright: logged in, drove `/dashboard`,
`/registratie` (registration-detail), `/aanvraag/:id`, `/concepts`, and the
`/registreren` wizard through the beroep step (both the DUO-match and the "mijn diploma
staat er niet bij" handmatig branch) — every fixed template renders its real data with
no console errors.
## Out of scope
## Deviation from the original plan
Brief page's `<app-async>` adoption (WP-07 — it depends on this WP's typing).
`AsyncLoadedDirective<T>` + `static ngTemplateContextGuard` **was added** (per the
Decisions block) and is real, working generic typing for `AsyncComponent`'s own
internals. But it does **not**, and structurally **cannot**, remove `$any()` at the ~9
"root cause" consumer sites (dashboard, registration-detail, aanvraag-detail): Angular
only infers a structural directive's type parameter from an **input bound on that same
node** (see `NgFor`'s `ngForOf`, or `*ngIf="x as y"`'s `ngIf` input) — a generic on a
directive that has no input of its own cannot inherit a type from a sibling input on the
parent `<app-async>` element, even though the two are nested in the same template. This
is a hard limitation of Angular's template type-checker, not a gap in this
implementation (confirmed against the documented `ngTemplateContextGuard` pattern and by
the compiler continuing to type `let-p` as `unknown` after the generic was added).
## Risks
The actual fix for those sites uses the WP's own sanctioned fallback wording ("typed
getters are fine"): each consumer gets a small `computed()` that unwraps the `RemoteData`
Success value, and the template narrows it locally with `@if (x(); as p)` inside the
`appAsyncLoaded` slot (no `let-p` on the directive itself). `registratie-wizard` reused
its existing `duoData` computed instead of adding a new one. The registration-summary
union-narrowing case used the anticipated `@switch` fix, but needed a `@let status =
reg().status` binding first — `@switch`/`@case` only narrows a stable local, not a
repeated `reg().status` function call. The showcase fake-resource case (`successRes`)
just reads `successRes.value()` directly in the `@for`, skipping `let-v` entirely.
Angular generic-component inference edge cases — the documented fallback keeps the WP
bounded.
`AsyncComponent`'s public API (`[data]`/`[resource]` inputs) is unchanged, so this
deviation is contained to consumer templates, as the WP intended.