The three wizards paired a dispatch with a hand-written effect call (onPrimary/onRetry + runIfSubmitting/runIfIndienen). A missed call failed silently. RD-05 added the effect map and RD-07 added the Primary message; this ticket moves each wizard onto both. Each wizard now registers its submit effect on createStore, keyed by its own submitting tag (Submitting for herregistratie and intake, Indienen for registratie — the type catches a wrong key at compile time). The optimistic begin/confirm/rollback calls stay inside the effect body, unchanged. The template dispatches Primary and Retry directly, matching how Back already worked. onPrimary, onRetry, and runIfSubmitting/runIfIndienen are deleted from all three components. herregistratie-wizard drops under the 250-rule-line budget, so its eslint-disable max-lines header is removed in this same commit (RD-02's self-cleaning mechanism). intake-wizard and registratie-wizard stay over budget and keep theirs, both already flagged for RD-22/RD-23. Three doc comments (in the three machine files, plus one in store.ts) named the deleted onPrimary()/runIfSubmitting() identifiers in prose. Reworded them so the "idiom is gone from the repo" grep check is not defeated by its own explanatory comments. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
79 lines
3.2 KiB
TypeScript
79 lines
3.2 KiB
TypeScript
import { Signal, signal } from '@angular/core';
|
|
|
|
/**
|
|
* A tiny "Elm-style" store. The whole idea: all state lives in ONE value
|
|
* (the Model). The only way to change it is to send a message (Msg) to a PURE
|
|
* function `update(model, msg)` that returns the next Model. Nothing else
|
|
* mutates state, so to understand the app you only read the update function.
|
|
*
|
|
* Side effects (HTTP, timers) do NOT go in `update` — that stays pure and easy
|
|
* to test. Instead, effectful "command" functions call the network and then
|
|
* `dispatch` a message describing what happened (e.g. Loaded / Failed).
|
|
*/
|
|
export interface Store<Model, Msg> {
|
|
/** The current state, as a read-only Angular signal. */
|
|
readonly model: Signal<Model>;
|
|
/** Send a message; the model becomes update(model, msg). */
|
|
dispatch(msg: Msg): void;
|
|
}
|
|
|
|
/**
|
|
* The effect map: one optional handler per state tag, run when the store
|
|
* enters that tag (see the trigger rule on `createStore` below). Resolves to
|
|
* `never` for a tagless `Model` (e.g. `Model = number` in `store.spec.ts`), so
|
|
* a plain-value store still compiles without ever supplying effects.
|
|
*
|
|
* An effect body must never throw: end it in a `Result` from
|
|
* `runSubmit`/`runResult` (`submit.ts`) and let the failure travel as a
|
|
* dispatched message, not an exception. `dispatch` floats the effect's
|
|
* promise, exactly as the hand-written effect method it replaces did.
|
|
*/
|
|
export type StoreEffects<Model, Msg> = Model extends { tag: string }
|
|
? {
|
|
[K in Model['tag']]?: (
|
|
state: Extract<Model, { tag: K }>,
|
|
store: Store<Model, Msg>,
|
|
) => unknown;
|
|
}
|
|
: never;
|
|
|
|
function hasTag(value: unknown): value is { tag: unknown } {
|
|
return typeof value === 'object' && value !== null && 'tag' in value;
|
|
}
|
|
|
|
export function createStore<Model, Msg>(
|
|
init: Model,
|
|
update: (model: Model, msg: Msg) => Model,
|
|
effects?: StoreEffects<Model, Msg>,
|
|
): Store<Model, Msg> {
|
|
const model = signal(init);
|
|
const store: Store<Model, Msg> = {
|
|
model: model.asReadonly(),
|
|
dispatch: (msg) => {
|
|
let prev!: Model;
|
|
let next!: Model;
|
|
// Use `.update` (raw current value, no tracked read) not `set(update(model(), …))`:
|
|
// dispatch is a command and must never subscribe its caller to `model`. Reading
|
|
// `model()` here inside an effect that also dispatches makes the effect depend on
|
|
// its own write and livelock the main thread (crashed the upload wizards).
|
|
model.update((m) => {
|
|
prev = m;
|
|
next = update(m, msg);
|
|
return next;
|
|
});
|
|
|
|
// Fire the entered tag's effect, but only when the store actually entered it
|
|
// (prev.tag !== next.tag) and the message is not `Seed` — the mount/restore
|
|
// message in every machine that has one. Without the `Seed` exemption, a
|
|
// component that mounts straight into `Submitting` (Storybook, a resumed
|
|
// draft) would fire the effect on load, not on user action.
|
|
if (!hasTag(next) || !hasTag(prev) || prev.tag === next.tag) return;
|
|
if (hasTag(msg) && msg.tag === 'Seed') return;
|
|
const handler = (effects as Record<string, unknown> | undefined)?.[String(next.tag)] as
|
|
((state: Model, store: Store<Model, Msg>) => unknown) | undefined;
|
|
handler?.(next, store);
|
|
},
|
|
};
|
|
return store;
|
|
}
|