Files
atomic-design-poc/libs/shared/src/application/store.ts
T
ehoandClaude Sonnet 5 b8aced75e3 refactor: migrate the 3 wizards to the effect map and Primary (RD-08)
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>
2026-09-04 17:20:52 +02:00

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;
}